Payment Pipeline with Saga Orchestration

Build a two-service payment system with saga pattern, circuit breaker, and idempotency.

Introduction

30 Second Summary

When you send money through a payment app, your transfer passes through multiple services that each need to succeed or roll back together. If a credit fails after a debit already went through, the system needs automatic reversal to avoid losing money.

In this project, you will build a two-service payment pipeline using Spring Boot that orchestrates multi-step transfers with automatic rollback on failure, circuit breaker resilience, and idempotency enforcement.

What You'll Build

A payment processing system consisting of two Spring Boot microservices that coordinate money transfers using the Saga Orchestration Pattern.

By the end of this project, you'll have:

  • An Account Service that manages balances with pessimistic locking and exposes debit, credit, and compensation endpoints backed by PostgreSQL.
  • A Payment Orchestrator Service that coordinates the transfer saga, enforces idempotency, and wraps downstream calls in a circuit breaker powered by Resilience4j.
  • Demonstrated failure scenarios proving the system maintains consistency through automatic compensation and graceful degradation.
  • Secret Mission: Add a transactional outbox table that persists saga events for auditability and replay.

Are there any prerequisites?

This project assumes prior experience with Spring Boot microservices, REST APIs, JPA/Hibernate, and basic Docker usage. You should be comfortable creating Spring Boot applications and working with relational databases.

Before We Start

Before We Start...

Set Up Docker and Project Scaffolding

A payment pipeline that coordinates transfers across two services needs a shared foundation. Each service needs its own database to maintain data isolation, and both services need consistent configuration before you write any business logic.

In this step, you will spin up two PostgreSQL databases using Docker, generate two Spring Boot projects via Spring Initializr, and wire up the configuration that connects everything together.

In this step, get ready to:
  • Create a Docker Compose file with two PostgreSQL containers.
  • Generate and import the Account Service and Payment Orchestrator projects.
  • Configure database connections, server ports, and Resilience4j circuit breaker settings.
Create the Docker Compose file

Each microservice gets its own database. This prevents one service from accidentally reading or modifying another service's data.

  • Open a terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir payment-pipeline
cd payment-pipeline
  • Confirm the folder was created by running:
ls

You should see the payment-pipeline folder listed in the output (on macOS/Linux) or your current directory is now payment-pipeline.

  • Create a file called docker-compose.yml inside payment-pipeline by running:
touch docker-compose.yml
  • Open docker-compose.yml in your preferred text editor and paste the following configuration:
services:
  account-db:
    image: postgres:17-alpine
    container_name: account-db
    environment:
      POSTGRES_DB: accountdb
      POSTGRES_USER: account_user
      POSTGRES_PASSWORD: account_pass
    ports:
      - "5432:5432"
    volumes:
      - account-data:/var/lib/postgresql/data

  payment-db:
    image: postgres:17-alpine
    container_name: payment-db
    environment:
      POSTGRES_DB: paymentdb
      POSTGRES_USER: payment_user
      POSTGRES_PASSWORD: payment_pass
    ports:
      - "5433:5432"
    volumes:
      - payment-data:/var/lib/postgresql/data

volumes:
  account-data:
  payment-data:

What does this configuration do?

This file defines two PostgreSQL 17 containers. The account-db container runs on the default port 5432 and hosts the Account Service database. The payment-db container maps host port 5433 to the container's internal port 5432.

Named volumes (account-data and payment-data) persist data between container restarts so you do not lose test records when stopping Docker.

  • Save docker-compose.yml.
  • Make sure Docker Desktop is running. Then start the containers by running:
docker compose up -d
  • Verify both containers are running by running:
docker ps

You should see two containers listed: account-db and payment-db, both with a status of Up.

Containers not starting?

  • Check that Docker Desktop is running. The Docker engine must be active before you can start containers.
  • If you see a port conflict error, another process is already using port 5432 or 5433. Stop any local PostgreSQL instances first.
  • Help me troubleshoot my Docker containers

✔️ Awesome, I've got everything!

Great. Make sure you have saved docker-compose.yml and both containers show as running.

ⓧ I'd like to double check the full code

services:
  account-db:
    image: postgres:17-alpine
    container_name: account-db
    environment:
      POSTGRES_DB: accountdb
      POSTGRES_USER: account_user
      POSTGRES_PASSWORD: account_pass
    ports:
      - "5432:5432"
    volumes:
      - account-data:/var/lib/postgresql/data

  payment-db:
    image: postgres:17-alpine
    container_name: payment-db
    environment:
      POSTGRES_DB: paymentdb
      POSTGRES_USER: payment_user
      POSTGRES_PASSWORD: payment_pass
    ports:
      - "5433:5432"
    volumes:
      - payment-data:/var/lib/postgresql/data

volumes:
  account-data:
  payment-data:
Generate the two Spring Boot projects

Spring Initializr generates project scaffolding so you do not have to manually configure Maven build files and dependency management from scratch.

  • Open start.spring.io in your browser.
  • Set Project to Maven.
  • Set Language to Java.
  • Set Spring Boot to 3.5.15.
  • Under Project Metadata, set Group to com.example.
  • Set Artifact to account-service.
  • Set Java version to 25.
  • Click ADD DEPENDENCIES and add: Spring Web, Spring Data JPA, PostgreSQL Driver, and Spring Boot Actuator.
  • Click GENERATE to download the zip file.
  • Extract the downloaded account-service.zip into your payment-pipeline folder on the Desktop.

Now repeat the process for the Payment Orchestrator.

  • Return to start.spring.io.
  • Keep all settings the same, but change Artifact to payment-orchestrator.
  • Keep the same four dependencies: Spring Web, Spring Data JPA, PostgreSQL Driver, and Spring Boot Actuator.
  • Click GENERATE and extract payment-orchestrator.zip into the same payment-pipeline folder.

What should my folder structure look like?

Your payment-pipeline folder should now contain three items: docker-compose.yml, an account-service folder, and a payment-orchestrator folder.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type IntelliJ IDEA and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open.
  • Navigate to your Desktop and select the payment-pipeline folder. Click Open.
  • If IntelliJ asks whether to trust the project, click Trust Project.

IntelliJ will index both projects. Wait for the progress bar in the bottom status bar to finish before continuing.

IntelliJ not recognizing the projects?

  • Make sure you opened the payment-pipeline root folder, not one of the service subfolders.
  • If Maven dependencies are not resolving, right-click the pom.xml in the project tree and select Maven then Reload Project.
  • Help me fix IntelliJ project recognition
Add Resilience4j and configure both services

The Payment Orchestrator needs a circuit breaker to protect itself when the Account Service is unavailable. Resilience4j provides this pattern as a Spring Boot starter.

  • In IntelliJ, expand the payment-orchestrator folder in the Project sidebar.
  • Open pom.xml inside payment-orchestrator.
  • Find the <dependencies> section. Add the following two dependencies after the existing entries, before the closing </dependencies> tag:
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <dependency>
            <groupId>io.github.resilience4j</groupId>
            <artifactId>resilience4j-spring-boot3</artifactId>
            <version>2.3.0</version>
        </dependency>

