In a time when nostalgia may have gone a little too far, the software development world has been swept by a heated debate: has the massive adoption of the microservicespattern truly delivered the expected benefits, or is its balance sheet more uncertain? Many teams are starting to wonder whether it’s time for a “homecoming” to the good old, reliable monolith — or if the answer lies somewhere in between, in the form of the modular monolith.
Excluding the cost factor, this rethinking often stems from the inherent complexity of distributed systems, particularly one of their crucial pain points:distributed transactions.

This article aims to address precisely this topic, demonstrating how Apache Seata, in this case combined with the agility of Spring Boot, manages to transform what for many is a nightmare into a surprisingly manageable solution. I will show you how Seata can help you sleep soundly at night, eliminating the need for complex architectures and rollback strategies and/or resorting to more cumbersome patterns like the Outbox pattern.

Apache Seata Apache Seata is an open source distributed transaction solution, currently in Apache incubation, that delivers high performance and easy to use distributed transaction services under a microservices architecture.

The main components are:

Transaction Coordinator (TC): maintain status of global and branch transactions, drive the global commit or rollback. Transaction Manager (TM): define the scope of global transaction: begin a global transaction, commit or rollback a global transaction. Resource Manager (RM): manage resources that branch transactions working on, talk to TC for registering branch transactions and reporting status of branch transactions, and drive the branch transaction commit or rollback. The supported transaction models are: AT, XA (2PC) and SAGA.

(If you’d rather skip the theory, go straight to the practical solution!)

XA (2PC) XA is a specification released in 1991 by X/Open (which later merged with The Open Group).

Phase 1: Prepare (Commit Vote): TC (Seata Server), asks all participating RMs (e.g., your microservices interacting with databases) if they are ready to commit their local transaction. Each RM performs its operations, writes transaction logs to ensure durability, and locks resources to guarantee isolation. If an RM is ready, it responds “Yes” to the TC. If not, it responds “No”. 2. Phase 2: Commit or Rollback:

If all RMs respond “Yes”, the TC sends a “Commit” command to all RMs. They finalize their local transactions and release locks. If any RM responds “No”, or if the TC detects a timeout/failure, the TC sends a “Rollback” command. All RMs undo their operations and release locks. Pros & Cons (Briefly):

Pro: Provides strong data consistency (ACID) across multiple services, acting like a single, unbreakable transaction. Con: Can lead to resource blocking (high latency) if participants or the coordinator are slow or fail, potentially impacting availability and scalability. It also relies on underlying databases/resources supporting the XA standard. SAGA The SAGA pattern is a widely adopted approach in microservices architectures to manage distributed transactions. Unlike 2PC, Saga sacrifices immediate strong consistency for higher availability and scalability, achieving eventual consistency.

A Saga is a sequence of local transactions, where each local transaction (within a single microservice) updates its own database and then publishes an event or message to trigger the next local transaction in the sequence.

No Global Locks: crucially, local transactions commit immediately and do not hold global locks, allowing for greater concurrency. Compensation for Failure: if any local transaction fails, the Saga does not “rollback” in the traditional sense. Instead, it executes a series of compensating transactions to semantically undo the effects of previously completed local transactions. These compensating transactions are new operations designed to reverse the business impact. Saga can be implemented via:

Choreography: Services publish events and react to them, leading to a decentralized flow. Orchestration: A central orchestrator service coordinates the flow, sending commands and reacting to responses. Pros & Cons (Briefly):

Pro: Excellent for high availability and scalability due to lack of long-held distributed locks. Ideal for loosely coupled microservices. Con: Achieves eventual consistency, meaning data might be temporarily inconsistent. Requires significant development effort to implement all compensating transactions and manage complex Saga logic, which can also make debugging harder. AT The AT (Automatic Transaction) Mode is Seata’s flagship solution, aiming to offer the ease-of-use of 2PC with the non-blocking nature and scalability benefits of Saga. It’s the recommended default for most microservices using relational databases.

