@@ -471,3 +471,258 @@ Spring for GraphQL defines a `SortStrategy` to create `Sort` from GraphQL argume
471471`AbstractSortStrategy` implements the contract with abstract methods to extract the sort
472472direction and properties. To enable support for `Sort` as a controller method argument,
473473you need to declare a `SortStrategy` bean.
474+
475+
476+
477+ [[data.transaction-management]]
478+ == Transaction Management
479+
480+ At some point when working with data then transactional boundaries start to matter.
481+ GraphQL itself does not define any transaction semantics, so it is up to the server
482+ and your application to decide how to handle transactions.
483+
484+ Spring Data repositories use implicit transactions for individual operations resulting
485+ in starting and commiting a transaction for each repository method call. This is the
486+ normal mode of operation for most NoSQL databases but not necessarily what you
487+ would want for relational databases.
488+
489+ You have several options how you could manage transactions in a GraphQL server:
490+
491+ 1. <<data.transaction-management.transactional-service-methods,Use transactional service methods>>
492+ 2. <<data.transaction-management.instrumentation,Using a `Instrumentation` that manages transactions programmatically>>
493+ 3. <<data.transaction-management.execution-strategy,Implement a custom `ExecutionStrategy` that manages transactions programmatically>>
494+
495+ We generally recommend isolating transaction management in the controller or service.
496+ Any extended use (such as batch loading) might have their own requirements to transaction
497+ management and so you might want to consider the other two options.
498+
499+ [[data.transaction-management.transactional-service-methods]]
500+ === Transactional Service Methods
501+
502+ Use Spring's `@Transactional` annotation on service methods that are
503+ invoked from your `@QueryMapping` controller method. This is the recommended approach
504+ as it gives you full control over the transaction boundaries with a clearly defined
505+ entrypoint, for example:
506+
507+ [source,java,indent=0,subs="verbatim,quotes"]
508+ ----
509+ @Controller
510+ public class AccountController {
511+
512+ @QueryMapping
513+ @Transactional
514+ public Account accountById(@Argument String id) {
515+ ... // fetch the entire account object within a single transaction
516+ }
517+ }
518+ ----
519+
520+ A transaction spans from entering the `accountById` method until it returns meaning
521+ that the entire object must be materialized within that method. Taking GraphQL's core
522+ design principles into account, this is not idiomatic as it limits the ability of the
523+ client to drive the query and forces the server to load the entire object graph upfront.
524+
525+ Another aspect to consider is that subsequent data fetching for nested fields is not part
526+ of the transactional method `accountById` as the transaction is cleaned up after leaving
527+ the method.
528+
529+ [source,java,indent=0,subs="verbatim,quotes"]
530+ ----
531+ @Controller
532+ public class AccountController {
533+
534+ @QueryMapping
535+ @Transactional
536+ public Account accountById(@Argument String id) {
537+ ... // fetch the account within a transaction
538+ }
539+
540+ @SchemaMapping
541+ @Transactional
542+ public Person person(Account account) {
543+ ... // fetching the person within a separate transaction
544+ }
545+ }
546+ ----
547+
548+ Using `@Transactional` on multiple controller methods is possible and in fact
549+ recommended when applying mutations. Any transactional queries that would resolve nested
550+ fields fall into their own transaction.
551+
552+ You can replace Spring's `@Transactional` with `TransactionOperations` or
553+ `TransactionalOperator` if you wish to manage the transaction programmatically instead
554+ of using Spring's aspect-oriented programming (AOP) support for transactions.
555+
556+
557+ [[data.transaction-management.instrumentation]]
558+ === Transactional Instrumentation
559+
560+ GraphQL Java's `Instrumentation` contract allows you to hook into the execution lifecycle
561+ at various stages. The Instrumentation SPI was designed with observability in mind, yet it
562+ serves as execution-agnostic extension points regardless of whether you're using synchronous
563+ reactive, or any other asynchronous form to invoke data fetchers.
564+
565+ Spanning an instrumentation over the entire execution creates an enclosing transaction.
566+ Ideally, the underlying `ExecutionStrategy` runs `DataFetcher` invocations serially so
567+ that all invocations are executed on the same `Thread`. This is mandatory: Synchronous
568+ transaction management uses `ThreadLocal` state to allow participation in transactions.
569+
570+ Another aspect of creating an outer transaction is that it spans across the entire execution.
571+ All `@SchemaMapping` controller methods participate in the transaction regardless whether
572+ they are invoked for the root, nested fields, or as part of a mutation. When participating
573+ in an ongoing transaction, transactional controller or service methods can declare
574+ transactional attributes such as propagation behavior `REQUIRES_NEW` to start
575+ a new transaction.
576+
577+ An example transactional `Instrumentation` must start a transaction before execution and
578+ clean up the transaction after execution completes. An example instrumentation could look
579+ like as follows:
580+
581+ [source,java,indent=0,subs="verbatim,quotes"]
582+ ----
583+ class TransactionalInstrumentation implements Instrumentation {
584+
585+ private final Log logger = LogFactory.getLog(getClass());
586+
587+ private final PlatformTransactionManager txManager;
588+ private final TransactionDefinition definition;
589+
590+ TransactionalInstrumentation(PlatformTransactionManager txManager, TransactionDefinition definition) {
591+ this.transactionManager = transactionManager;
592+ this.definition = definition;
593+ }
594+
595+ @Override
596+ public @Nullable InstrumentationContext<ExecutionResult> beginExecution(
597+ InstrumentationExecutionParameters parameters, InstrumentationState state) {
598+
599+ TransactionStatus status = this.transactionManager.getTransaction(definition);
600+
601+ return SimpleInstrumentationContext.whenCompleted((result, t) -> {
602+ if (t != null) {
603+ rollbackOnException(status, t);
604+ } else {
605+ for (GraphQLError error : result.getErrors()) {
606+ if (error instanceof ExceptionWhileDataFetching e) {
607+ rollbackOnException(status, e.getException());
608+ return;
609+ }
610+ }
611+ this.transactionManager.commit(status);
612+ }
613+ });
614+ }
615+
616+ private void rollbackOnException(TransactionStatus status, Throwable ex) throws TransactionException {
617+
618+ logger.debug("Initiating transaction rollback on application exception", ex);
619+ try {
620+ this.transactionManager.rollback(status);
621+ } catch (TransactionSystemException ex2) {
622+ logger.error("Application exception overridden by rollback exception", ex);
623+ ex2.initApplicationException(ex);
624+ throw ex2;
625+ } catch (RuntimeException | Error ex2) {
626+ logger.error("Application exception overridden by rollback exception", ex);
627+ }
628+ }
629+ }
630+ ----
631+
632+ This implementation repeats parts that reside in `TransactionTemplate` for proper
633+ transaction management. Another aspect to consider is that the above implementation relies
634+ on `ExceptionWhileDataFetching` that is only available if the underlying
635+ `ExecutionStrategy` uses `SimpleDataFetcherExceptionHandler`. By default, Spring GraphQL
636+ falls back to an internal `GraphQLError` that doesn't expose the original exception.
637+
638+ If there is already an `Instrumentation` configured, then you need to go a step further
639+ and use the `ExecutionStrategy` method.
640+
641+ [[data.transaction-management.execution-strategy]]
642+ === Transactional `ExecutionStrategy`
643+
644+ Implementing an own transactional `ExecutionStrategy` is similar to the `Instrumentation`
645+ approach, but it gives you more control over the execution.
646+
647+ It can also serve as good entry point to implement custom directives that allow clients
648+ specifying transactional attributes through directives or using directives in your schema
649+ to demarcate transactional boundaries for certain queries or mutations.
650+
651+ An `ExecutionStrategy` provides full control over the execution and opens a multitude
652+ of possibilities over how to communicate failed transactions or errors during transaction
653+ cleanup back to the client.
654+
655+ The following block shows a rather simple example implementation without considering
656+ all invariants of exception handling:
657+
658+ [source,java,indent=0,subs="verbatim,quotes"]
659+ ----
660+ static class TransactionalExecutionStrategy extends AsyncSerialExecutionStrategy {
661+
662+ protected final Log logger = LogFactory.getLog(getClass());
663+
664+ private final PlatformTransactionManager transactionManager;
665+ private final TransactionDefinition definition;
666+
667+ public TransactionalExecutionStrategy(PlatformTransactionManager transactionManager,
668+ TransactionDefinition definition) {
669+ this.transactionManager = transactionManager;
670+ this.definition = definition;
671+ }
672+
673+ @Override
674+ public CompletableFuture<ExecutionResult> execute(ExecutionContext executionContext,
675+ ExecutionStrategyParameters parameters) throws NonNullableFieldWasNullException {
676+
677+ TransactionStatus status = this.transactionManager.getTransaction(definition);
678+ try {
679+ return super.execute(executionContext, parameters).whenComplete((result, exception) -> {
680+
681+ if (exception != null) {
682+ rollbackOnException(status, exception);
683+ } else {
684+
685+ for (GraphQLError error : result.getErrors()) {
686+ if (error instanceof ExceptionWhileDataFetching e) {
687+ rollbackOnException(status, e.getException());
688+ return;
689+ }
690+ }
691+
692+ this.transactionManager.commit(status);
693+ }
694+ });
695+ } catch (RuntimeException | Error ex) {
696+ // Transactional code threw application exception -> rollback
697+ return CompletableFuture.failedFuture(rollbackOnException(status, ex));
698+ } catch (Throwable ex) {
699+ // Transactional code threw unexpected exception -> rollback
700+ return CompletableFuture.failedFuture(new UndeclaredThrowableException(rollbackOnException(status, ex),
701+ "TransactionCallback threw undeclared checked exception"));
702+ }
703+ }
704+
705+ /**
706+ * Perform a rollback, handling rollback exceptions properly.
707+ *
708+ * @param status object representing the transaction
709+ * @param ex the thrown application exception or error
710+ */
711+ private Throwable rollbackOnException(TransactionStatus status, Throwable ex) {
712+
713+ logger.debug("Initiating transaction rollback on application exception", ex);
714+ try {
715+ this.transactionManager.rollback(status);
716+ } catch (TransactionSystemException ex2) {
717+ logger.error("Application exception overridden by rollback exception", ex);
718+ ex2.initApplicationException(ex);
719+ return ex2;
720+ } catch (RuntimeException | Error ex2) {
721+ logger.error("Application exception overridden by rollback exception", ex);
722+ return ex2;
723+ }
724+
725+ return ex;
726+ }
727+ }
728+ ----
0 commit comments