@@ -477,252 +477,171 @@ you need to declare a `SortStrategy` bean.
477
477
[[data.transaction-management]]
478
478
== Transaction Management
479
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.
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.
483
484
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 .
488
489
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 :
490
491
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>>
494
523
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
524
499
525
[[data.transaction-management.transactional-service-methods]]
500
526
=== Transactional Service Methods
501
527
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:
506
531
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"]
508
537
----
509
538
@Controller
510
539
public class AccountController {
511
540
512
- @QueryMapping
541
+ @MutationMapping
513
542
@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
+ // ...
516
545
}
517
546
}
518
547
----
519
548
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"]
530
552
----
531
553
@Controller
532
554
public class AccountController {
533
555
534
- @QueryMapping
535
- @Transactional
536
- public Account accountById(@Argument String id) {
537
- ... // fetch the account within a transaction
538
- }
556
+ private final TransactionOperations transactionOperations;
539
557
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
+ });
544
563
}
545
564
}
546
565
----
566
+ ======
547
567
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.
569
571
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.
576
575
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 :
580
579
581
580
[source,java,indent=0,subs="verbatim,quotes"]
582
581
----
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 {
594
584
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
+ // ...
614
589
}
615
590
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
628
595
}
629
596
}
630
597
----
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.
631
604
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
605
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
663
608
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.
666
613
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.
672
619
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:
704
621
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