What do these dependencies do?

The spring-boot-starter-aop dependency enables aspect-oriented programming, which Resilience4j uses to intercept method calls and apply circuit breaker logic via annotations.

The resilience4j-spring-boot3 starter integrates the Resilience4j library with Spring Boot auto-configuration so you can declare circuit breakers in YAML instead of writing boilerplate code.

  • Save pom.xml.
  • Click the Maven reload icon that appears in the top-right corner of the editor (or right-click pom.xml and select Maven then Reload Project).
  • Wait for IntelliJ to finish downloading the new dependencies. The progress bar in the status bar will disappear when complete.

Dependencies not resolving?

  • Make sure you placed the new dependencies inside the <dependencies> block, not inside <build> or <plugins>.
  • Check your internet connection. Maven downloads libraries from remote repositories.
  • Help me resolve Maven dependency issues

✔️ Awesome, I've got everything!

Great. Make sure the Maven reload completed without errors in the Build output panel.

ⓧ I'd like to double check the full code

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.15</version>
        <relativePath/>
    </parent>
    <groupId>com.example</groupId>
    <artifactId>payment-orchestrator</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>payment-orchestrator</name>
    <properties>
        <java.version>25</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <dependency>
            <groupId>io.github.resilience4j</groupId>
            <artifactId>resilience4j-spring-boot3</artifactId>
            <version>2.3.0</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Now configure the Account Service to connect to its database.

  • In IntelliJ, expand account-service then navigate to src/main/resources.
  • Delete the default application.properties file (right-click and select Delete).
  • Right-click the resources folder and select New then File. Name it application.yml.
  • Paste the following configuration into the new file:
server:
  port: 8081

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/accountdb
    username: account_user
    password: account_pass
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect
    show-sql: true
  sql:
    init:
      mode: always

What does this configuration do?

The server.port sets the Account Service to run on port 8081. The datasource URL points to the account-db container you started with Docker Compose.

The ddl-auto: update setting tells JPA to automatically create or update database tables based on your entity classes. The sql.init.mode: always ensures seed data scripts run on every startup.

  • Save application.yml.

✔️ Awesome, I've got everything!

Great. Make sure you saved account-service/src/main/resources/application.yml.

ⓧ I'd like to double check the full code

server:
  port: 8081

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/accountdb
    username: account_user
    password: account_pass
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect
    show-sql: true
  sql:
    init:
      mode: always

Now configure the Payment Orchestrator with its database connection and circuit breaker settings.

  • In IntelliJ, expand payment-orchestrator then navigate to src/main/resources.
  • Delete the default application.properties file.
  • Right-click the resources folder and select New then File. Name it application.yml.
  • Paste the following configuration into the new file:
server:
  port: 8082

spring:
  datasource:
    url: jdbc:postgresql://localhost:5433/paymentdb
    username: payment_user
    password: payment_pass
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect
    show-sql: true

account-service:
  base-url: http://localhost:8081

resilience4j:
  circuitbreaker:
    instances:
      accountService:
        slidingWindowSize: 5
        failureRateThreshold: 50
        waitDurationInOpenState: 10s
        permittedNumberOfCallsInHalfOpenState: 2
        slidingWindowType: COUNT_BASED

management:
  endpoints:
    web:
      exposure:
        include: health,circuitbreakers
  health:
    circuitbreakers:
      enabled: true

What does the circuit breaker configuration do?

The resilience4j.circuitbreaker block defines an instance called accountService. It monitors the last 5 calls (slidingWindowSize) and opens the circuit if 50% or more fail.

Once open, the circuit stays open for 10 seconds (waitDurationInOpenState) before allowing 2 test calls (permittedNumberOfCallsInHalfOpenState) to check if the downstream service has recovered. The management section exposes health and circuit breaker status via the Actuator endpoints.

  • Save application.yml.

YAML indentation errors?

  • YAML is whitespace-sensitive. Make sure you use spaces (not tabs) for indentation.
  • IntelliJ highlights YAML syntax errors with a red underline. Check that each nested key is indented exactly 2 spaces deeper than its parent.
  • Help me fix YAML parsing errors

✔️ Awesome, I've got everything!

Great. Make sure you saved payment-orchestrator/src/main/resources/application.yml.

ⓧ I'd like to double check the full code

server:
  port: 8082

spring:
  datasource:
    url: jdbc:postgresql://localhost:5433/paymentdb
    username: payment_user
    password: payment_pass
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect
    show-sql: true

account-service:
  base-url: http://localhost:8081

resilience4j:
  circuitbreaker:
    instances:
      accountService:
        slidingWindowSize: 5
        failureRateThreshold: 50
        waitDurationInOpenState: 10s
        permittedNumberOfCallsInHalfOpenState: 2
        slidingWindowType: COUNT_BASED

management:
  endpoints:
    web:
      exposure:
        include: health,circuitbreakers
  health:
    circuitbreakers:
      enabled: true

Your infrastructure is ready and both projects are configured. Next up, you will build the Account Service with endpoints that handle debits, credits, and compensation.

Build the Account Service

Your Docker databases are configured and both Maven projects are ready. Now it's time to build the first microservice that holds all the money.

The Account Service manages account balances and exposes endpoints for debiting, crediting, and compensating transactions. These are the primitive operations that the Payment Orchestrator will call during a transfer saga.

In this step, get ready to:
  • Create the Account entity with JPA annotations and a repository with pessimistic locking.
  • Build three controller endpoints for debit, credit, and compensation operations.
  • Start the service and verify the endpoints work with test requests.
Create the Account entity and repository

Every payment system needs accounts to hold balances. The Account entity maps a Java object to a PostgreSQL table, and the repository provides database access with a critical safety feature: pessimistic locking to prevent two concurrent transfers from overdrawing the same account.

  • In IntelliJ IDEA, navigate to account-service/src/main/java/com/example/accountservice/ in the Project sidebar.
  • Create a new Java class called Account.java by right-clicking the accountservice package, selecting New then Java Class, and typing Account.
  • Paste the following code into Account.java:
package com.example.accountservice;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.math.BigDecimal;

@Entity
public class Account {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String accountNumber;
    private String ownerName;
    private BigDecimal balance;

    // Default constructor required by JPA
    public Account() {}

    // Getters and setters for each field
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getAccountNumber() { return accountNumber; }
    public void setAccountNumber(String accountNumber) { this.accountNumber = accountNumber; }
    public String getOwnerName() { return ownerName; }
    public void setOwnerName(String ownerName) { this.ownerName = ownerName; }
    public BigDecimal getBalance() { return balance; }
    public void setBalance(BigDecimal balance) { this.balance = balance; }
}

What does this code do?

The @Entity annotation tells Hibernate to map this class to a database table called account. Each field becomes a column.