Phase 1: Local Transaction & Prepare: When a microservice (RM) performs a database operation (e.g., UPDATE, INSERT, DELETE) within a global transaction: Seata’s intelligent DataSourceProxy intercepts the SQL. It automatically creates an undo_log (recording the data’s state before the modification). The SQL operation is executed and committed immediately on the local database. Seata then acquires a global lock for the modified resource via the Transaction Coordinator (TC). This lock is not a traditional database lock; it prevents other global transactions from concurrently modifying the same resource, but doesn’t block read operations. The RM informs the TC that its branch transaction is “prepared.” 2. Phase 2: Global Commit or Rollback:

Global Commit: If all branch transactions prepare successfully, the TC instructs them to commit. Since local DB transactions were already committed in Phase 1, RMs simply release their global locks. Global Rollback: If any branch transaction fails, or the global transaction needs to rollback: The TC instructs the RMs to roll back. RMs use their stored undo_log to automatically compensate for the changes made to their local databases, effectively restoring the previous state. They then release their global locks. Pros & Cons (Briefly):

Pro: Provides strong consistency for the global transaction. Offers excellent availability and scalability as local database locks are held only briefly. It’s highly transparent to developers, requiring minimal code changes. Automatic rollback simplifies error handling. Con: Primarily designed for relational databases. While non-blocking at the DB level, there’s still an overhead from generating undo_logs and managing global locks.

A practical demonstration with Spring Boot

Let’s envision a scenario involving two distinct microservices, each operating with its own dedicated and autonomous database:

Orchestrating these two services is a BFF (Backend For Frontend). Its role is to coordinate the shipment purchase operation, which translates into a sequence of distributed calls:

The crucial question then arises: how can we ensure that these operations, distributed across different services and databases, maintain their transactional consistency, guaranteeing that the purchase is completed only if the credit has been successfully updated, and vice-versa?

Architecture


TM

The BFF will represent the Transaction Manager, i.e. it will define the global transaction.

@Service
public class BFFManager {

    @Autowired
    private CreditService creditService;

    @Autowired
    private ShippingService shippingService;

    @GlobalTransactional
    public ShippingResult buyShipping(Long userID, BigDecimal cost) {
        var wallet = creditService.updateBalance(userID, cost);
        var shipping = shippingService.buyShipping(userID, cost);
        
        wallet = creditService.getWallet(userID);

        var result = new ShippingResult();
        result.setCost(cost);
        result.setShippingID(shipping.getId());
        result.setCurrentBalance(wallet.getBalance());
        return result;
    }
}

So just the @GlobalTransactional annotation is enough? Of course not, but little else is needed:

Dependencies needed for TM:

implementation 'org.apache.seata:seata-spring-boot-starter:2.3.0'
implementation('com.alibaba.cloud:spring-cloud-starter-alibaba-seata:2023.0.3.3') {
  exclude group: 'org.apache.seata', module: 'seata-spring-boot-starter'
}

spring-cloud-starter-alibaba-seata, among other things it can do, ensures that http communications between microservices always contain the Global Transaction ID (XID).

seata-spring-boot-starter is the classic Spring Boot starter that autoconfigures the Seata entity (in this case the TM) starting from the properties:

seata:
  enabled: true
  data-source-proxy-mode: AT
  enable-auto-data-source-proxy: true
  application-id:  ${spring.application.name}
  tx-service-group: default_tx_group
  service:
    vgroup-mapping:
      default_tx_group: default
    grouplist:
      default: 127.0.0.1:8091

RM

credit-api and shipping-api act as RM. They only need dependency seata-spring-boot-starter with following properties:

seata:
  enabled: true
  data-source-proxy-mode: AT
  enable-auto-data-source-proxy: true
  application-id:  ${spring.application.name}
  tx-service-group: default_tx_group
  service:
    vgroup-mapping:
      default_tx_group: default
    grouplist:
      default: 127.0.0.1:8091

It is necessary that the RM DB contains the undo_log table for Seata. Here are the necessary scripts for each type of DB.

