@@ -477,252 +477,171 @@ you need to declare a `SortStrategy` bean.
477477[[data.transaction-management]]
478478== Transaction Management
479479
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.
480+ At some point when working with data atomicity and isolation of operations start to
481+ matter. These are both properties of transactions. GraphQL itself does not define any
482+ transaction semantics, so it is up to the server and your application to decide how to
483+ handle transactions.
483484
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 .
485+ GraphQL and specifically GraphQL Java are designed to be non-opinionated about how data
486+ is fetched. A core property of GraphQL is that clients drive the query; Fields
487+ can be resolved independently of their original source to allow for composition.
488+ A reduced fieldset can require less data to be fetched resulting in better performance .
488489
489- You have several options how you could manage transactions in a GraphQL server :
490+ Applying the concept of distributed field resolution within transactions is not a good fit :
490491
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>>
492+ * Transactions keep a unit of work together resulting typically in fetching the entire
493+ object graph (like a typical object-relational mapper would behave) within a single
494+ transaction. This is at odds with GraphQL's core design to let the client drive queries.
495+
496+ * Keeping a transaction open across multiple data fetchers of which each one would
497+ fetch only its flat object mitigates the performance aspect and aligns with decoupled
498+ field resolution, but it can lead to long-running transactions that hold on to resources
499+ for longer than necessary.
500+
501+ Generally speaking, transactions are best applied to mutations that change state and not
502+ necessarily to queries that just read data. However, there are use cases where
503+ transactional reads are required.
504+
505+ GraphQL is designed to support multiple mutations within a single query. Depending on
506+ the use case, you might want to:
507+
508+ * Run each mutation within its own transaction.
509+ * Keep some mutations within a single transaction to ensure a consistent state.
510+ * Span a single transaction over all involved mutations.
511+
512+ Each approach requires a slightly different transaction management strategy.
513+
514+ By default, (and without any further instrumentation) Spring Data repositories use implicit
515+ transactions for individual operations resulting in starting and commiting a transaction
516+ for each repository method call. This is the normal mode of operation for most databases.
517+
518+ The following sections are outlining two different strategies to manage transactions in a
519+ GraphQL server:
520+
521+ 1. <<data.transaction-management.transactional-service-methods,Transaction per Controller Method>>
522+ 2. <<data.transaction-management.transactional-instrumentation,Spanning a Transaction programmatically over the entire request>>
494523
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.
498524
499525[[data.transaction-management.transactional-service-methods]]
500526=== Transactional Service Methods
501527
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:
528+ The simplest approach to manage transactions is to use Spring's Transaction Management
529+ together with `@MutationMapping` controller methods (or any other `@SchemaMapping` method)
530+ for example:
506531
507- [source,java,indent=0,subs="verbatim,quotes"]
532+ [tabs]
533+ ======
534+ Declarative::
535+ +
536+ [source,java,indent=0,subs="verbatim,quotes,attributes",role="primary"]
508537----
509538@Controller
510539public class AccountController {
511540
512- @QueryMapping
541+ @MutationMapping
513542 @Transactional
514- public Account accountById (@Argument String id ) {
515- ... // fetch the entire account object within a single transaction
543+ public Account addAccount (@Argument AccountInput input ) {
544+ // ...
516545 }
517546}
518547----
519548
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"]
549+ Programmatic::
550+ +
551+ [source,java,indent=0,subs="verbatim,quotes,attributes",role="secondary"]
530552----
531553@Controller
532554public class AccountController {
533555
534- @QueryMapping
535- @Transactional
536- public Account accountById(@Argument String id) {
537- ... // fetch the account within a transaction
538- }
556+ private final TransactionOperations transactionOperations;
539557
540- @SchemaMapping
541- @Transactional
542- public Person person(Account account) {
543- ... // fetching the person within a separate transaction
558+ @MutationMapping
559+ public Account addAccount(@Argument AccountInput input) {
560+ return transactionOperations.execute(status -> {
561+ // ...
562+ });
544563 }
545564}
546565----
566+ ======
547567
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.
568+ A transaction spans from entering the `addAccount` method until it returns its return value.
569+ All invocations to transactional resources are part of the same transaction resulting in
570+ atomicity and isolation of the mutation.
569571
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.
572+ This is the recommended approach as it gives you full control over the transaction
573+ boundaries with a clearly defined entrypoint without the need to instrument GraphQL
574+ server infrastructure.
576575
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 :
576+ Another aspect to consider is that subsequent data fetching for nested fields is not part
577+ of the transactional method `addAccount` as the transaction is cleaned up after leaving
578+ the method as shown below :
580579
581580[source,java,indent=0,subs="verbatim,quotes"]
582581----
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- }
582+ @Controller
583+ public class AccountController {
594584
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- });
585+ @MutationMapping
586+ @Transactional
587+ public Account addAccount(@Argument AccountInput input) { <1>
588+ // ...
614589 }
615590
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- }
591+ @SchemaMapping
592+ @Transactional
593+ public Person person(Account account) { <2>
594+ ... // fetching the person within a separate transaction
628595 }
629596}
630597----
598+ <1> The `addAccount` method invocation runs within its own transaction.
599+ <2> The `person` method invocation creates its own, separate transaction that is not
600+ tied to the `addAccount` method in case both methods were invoked as part of the same
601+ GraphQL request. A separate transaction comes with all possible drawbacks of not
602+ being part of the same transaction, such as non-repeatable reads or inconsistencies
603+ in case the data has been modified between the `addAcount` and `person` method invocations.
631604
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`
643605
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());
606+ [[data.transaction-management.transactional-instrumentation]]
607+ === Transactional Instrumentation
663608
664- private final PlatformTransactionManager transactionManager;
665- private final TransactionDefinition definition;
609+ Applying a Transactional Instrumentation is a more advanced approach to span a
610+ transaction over the entire execution of a GraphQL request. By stating a transaction
611+ before the first data fetcher is invoked your application can ensure that all data
612+ fetchers can participate in the same transaction.
666613
667- public TransactionalExecutionStrategy(PlatformTransactionManager transactionManager,
668- TransactionDefinition definition) {
669- this.transactionManager = transactionManager;
670- this.definition = definition;
671- }
614+ When instrumenting the server, you need to ensure an `ExecutionStrategy` runs
615+ `DataFetcher` invocations serially so that all invocations are executed on the same
616+ `Thread`. This is mandatory: Synchronous transaction management uses `ThreadLocal` state
617+ to allow participation in transactions. Considering `AsyncSerialExecutionStrategy` as
618+ starting point is a good choice as it executes data fetchers serially.
672619
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- }
620+ You have two general options to implement transactional instrumentation:
704621
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- ----
622+ 1. GraphQL Java's `Instrumentation` contract allows to hook into the execution lifecycle
623+ at various stages. The Instrumentation SPI was designed with observability in mind, yet it
624+ serves as execution-agnostic extension points regardless of whether you're using
625+ synchronous reactive, or any other asynchronous form to invoke data fetchers and is less
626+ opinionated in that regard.
627+
628+ 2. An `ExecutionStrategy` provides full control over the execution and opens a variety
629+ of possibilities how to communicate failed transactions or errors during transaction
630+ cleanup back to the client. It can also serve as good entry point to implement custom
631+ directives that allow clients specifying transactional attributes through directives or
632+ using directives in your schema to demarcate transactional boundaries for certain queries
633+ or mutations.
634+
635+ When manually managing transactions, ensure to cleanup the transaction, that is either
636+ commiting or rolling back, after completing the unit of work.
637+ `ExceptionWhileDataFetching` can be a useful `GraphQLError` to obtain an underlying
638+ `Exception`. This error is constructed when using `SimpleDataFetcherExceptionHandler`.
639+ By default, Spring GraphQL falls back to an internal `GraphQLError` that doesn't expose
640+ the original exception.
641+
642+ Applying transactional instrumentation creates opportunities to rethink transaction
643+ participation: All `@SchemaMapping` controller methods participate in the transaction
644+ regardless whether they are invoked for the root, nested fields, or as part of a mutation.
645+ Transactional controller methods (or service methods within the invocation chain) can
646+ declare transactional attributes such as propagation behavior `REQUIRES_NEW` to start
647+ a new transaction if required.
0 commit comments