The @Id and @GeneratedValue(strategy = GenerationType.IDENTITY) annotations mark the primary key and tell PostgreSQL to auto-increment it. The BigDecimal type is essential for financial data because it avoids floating-point rounding errors.

  • Save Account.java.
  • Create another Java interface in the same package called AccountRepository.java by right-clicking the accountservice package, selecting New then Java Class, choosing Interface, and typing AccountRepository.
  • Paste the following code into AccountRepository.java:
package com.example.accountservice;

import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;

import java.util.Optional;

public interface AccountRepository extends JpaRepository<Account, Long> {

    // Acquire a database-level lock on the row before reading
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT a FROM Account a WHERE a.id = :id")
    Optional<Account> findByIdForUpdate(Long id);
}

What is pessimistic locking?

When two transfer requests hit the same account simultaneously, both might read a balance of $5000 and both try to debit $3000. Without locking, both succeed and the account ends up at -$1000.

The @Lock(LockModeType.PESSIMISTIC_WRITE) annotation tells PostgreSQL to lock the row when it is read. The second request must wait until the first transaction commits. This guarantees only one debit processes at a time per account.

  • Save AccountRepository.java.
  • Create a new file called data.sql inside account-service/src/main/resources/ by right-clicking the resources folder, selecting New then File, and typing data.sql.
  • Paste the following SQL into data.sql to seed two test accounts:
INSERT INTO account (id, account_number, owner_name, balance)
VALUES (1, 'ACC-001', 'Alice Johnson', 5000.00)
ON CONFLICT (id) DO NOTHING;

INSERT INTO account (id, account_number, owner_name, balance)
VALUES (2, 'ACC-002', 'Bob Smith', 3000.00)
ON CONFLICT (id) DO NOTHING;

Why ON CONFLICT DO NOTHING?

The application.yml has spring.sql.init.mode: always, which means data.sql runs on every application startup. The ON CONFLICT (id) DO NOTHING clause prevents duplicate-key errors when the accounts already exist from a previous run.

Hibernate creates the table automatically because ddl-auto: update is set. Then Spring Boot runs data.sql to populate the initial data.

  • Save data.sql.

Not sure where to create the files?

All Java files go in account-service/src/main/java/com/example/accountservice/. The data.sql file goes in account-service/src/main/resources/ alongside the existing application.yml.

If IntelliJ shows red underlines on imports, wait a few seconds for the IDE to index. If they persist, right-click the project and select Maven then Reload Project. Help me fix import errors in IntelliJ.

Build the controller endpoints

The controller exposes three operations that the Payment Orchestrator will call during a saga: debit (remove money), credit (add money), and compensate-debit (reverse a debit if a later step fails).

  • Create a new Java file called TransactionRequest.java in the accountservice package.
  • Paste the following code into TransactionRequest.java:
package com.example.accountservice;

import java.math.BigDecimal;

// Java record: a compact class with immutable fields and auto-generated accessors
public record TransactionRequest(BigDecimal amount, String transactionId) {}
  • Save TransactionRequest.java.
  • Create a new Java class called AccountController.java in the same package.
  • Paste the following code into AccountController.java:
package com.example.accountservice;

import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;
import java.util.Map;

@RestController
@RequestMapping("/api/accounts")
public class AccountController {

    private final AccountRepository accountRepository;

    public AccountController(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    // Debit endpoint: subtract amount from account balance
    @PostMapping("/{id}/debit")
    @Transactional
    public ResponseEntity<?> debit(@PathVariable Long id, @RequestBody TransactionRequest request) {
        Account account = accountRepository.findByIdForUpdate(id)
                .orElse(null);
        if (account == null) {
            return ResponseEntity.notFound().build();
        }
        if (account.getBalance().compareTo(request.amount()) < 0) {
            return ResponseEntity.badRequest()
                    .body(Map.of("error", "Insufficient funds", "balance", account.getBalance()));
        }
        account.setBalance(account.getBalance().subtract(request.amount()));
        accountRepository.save(account);
        return ResponseEntity.ok(Map.of("status", "DEBITED", "newBalance", account.getBalance()));
    }

    // Credit endpoint: add amount to account balance
    @PostMapping("/{id}/credit")
    @Transactional
    public ResponseEntity<?> credit(@PathVariable Long id, @RequestBody TransactionRequest request) {
        Account account = accountRepository.findByIdForUpdate(id)
                .orElse(null);
        if (account == null) {
            return ResponseEntity.notFound().build();
        }
        account.setBalance(account.getBalance().add(request.amount()));
        accountRepository.save(account);
        return ResponseEntity.ok(Map.of("status", "CREDITED", "newBalance", account.getBalance()));
    }

    // Compensation endpoint: reverse a debit by re-crediting the amount
    @PostMapping("/{id}/compensate-debit")
    @Transactional
    public ResponseEntity<?> compensateDebit(@PathVariable Long id, @RequestBody TransactionRequest request) {
        Account account = accountRepository.findByIdForUpdate(id)
                .orElse(null);
        if (account == null) {
            return ResponseEntity.notFound().build();
        }
        account.setBalance(account.getBalance().add(request.amount()));
        accountRepository.save(account);
        return ResponseEntity.ok(Map.of("status", "COMPENSATED", "newBalance", account.getBalance()));
    }
}

What do these endpoints do?

  • POST /api/accounts/{id}/debit subtracts the requested amount. Returns 400 Bad Request if the account has insufficient funds.
  • POST /api/accounts/{id}/credit adds the requested amount to the balance.
  • POST /api/accounts/{id}/compensate-debit reverses a debit by adding the amount back. This is the compensation action the saga calls when a later step fails.

Every method uses findByIdForUpdate instead of plain findById to acquire the pessimistic lock before modifying the balance. The @Transactional annotation ensures the lock is held for the entire method execution.

  • Save AccountController.java.

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files: Account.java, AccountRepository.java, TransactionRequest.java, and AccountController.java.

ⓧ I'd like to double check the full code

Here is Account.java:

package com.example.accountservice;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.math.BigDecimal;

@Entity
public class Account {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String accountNumber;
    private String ownerName;
    private BigDecimal balance;

    public Account() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getAccountNumber() { return accountNumber; }
    public void setAccountNumber(String accountNumber) { this.accountNumber = accountNumber; }
    public String getOwnerName() { return ownerName; }
    public void setOwnerName(String ownerName) { this.ownerName = ownerName; }
    public BigDecimal getBalance() { return balance; }
    public void setBalance(BigDecimal balance) { this.balance = balance; }
}

Here is AccountRepository.java:

package com.example.accountservice;

import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;

import java.util.Optional;

public interface AccountRepository extends JpaRepository<Account, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT a FROM Account a WHERE a.id = :id")
    Optional<Account> findByIdForUpdate(Long id);
}

Here is TransactionRequest.java:

package com.example.accountservice;

import java.math.BigDecimal;

public record TransactionRequest(BigDecimal amount, String transactionId) {}

Here is AccountController.java:

package com.example.accountservice;

import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;
import java.util.Map;

@RestController
@RequestMapping("/api/accounts")
public class AccountController {

    private final AccountRepository accountRepository;