In the code that you will find on GitHub the creation of the table is managed through the docker compose that creates the dedicated db (through org.springframework.boot:spring-boot-docker-compose)

In RMs, no specific annotation is needed. Write your code towards the repository as you always have. If you want, and I recommend it, continue to use @Transactional for local transactions.

TC

The TC is represented by the heart of Seata, seata-server. This requires two main configurations:

Here’s the docker compose setup used to initialize the server:

services:
  mysql:
    image: mysql:8.0.33
    container_name: mysql-seata
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: seata
      MYSQL_USER: seata_user
      MYSQL_PASSWORD: seata_pass
    ports:
      - "3317:3306"
    volumes:
      - mysql_data:/var/lib/mysql
      - ./docker/seata/mysql.sql:/docker-entrypoint-initdb.d/seata.sql:ro
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-prootpass"]
      interval: 10s
      timeout: 5s
      retries: 5

  seata-server:
    image: apache/seata-server:2.3.0
    container_name: seata-server
    depends_on:
      mysql:
        condition: service_healthy
    environment:
      - SEATA_CONFIG_NAME=application.yml
    volumes:
      - "./docker/seata/resources/application.yml:/seata-server/resources/application.yml"
      - "./docker/seata/mysql-connector-j-8.0.33.jar:/seata-server/libs/mysql-connector-j-8.0.33.jar"
    ports:
      - "7091:7091"
      - "8091:8091"

volumes:
  mysql_data:

And the configuration properties (application.yaml):

server:
  port: 7091

spring:
  application:
    name: seata-server
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://mysql:3306/seata
    username: seata_user
    password: seata_pass

logging:
  config: classpath:logback-spring.xml
  file:
    path: ${log.home:${user.home}/logs/seata}

console:
  user:
    username: seata
    password: seata

seata:
  security:
    secretKey: seata
    tokenValidityInMilliseconds: 1800000

  config:
    type: file

  registry:
    type: file

  store:
    mode: db
    db:
      dbType: mysql
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://mysql:3306/seata
      user: seata_user
      password: seata_pass
      min-conn: 10
      max-conn: 100
      global-table: global_table
      branch-table: branch_table
      lock-table: lock_table
      distributed-lock-table: distributed_lock
      vgroup-table: vgroup_table
      query-limit: 1000
      max-wait: 5000

As you may have noticed, the dedicated database needs a number of tables to function. All the creation sql scripts are available here.

Play!

@SpringBootTest
public class GlobalTransactionalTest {

    @Autowired
    private BFFManager bffManager;

    @Autowired
    private CreditService creditService;

    @MockitoBean
    private ShippingService shippingService;

    @Test
    public void globalTransactionalTest_OK() {
        var wallet = creditService.getWallet(1L);
        var shipping = new Shipping();
        shipping.setId(2L);
        when(shippingService.buyShipping(1L, new BigDecimal(4))).thenReturn(shipping);
        bffManager.buyShipping(1L, new BigDecimal(4));
        var newWallet = creditService.getWallet(1L);
        assertEquals(new BigDecimal("4.00"), wallet.getBalance().subtract(newWallet.getBalance()));
    }

    @Test
    public void globalTransactionalTest_KO() {
        var wallet = creditService.getWallet(1L);
        var shipping = new Shipping();
        shipping.setId(2L);
        when(shippingService.buyShipping(1L, new BigDecimal(4))).thenThrow(new RuntimeException());

        try {
            bffManager.buyShipping(1L, new BigDecimal(4));
        } catch (Exception e) {}

        var newWallet = creditService.getWallet(1L);
        assertEquals(newWallet.getBalance(), wallet.getBalance());
    }

}

The complete and working code is available on GitHub. Run the components and let me know what you think!

Key alternatives

This article does not aim to promote Apache Seata over the other alternatives mentioned, but rather to highlight its ease of use. As always, the right tool should be chosen based on the specific context and system requirements.

Next episode: “How cool were the monoliths?” Stay tuned.