@@ -471,3 +471,258 @@ Spring for GraphQL defines a `SortStrategy` to create `Sort` from GraphQL argume
471
471
`AbstractSortStrategy` implements the contract with abstract methods to extract the sort
472
472
direction and properties. To enable support for `Sort` as a controller method argument,
473
473
you 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