    public AccountController(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    @PostMapping("/{id}/debit")
    @Transactional
    public ResponseEntity<?> debit(@PathVariable Long id, @RequestBody TransactionRequest request) {
        Account account = accountRepository.findByIdForUpdate(id)
                .orElse(null);
        if (account == null) {
            return ResponseEntity.notFound().build();
        }
        if (account.getBalance().compareTo(request.amount()) < 0) {
            return ResponseEntity.badRequest()
                    .body(Map.of("error", "Insufficient funds", "balance", account.getBalance()));
        }
        account.setBalance(account.getBalance().subtract(request.amount()));
        accountRepository.save(account);
        return ResponseEntity.ok(Map.of("status", "DEBITED", "newBalance", account.getBalance()));
    }

    @PostMapping("/{id}/credit")
    @Transactional
    public ResponseEntity<?> credit(@PathVariable Long id, @RequestBody TransactionRequest request) {
        Account account = accountRepository.findByIdForUpdate(id)
                .orElse(null);
        if (account == null) {
            return ResponseEntity.notFound().build();
        }
        account.setBalance(account.getBalance().add(request.amount()));
        accountRepository.save(account);
        return ResponseEntity.ok(Map.of("status", "CREDITED", "newBalance", account.getBalance()));
    }

    @PostMapping("/{id}/compensate-debit")
    @Transactional
    public ResponseEntity<?> compensateDebit(@PathVariable Long id, @RequestBody TransactionRequest request) {
        Account account = accountRepository.findByIdForUpdate(id)
                .orElse(null);
        if (account == null) {
            return ResponseEntity.notFound().build();
        }
        account.setBalance(account.getBalance().add(request.amount()));
        accountRepository.save(account);
        return ResponseEntity.ok(Map.of("status", "COMPENSATED", "newBalance", account.getBalance()));
    }
}

And data.sql in src/main/resources/:

INSERT INTO account (id, account_number, owner_name, balance)
VALUES (1, 'ACC-001', 'Alice Johnson', 5000.00)
ON CONFLICT (id) DO NOTHING;

INSERT INTO account (id, account_number, owner_name, balance)
VALUES (2, 'ACC-002', 'Bob Smith', 3000.00)
ON CONFLICT (id) DO NOTHING;
Start the service and test the endpoints

The code is written. Now you need to start the PostgreSQL database, launch the Account Service, and verify each endpoint responds correctly.

  • Open a terminal in IntelliJ by pressing Alt+F12 (Windows) or Option+F12 (macOS).
  • Navigate to your project root (the folder containing docker-compose.yml) and start the database containers by running:
docker compose up -d
  • Confirm both containers are running by running:
docker compose ps

You should see account-db and payment-db both listed with a status of running.

Containers not starting?

Make sure Docker Desktop is running. If port 5432 is already in use by a local PostgreSQL installation, stop it first or change the port mapping in docker-compose.yml. Help me fix Docker container port conflicts.

  • Navigate into the account-service directory and start the application by running:
cd account-service
./mvnw spring-boot:run

Wait for the startup log to show a line like Started AccountServiceApplication. This means the service is running on port 8081.

Seeing a connection refused error?

If the logs show a PostgreSQL connection error, the database container may not be fully ready yet. Wait 5-10 seconds after docker compose up before starting the Spring Boot app. You can also check container health with docker compose logs account-db. Help me debug the database connection.

  • Open a second terminal tab (click + next to the terminal tab in IntelliJ) to send test requests.
  • Test the debit endpoint by running:
curl -X POST http://localhost:8081/api/accounts/1/debit \
  -H "Content-Type: application/json" \
  -d '{"amount": 500.00, "transactionId": "test-001"}'

You should see a JSON response like:

{"status":"DEBITED","newBalance":4500.00}
  • Test the credit endpoint by running:
curl -X POST http://localhost:8081/api/accounts/2/credit \
  -H "Content-Type: application/json" \
  -d '{"amount": 500.00, "transactionId": "test-002"}'

You should see a JSON response confirming the credit with a new balance of 3500.00.

  • Test the insufficient funds check by running:
curl -X POST http://localhost:8081/api/accounts/1/debit \
  -H "Content-Type: application/json" \
  -d '{"amount": 99999.00, "transactionId": "test-003"}'

You should see a 400 Bad Request response with "error":"Insufficient funds" and the current balance. This proves the debit validation rejects overdrafts.

Why test the insufficient funds case?

The debit endpoint is the saga's first step. If it rejects a request, the orchestrator marks the payment as FAILED immediately without needing compensation. This boundary check is your first line of defense against invalid transfers.

Your Account Service is live and handling debit, credit, and compensation requests. Next up, you will build the Payment Orchestrator that coordinates these calls into a complete transfer saga with automatic rollback.

Build the Payment Orchestrator Saga Flow

Your Account Service is running and ready to accept debit, credit, and compensation requests. Now you need a second service that coordinates these calls in the right order and handles failures gracefully.

The Saga orchestration pattern solves a specific problem in distributed systems: how do you ensure consistency when a single business operation spans multiple services? The orchestrator drives each step in sequence. If any step fails, it runs compensation logic to undo what already succeeded.

In this step, get ready to:
  • Create the Payment entity, status enum, and repository.
  • Implement the saga orchestrator with RestClient and circuit breaker.
  • Wire up the transfer endpoint and test a successful payment.
Create the Payment entity and repository

The Payment entity tracks each transfer through its lifecycle. It moves through a state machine: INITIATED, DEBIT_SUCCESS, COMPLETED, FAILED, or COMPENSATED.

  • In IntelliJ IDEA, navigate to the payment-orchestrator/src/main/java/com/example/paymentorchestrator/ package.
  • Create a new Java file called PaymentStatus.java with the following code:
package com.example.paymentorchestrator;

public enum PaymentStatus {
    INITIATED,
    DEBIT_SUCCESS,
    COMPLETED,
    FAILED,
    COMPENSATED
}

What do these statuses mean?

  • INITIATED means the payment record was created but no external calls have been made yet.
  • DEBIT_SUCCESS means money was removed from the source account successfully.
  • COMPLETED means both debit and credit succeeded. The transfer is done.
  • FAILED means the debit itself could not be completed (e.g. insufficient funds).
  • COMPENSATED means the credit failed after a successful debit, so the debit was reversed.
  • Create a new Java file called Payment.java in the same package with the following code:
package com.example.paymentorchestrator;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
public class Payment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String idempotencyKey;

    private Long sourceAccountId;
    private Long destinationAccountId;
    private BigDecimal amount;

    @Enumerated(EnumType.STRING)
    private PaymentStatus status;

    private LocalDateTime createdAt;

    public Payment() {
        this.createdAt = LocalDateTime.now();
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getIdempotencyKey() { return idempotencyKey; }
    public void setIdempotencyKey(String idempotencyKey) { this.idempotencyKey = idempotencyKey; }
    public Long getSourceAccountId() { return sourceAccountId; }
    public void setSourceAccountId(Long sourceAccountId) { this.sourceAccountId = sourceAccountId; }
    public Long getDestinationAccountId() { return destinationAccountId; }
    public void setDestinationAccountId(Long destinationAccountId) { this.destinationAccountId = destinationAccountId; }
    public BigDecimal getAmount() { return amount; }
    public void setAmount(BigDecimal amount) { this.amount = amount; }
    public PaymentStatus getStatus() { return status; }
    public void setStatus(PaymentStatus status) { this.status = status; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

What does this entity do?

The @Column(unique = true, nullable = false) on idempotencyKey enforces that no two payments can share the same key at the database level. This prevents duplicate transfers even under concurrent requests.

The @Enumerated(EnumType.STRING) stores the status as a readable string (like COMPLETED) in the database instead of an integer ordinal.

  • Create TransferRequest.java in the same package. This record defines the shape of incoming transfer requests:
package com.example.paymentorchestrator;

import java.math.BigDecimal;

public record TransferRequest(
        Long sourceAccountId,
        Long destinationAccountId,
        BigDecimal amount,
        String idempotencyKey
) {}
  • Create PaymentRepository.java in the same package:
package com.example.paymentorchestrator;

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface PaymentRepository extends JpaRepository<Payment, Long> {
    Optional<Payment> findByIdempotencyKey(String idempotencyKey);
}

What does findByIdempotencyKey do?

Spring Data JPA generates the SQL query automatically from the method name. findByIdempotencyKey translates to SELECT * FROM payment WHERE idempotency_key = ?. The orchestrator uses this to check whether a transfer has already been processed.

  • Save all four files.

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files in the com.example.paymentorchestrator package.

ⓧ I'd like to double check the full code

package com.example.paymentorchestrator;

public enum PaymentStatus {
    INITIATED,
    DEBIT_SUCCESS,
    COMPLETED,
    FAILED,
    COMPENSATED
}
package com.example.paymentorchestrator;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
public class Payment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String idempotencyKey;

    private Long sourceAccountId;
    private Long destinationAccountId;
    private BigDecimal amount;

    @Enumerated(EnumType.STRING)
    private PaymentStatus status;

    private LocalDateTime createdAt;

    public Payment() {
        this.createdAt = LocalDateTime.now();
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getIdempotencyKey() { return idempotencyKey; }
    public void setIdempotencyKey(String idempotencyKey) { this.idempotencyKey = idempotencyKey; }
    public Long getSourceAccountId() { return sourceAccountId; }
    public void setSourceAccountId(Long sourceAccountId) { this.sourceAccountId = sourceAccountId; }
    public Long getDestinationAccountId() { return destinationAccountId; }
    public void setDestinationAccountId(Long destinationAccountId) { this.destinationAccountId = destinationAccountId; }
    public BigDecimal getAmount() { return amount; }
    public void setAmount(BigDecimal amount) { this.amount = amount; }
    public PaymentStatus getStatus() { return status; }
    public void setStatus(PaymentStatus status) { this.status = status; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
package com.example.paymentorchestrator;

import java.math.BigDecimal;

public record TransferRequest(
        Long sourceAccountId,
        Long destinationAccountId,
        BigDecimal amount,
        String idempotencyKey
) {}
package com.example.paymentorchestrator;

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface PaymentRepository extends JpaRepository<Payment, Long> {
    Optional<Payment> findByIdempotencyKey(String idempotencyKey);
}
Implement the saga orchestrator with RestClient

The orchestrator needs a way to call the Account Service over HTTP. Spring Boot auto-configures a RestClient.Builder bean that you inject into your service class and use to build a typed HTTP client.

  • Create ServiceUnavailableException.java in the same package. This custom exception signals that a downstream service is unreachable:
package com.example.paymentorchestrator;

public class ServiceUnavailableException extends RuntimeException {
    public ServiceUnavailableException(String message) {
        super(message);
    }
}
  • Create AccountServiceClient.java in the same package. This class wraps all HTTP calls to the Account Service and protects them with a circuit breaker:
package com.example.paymentorchestrator;

import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

import java.math.BigDecimal;
import java.util.Map;

@Service
public class AccountServiceClient {

    private final RestClient restClient;

    public AccountServiceClient(RestClient.Builder restClientBuilder,
                                @Value("${account-service.base-url}") String baseUrl) {
        this.restClient = restClientBuilder.baseUrl(baseUrl).build();
    }

    @CircuitBreaker(name = "accountService", fallbackMethod = "debitFallback")
    public Map debit(Long accountId, BigDecimal amount, String transactionId) {
        return restClient.post()
                .uri("/api/accounts/{id}/debit", accountId)
                .body(Map.of("amount", amount, "transactionId", transactionId))
                .retrieve()
                .body(Map.class);
    }

    @CircuitBreaker(name = "accountService", fallbackMethod = "creditFallback")
    public Map credit(Long accountId, BigDecimal amount, String transactionId) {
        return restClient.post()
                .uri("/api/accounts/{id}/credit", accountId)
                .body(Map.of("amount", amount, "transactionId", transactionId))
                .retrieve()
                .body(Map.class);
    }

    public Map compensateDebit(Long accountId, BigDecimal amount, String transactionId) {
        return restClient.post()
                .uri("/api/accounts/{id}/compensate-debit", accountId)
                .body(Map.of("amount", amount, "transactionId", transactionId))
                .retrieve()
                .body(Map.class);
    }

    private Map debitFallback(Long accountId, BigDecimal amount, String transactionId, Throwable t) {
        throw new ServiceUnavailableException("Account Service unavailable for debit: " + t.getMessage());
    }

    private Map creditFallback(Long accountId, BigDecimal amount, String transactionId, Throwable t) {
        throw new ServiceUnavailableException("Account Service unavailable for credit: " + t.getMessage());
    }
}

What does this code do?

The constructor injects Spring Boot's auto-configured RestClient.Builder and sets the base URL from application.yml. Every HTTP call then uses relative paths like /api/accounts/{id}/debit.

The @CircuitBreaker annotation from Resilience4j wraps the debit and credit methods. If the Account Service fails repeatedly, the circuit opens and calls go directly to the fallback method instead of timing out.

The compensateDebit method intentionally has no circuit breaker. Compensation must always attempt to reach the Account Service to reverse a debit, even if previous calls failed.

Why RestClient instead of WebClient?

Spring Boot 3.5 recommends RestClient for applications that do not use reactive programming. It provides a modern fluent API without pulling in the WebFlux dependency. Your payment orchestrator uses blocking (synchronous) HTTP calls, which makes RestClient the correct choice.

  • Now create PaymentSagaOrchestrator.java in the same package. This is the core saga coordination logic:
package com.example.paymentorchestrator;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class PaymentSagaOrchestrator {

    private final PaymentRepository paymentRepository;
    private final AccountServiceClient accountServiceClient;

    public PaymentSagaOrchestrator(PaymentRepository paymentRepository,
                                   AccountServiceClient accountServiceClient) {
        this.paymentRepository = paymentRepository;
        this.accountServiceClient = accountServiceClient;
    }

    @Transactional
    public Payment processTransfer(TransferRequest request) {
        // Idempotency check: return existing payment if this key was already processed
        var existing = paymentRepository.findByIdempotencyKey(request.idempotencyKey());
        if (existing.isPresent()) {
            return existing.get();
        }

        // Step 1: Create payment record as INITIATED
        Payment payment = new Payment();
        payment.setIdempotencyKey(request.idempotencyKey());
        payment.setSourceAccountId(request.sourceAccountId());
        payment.setDestinationAccountId(request.destinationAccountId());
        payment.setAmount(request.amount());
        payment.setStatus(PaymentStatus.INITIATED);
        paymentRepository.save(payment);

        // Step 2: Debit the source account
        try {
            accountServiceClient.debit(
                    request.sourceAccountId(), request.amount(), payment.getId().toString());
            payment.setStatus(PaymentStatus.DEBIT_SUCCESS);
            paymentRepository.save(payment);
        } catch (Exception e) {
            payment.setStatus(PaymentStatus.FAILED);
            paymentRepository.save(payment);
            return payment;
        }

        // Step 3: Credit the destination account
        try {
            accountServiceClient.credit(
                    request.destinationAccountId(), request.amount(), payment.getId().toString());
            payment.setStatus(PaymentStatus.COMPLETED);
            paymentRepository.save(payment);
        } catch (Exception e) {
            // Compensation: reverse the debit if credit fails
            accountServiceClient.compensateDebit(
                    request.sourceAccountId(), request.amount(), payment.getId().toString());
            payment.setStatus(PaymentStatus.COMPENSATED);
            paymentRepository.save(payment);
        }

        return payment;
    }
}

How does the saga state machine work?

The orchestrator drives a strict sequence: INITIATED to DEBIT_SUCCESS to COMPLETED. Each transition is persisted to the database before the next external call.

If the debit fails (insufficient funds or service down), the payment moves to FAILED and stops. No compensation is needed because nothing was taken.

If the credit fails after a successful debit, the orchestrator calls compensateDebit to reverse the debit and marks the payment as COMPENSATED. The source account gets its money back.

  • Save ServiceUnavailableException.java, AccountServiceClient.java, and PaymentSagaOrchestrator.java.

Seeing import errors in IntelliJ?

If IntelliJ shows red underlines on the Resilience4j import, right-click on pom.xml and select Maven then Reload Project. This re-downloads dependencies and refreshes IntelliJ's index.

help me fix Resilience4j import errors in my Payment Orchestrator

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before continuing.

ⓧ I'd like to double check the full code

package com.example.paymentorchestrator;

public class ServiceUnavailableException extends RuntimeException {
    public ServiceUnavailableException(String message) {
        super(message);
    }
}
package com.example.paymentorchestrator;

import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

import java.math.BigDecimal;
import java.util.Map;

@Service
public class AccountServiceClient {

    private final RestClient restClient;

    public AccountServiceClient(RestClient.Builder restClientBuilder,
                                @Value("${account-service.base-url}") String baseUrl) {
        this.restClient = restClientBuilder.baseUrl(baseUrl).build();
    }

    @CircuitBreaker(name = "accountService", fallbackMethod = "debitFallback")
    public Map debit(Long accountId, BigDecimal amount, String transactionId) {
        return restClient.post()
                .uri("/api/accounts/{id}/debit", accountId)
                .body(Map.of("amount", amount, "transactionId", transactionId))
                .retrieve()
                .body(Map.class);
    }

    @CircuitBreaker(name = "accountService", fallbackMethod = "creditFallback")
    public Map credit(Long accountId, BigDecimal amount, String transactionId) {
        return restClient.post()
                .uri("/api/accounts/{id}/credit", accountId)
                .body(Map.of("amount", amount, "transactionId", transactionId))
                .retrieve()
                .body(Map.class);
    }

    public Map compensateDebit(Long accountId, BigDecimal amount, String transactionId) {
        return restClient.post()
                .uri("/api/accounts/{id}/compensate-debit", accountId)
                .body(Map.of("amount", amount, "transactionId", transactionId))
                .retrieve()
                .body(Map.class);
    }

    private Map debitFallback(Long accountId, BigDecimal amount, String transactionId, Throwable t) {
        throw new ServiceUnavailableException("Account Service unavailable for debit: " + t.getMessage());
    }

    private Map creditFallback(Long accountId, BigDecimal amount, String transactionId, Throwable t) {
        throw new ServiceUnavailableException("Account Service unavailable for credit: " + t.getMessage());
    }
}
package com.example.paymentorchestrator;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class PaymentSagaOrchestrator {

    private final PaymentRepository paymentRepository;
    private final AccountServiceClient accountServiceClient;

    public PaymentSagaOrchestrator(PaymentRepository paymentRepository,
                                   AccountServiceClient accountServiceClient) {
        this.paymentRepository = paymentRepository;
        this.accountServiceClient = accountServiceClient;
    }

    @Transactional
    public Payment processTransfer(TransferRequest request) {
        var existing = paymentRepository.findByIdempotencyKey(request.idempotencyKey());
        if (existing.isPresent()) {
            return existing.get();
        }

        Payment payment = new Payment();
        payment.setIdempotencyKey(request.idempotencyKey());
        payment.setSourceAccountId(request.sourceAccountId());
        payment.setDestinationAccountId(request.destinationAccountId());
        payment.setAmount(request.amount());
        payment.setStatus(PaymentStatus.INITIATED);
        paymentRepository.save(payment);

        try {
            accountServiceClient.debit(
                    request.sourceAccountId(), request.amount(), payment.getId().toString());
            payment.setStatus(PaymentStatus.DEBIT_SUCCESS);
            paymentRepository.save(payment);
        } catch (Exception e) {
            payment.setStatus(PaymentStatus.FAILED);
            paymentRepository.save(payment);
            return payment;
        }

        try {
            accountServiceClient.credit(
                    request.destinationAccountId(), request.amount(), payment.getId().toString());
            payment.setStatus(PaymentStatus.COMPLETED);
            paymentRepository.save(payment);
        } catch (Exception e) {
            accountServiceClient.compensateDebit(
                    request.sourceAccountId(), request.amount(), payment.getId().toString());
            payment.setStatus(PaymentStatus.COMPENSATED);
            paymentRepository.save(payment);
        }

        return payment;
    }
}
Create the PaymentController and test the transfer

The controller exposes the saga as a single REST endpoint. Callers submit a transfer request and receive the final payment status.

  • Create PaymentController.java in the same package:
package com.example.paymentorchestrator;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api/payments")
public class PaymentController {

    private final PaymentSagaOrchestrator sagaOrchestrator;

    public PaymentController(PaymentSagaOrchestrator sagaOrchestrator) {
        this.sagaOrchestrator = sagaOrchestrator;
    }

    @PostMapping("/transfer")
    public ResponseEntity<?> transfer(@RequestBody TransferRequest request) {
        try {
            Payment payment = sagaOrchestrator.processTransfer(request);
            return ResponseEntity.ok(Map.of(
                    "paymentId", payment.getId(),
                    "status", payment.getStatus(),
                    "idempotencyKey", payment.getIdempotencyKey()
            ));
        } catch (ServiceUnavailableException e) {
            return ResponseEntity.status(503).body(Map.of("error", e.getMessage()));
        }
    }
}

What does this code do?

The transfer method delegates everything to the saga orchestrator. If the Account Service is completely unreachable (circuit breaker open), it catches ServiceUnavailableException and returns a 503 status to the caller.

Successful responses include the paymentId, the final status, and the idempotencyKey so the caller can track the transfer.

  • Save PaymentController.java.
  • Make sure your Account Service is still running on port 8081.
  • Start the Payment Orchestrator by running the PaymentOrchestratorApplication main class in IntelliJ (right-click the class and select Run).

Application won't start?

Check that your Docker containers are running (payment-db on port 5433). The application fails to start if it cannot connect to PostgreSQL.

Also verify that port 8082 is not already in use. If you see an address-already-in-use error, stop any other process on that port.

help me debug why my Payment Orchestrator won't start

  • Test a successful transfer by running this curl command in your terminal:
curl -X POST http://localhost:8082/api/payments/transfer \
  -H "Content-Type: application/json" \
  -d '{"sourceAccountId": 1, "destinationAccountId": 2, "amount": 500.00, "idempotencyKey": "txn-001"}'

You should see a JSON response with "status":"COMPLETED" and a paymentId value.

  • Verify the account balances changed by checking Alice's account:
curl http://localhost:8081/api/accounts/1/debit -X POST \
  -H "Content-Type: application/json" \
  -d '{"amount": 0, "transactionId": "check"}'

Alice's balance should now show 4500.00 (original 5000 minus the 500 transfer). Bob's balance should be 3500.00 (original 3000 plus 500).

Still seeing the original balances?

Make sure the curl response for the transfer showed COMPLETED status. If it shows FAILED, check the Account Service console for errors. A common cause is the data.sql seed data not having been loaded (accounts don't exist).

help me debug why my payment transfer is failing

✔️ Awesome, I've got everything!

Great. Your saga orchestrator is working end to end.

ⓧ I'd like to double check the full code

package com.example.paymentorchestrator;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api/payments")
public class PaymentController {

    private final PaymentSagaOrchestrator sagaOrchestrator;

    public PaymentController(PaymentSagaOrchestrator sagaOrchestrator) {
        this.sagaOrchestrator = sagaOrchestrator;
    }

    @PostMapping("/transfer")
    public ResponseEntity<?> transfer(@RequestBody TransferRequest request) {
        try {
            Payment payment = sagaOrchestrator.processTransfer(request);
            return ResponseEntity.ok(Map.of(
                    "paymentId", payment.getId(),
                    "status", payment.getStatus(),
                    "idempotencyKey", payment.getIdempotencyKey()
            ));
        } catch (ServiceUnavailableException e) {
            return ResponseEntity.status(503).body(Map.of("error", e.getMessage()));
        }
    }
}

Your payment pipeline is now processing transfers end to end. Next up, you will stress-test the idempotency and circuit breaker behavior to prove the system stays consistent under failure.

Test Failure Scenarios End-to-End

Your pipeline processes successful transfers, but production systems break. A downstream service crashes mid-transaction. Duplicate requests arrive simultaneously. The question is whether your system maintains consistency under these conditions.

In this step, you will simulate real failure scenarios to prove the saga compensates correctly, idempotency blocks duplicates, and the circuit breaker protects against cascading failures.

In this step, get ready to:
  • Trigger automatic compensation by simulating a credit endpoint failure.
  • Prove idempotency holds under concurrent pressure.
  • Verify system consistency across all saga states and circuit breaker behavior.
Test the compensation flow

The saga's most critical guarantee is compensation. If the credit step fails after a successful debit, the orchestrator must reverse the debit automatically. You will simulate this by temporarily forcing the credit endpoint to return a 500 error.

  • In IntelliJ IDEA, open AccountController.java in the account-service project.
  • Find the credit method. Locate this line at the top of the method body:
Account account = accountRepository.findByIdForUpdate(id)
        .orElse(null);
  • Add the following lines directly above that line to simulate a failure for account ID 2:
// Temporary: simulate downstream credit failure for testing
if (id == 2L) {
    return ResponseEntity.status(500).body(Map.of("error", "Simulated credit failure"));
}

What does this simulate?

This forces the credit endpoint to return HTTP 500 whenever the destination is account 2. The orchestrator will interpret this as a failed credit step and trigger the compensate-debit call to reverse the money taken from the source account.

  • Save AccountController.java.
  • Stop the Account Service in IntelliJ IDEA (click the red square stop button in the Run panel).
  • Restart it by clicking the green play button or pressing Ctrl+Shift+F10 (Windows) or Ctrl+R (macOS).

Before sending the test transfer, check the current balance of account 1 so you can confirm it remains unchanged after compensation.

  • Query account 1's balance by running this command in your terminal:
docker exec -it account-db psql -U account_user -d accountdb -c "SELECT id, balance FROM account;"

Note the balance for account 1. It may differ from 5000.00 if you ran transfers in previous steps.

  • Send a transfer request that targets account 2 as the destination by running:
curl -X POST http://localhost:8082/api/payments/transfer \
  -H "Content-Type: application/json" \
  -d '{"sourceAccountId": 1, "destinationAccountId": 2, "amount": 200.00, "idempotencyKey": "test-compensation-001"}'

You should see a response with "status":"COMPENSATED". This confirms the orchestrator detected the failed credit and reversed the debit.

  • Verify that account balances are unchanged by running the same psql query again:
docker exec -it account-db psql -U account_user -d accountdb -c "SELECT id, balance FROM account;"

Both balances should match what they were before the transfer. The debit was reversed by the compensation call.

Seeing COMPLETED instead of COMPENSATED?

Make sure you restarted the Account Service after adding the simulated failure code. If the old version is still running, the credit endpoint will succeed normally.

Also confirm the if (id == 2L) check is inside the credit method, not the debit method.

Why is my transfer not showing COMPENSATED status?

  • Now revert the temporary change. Remove the if (id == 2L) block you added to the credit method in AccountController.java.
  • Save the file and restart the Account Service again.

Why revert?

The simulated failure was only for testing. You need the credit endpoint working normally for the remaining tests in this step.

Test concurrent idempotency

In production, network retries and impatient clients can send the same payment request multiple times within milliseconds. Your idempotency enforcement must guarantee that only one payment is processed regardless of how many duplicates arrive.

  • Send the same idempotency key 5 times in rapid succession by running this loop:
for i in $(seq 1 5); do
  curl -s -X POST http://localhost:8082/api/payments/transfer \
    -H "Content-Type: application/json" \
    -d '{"sourceAccountId": 1, "destinationAccountId": 2, "amount": 50.00, "idempotencyKey": "test-concurrent-001"}' &
done
wait

What does this loop do?

The & at the end of each curl command runs it in the background. All 5 requests fire nearly simultaneously. The wait at the end pauses until all background jobs finish.

Every response should return the same paymentId and status. Only the first request actually processes the payment. The rest find the existing record by idempotencyKey and return it immediately.

  • Confirm only one payment was created by querying the payment database:
docker exec -it payment-db psql -U payment_user -d paymentdb -c "SELECT id, idempotency_key, status FROM payment WHERE idempotency_key = 'test-concurrent-001';"

You should see exactly one row with status COMPLETED. If multiple rows appeared, the idempotency check would have failed.

Seeing multiple rows?

If you see more than one row for the same idempotency_key, check that the @Column(unique = true) annotation is present on the idempotencyKey field in Payment.java. The database constraint is the final safety net against race conditions.

Why am I seeing duplicate payment records?

Review the saga state machine and circuit breaker

The final verification proves end-to-end system consistency. You will query all payment states, then stress-test the circuit breaker by taking the Account Service offline.

  • Query the full payment table to see all saga states by running:
docker exec -it payment-db psql -U payment_user -d paymentdb -c "SELECT id, idempotency_key, amount, status, created_at FROM payment ORDER BY created_at;"

You should see payments in COMPLETED, COMPENSATED, and potentially FAILED states. Every COMPENSATED payment means the debit was reversed. No payment should be stuck in DEBIT_SUCCESS without progressing.

What does a healthy saga state machine look like?

A consistent system shows only terminal states: COMPLETED (both steps succeeded), COMPENSATED (credit failed, debit reversed), or FAILED (debit failed, nothing to reverse). If you see INITIATED or DEBIT_SUCCESS lingering in the table, that indicates an incomplete saga execution.

Now test the circuit breaker by taking the Account Service completely offline.

  • Stop the Account Service in IntelliJ IDEA.
  • Send 6 payment requests in rapid succession to trip the circuit by running:
for i in $(seq 1 6); do
  echo "Request $i:"
  curl -s -X POST http://localhost:8082/api/payments/transfer \
    -H "Content-Type: application/json" \
    -d "{\"sourceAccountId\": 1, \"destinationAccountId\": 2, \"amount\": 10.00, \"idempotencyKey\": \"circuit-test-$i\"}"
  echo ""
done

Why 6 requests?

Your circuit breaker is configured with slidingWindowSize: 5 and failureRateThreshold: 50. With a COUNT_BASED window of size 5, the circuit evaluates after 5 calls fill the window. Since all 5 calls fail (Account Service is down), the failure rate is 100% which exceeds the 50% threshold. The circuit opens, and the 6th request fast-fails without attempting a network call.

The first 5 responses show FAILED status (the orchestrator caught the exception). The 6th response shows a 503 with an error message indicating the Account Service is unavailable. This fast-fail is the circuit breaker protecting your system from wasting resources on a known-dead downstream.

  • Check the circuit breaker state via the Actuator endpoint by running:
curl -s http://localhost:8082/actuator/health | python3 -m json.tool

You should see the circuitBreakers section showing the accountService circuit in OPEN state.

  • Restart the Account Service in IntelliJ IDEA.
  • Wait 10 seconds for the waitDurationInOpenState to elapse.
  • Send another transfer to trigger the half-open transition:
curl -s -X POST http://localhost:8082/api/payments/transfer \
  -H "Content-Type: application/json" \
  -d '{"sourceAccountId": 1, "destinationAccountId": 2, "amount": 10.00, "idempotencyKey": "circuit-recovery-001"}'

This request should succeed with COMPLETED status, proving the circuit transitioned from OPEN to HALF_OPEN (allowed 2 test calls through) and then back to CLOSED after the successful call.

  • Confirm the circuit is closed again by checking the health endpoint:
curl -s http://localhost:8082/actuator/health | python3 -m json.tool

The accountService circuit breaker should now show CLOSED state. Your system has fully recovered.

Circuit still showing OPEN after 10 seconds?

The waitDurationInOpenState timer starts from the moment the circuit opened. If you waited less than 10 seconds between the last failed request and the recovery request, wait a bit longer and try again.

Also confirm the Account Service is fully started (check its logs for the Spring Boot startup banner) before sending the recovery request.

Why is my circuit breaker stuck in OPEN state?

Your payment pipeline has survived every failure scenario. Compensation reverses orphaned debits. Idempotency blocks duplicates. The circuit breaker prevents cascading failures. Next up, you will take on the Secret Mission and add an audit trail for regulatory compliance.

Secret mission

Add a Transactional Outbox for Saga Events

Your payment pipeline works, but there is no record of what happened between steps. Banks and fintech regulators require a complete audit trail of every state transition in a distributed transaction. Can you add a transactional outbox that persists saga events for auditability and replay?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with Docker, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Two Docker containers (account-db and payment-db) running PostgreSQL 17.
  • Two Docker volumes (account-data and payment-data) persisting database data.
  • Two Spring Boot services (Account Service on port 8081 and Payment Orchestrator on port 8082).
  • Project files: docker-compose.yml, account-service/, and payment-orchestrator/ directories.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with the payment pipeline.

  • Your Docker containers will continue running and using memory until you stop them or restart your machine.
  • The Spring Boot services will remain active in their terminal windows.

Pause - I'll come back to this later

Shut down running processes to free up memory and ports, but keep your project files and Docker volumes intact.

  • Stop both Spring Boot services by pressing Ctrl+C in each terminal window where they are running.
  • Stop the Docker containers by running this command from the directory containing your docker-compose.yml:
docker compose down
  • This stops and removes the containers but preserves the account-data and payment-data volumes. Your database data remains intact.
  • When you are ready to resume, run docker compose up -d from the same directory, then start each Spring Boot service again.

Delete - I don't want to use this again

Remove all project resources completely and start fresh.

  • Stop both Spring Boot services by pressing Ctrl+C in each terminal window.
  • Stop the Docker containers and remove their volumes by running this from the directory containing docker-compose.yml:
docker compose down -v
  • The -v flag removes the named volumes (account-data and payment-data), deleting all persisted database data.
  • Delete the project folder from your Desktop by running:

macOS

cd ~/Desktop
rm -rf account-service payment-orchestrator docker-compose.yml

Windows

cd ~\Desktop
rmdir /s /q account-service
rmdir /s /q payment-orchestrator
del docker-compose.yml
  • Confirm the files are gone by running ls (macOS) or dir (Windows) on your Desktop. You should no longer see account-service, payment-orchestrator, or docker-compose.yml listed.

Nice Work!

Nice Work!

You just built a production-pattern payment processing pipeline with two Spring Boot microservices coordinating money transfers. This is the exact distributed systems architecture that fintech companies test for in backend engineering interviews.

What you learned:

  • Implemented the Saga orchestration pattern with step-by-step execution and automatic compensation logic that reverses completed steps when a downstream service fails.
  • Protected inter-service calls with a Resilience4j circuit breaker that fast-fails requests when the Account Service is unavailable, then self-heals through half-open state.
  • Enforced idempotency at the orchestrator level, preventing duplicate payment processing even under concurrent requests with the same key.
  • Secret Mission: Added a transactional outbox pattern that persists saga events for auditability and replay capability.

Ready to quiz yourself?