Build a Payment Processing Microservice
Build a fault-tolerant payment microservice with idempotency and circuit breakers.
Introduction
30 Second Summary
Every time you tap your card or pay online, a payment microservice decides in milliseconds whether to approve or reject the charge. Behind that speed is a system designed to never charge you twice, even if the network drops mid-transaction.
In this project, you will build a Payment Processing microservice using Spring Boot and Java 21 that handles transactions with the same reliability patterns used by Stripe and Razorpay.
What You'll Build
A Digital Payments microservice that processes payment transactions, tracks state transitions, and protects against duplicate charges using industry-standard idempotency keys.
By the end of this project, you'll have:
- A Spring Boot 3.5.15 microservice with Java 21 virtual threads processing concurrent payment requests, backed by a PostgreSQL database.
- An idempotent payment API that uses idempotency keys to prevent duplicate charges, following the exact pattern used by production payment systems.
- A fault-tolerant service with a Resilience4j circuit breaker protecting against external gateway failures, fully containerized with Docker Compose.
- Secret Mission: Add rate limiting to protect your payment API from abuse, returning HTTP 429 when limits are exceeded.
Are there any prerequisites?
This project assumes basic Spring Boot experience with REST controllers and JPA entities. You should also be familiar with HTTP APIs and relational databases.
If you have built a simple Spring Boot CRUD app before, you have everything you need to get started.
Set Up the Project
Before you can build payment processing logic, you need a working Spring Boot project with a database ready to store transactions.
In this step, you will scaffold a Spring Boot project using Spring Initializr, start a PostgreSQL database in a Docker container, and configure the application to connect everything together.
In this step, get ready to:
- Scaffold a Spring Boot 3.5.15 project with Java 21.
- Start a PostgreSQL database container with Docker.
- Configure the application and verify it starts successfully.
Scaffold the Spring Boot project
Spring Initializr is a web tool that generates pre-configured Spring Boot projects. Instead of clicking through the web UI, you will use a curl command to download the project with all the right settings in one shot.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), then type Terminal and press Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Download and unzip the Spring Boot project by running this command:
curl https://start.spring.io/starter.zip \
-d type=maven-project \
-d language=java \
-d bootVersion=3.5.15 \
-d baseDir=payment-service \
-d groupId=com.payments \
-d artifactId=payment-service \
-d name=payment-service \
-d packageName=com.payments \
-d javaVersion=21 \
-d dependencies=web,data-jpa,postgresql,actuator,validation \
-o payment-service.zip && unzip payment-service.zip
What does this command do?
This curl command talks to Spring Initializr's API and downloads a zip file containing a ready-to-go Maven project.
The -d flags tell Initializr what you want: Java 21, Spring Boot 3.5.15, and five starter dependencies (web, JPA, PostgreSQL driver, Actuator health endpoints, and validation).
- Open the project in VS Code by running:
code payment-service
VS Code opens with the payment-service folder as your workspace. You should see pom.xml, a src folder, and the Maven wrapper files in the Explorer sidebar.
Don't see the project files?
Make sure the curl command completed without errors. If you see a "connection refused" message, check that you have internet access.
If code is not recognized, open VS Code manually, click File then Open Folder, and select the payment-service folder on your Desktop.
Help me troubleshoot opening my Spring Boot project in VS Code.
Next, you need to add two dependencies that Spring Initializr does not include by default: Resilience4j for circuit breaker patterns and Spring AOP for annotation-based aspects.
- In the VS Code Explorer sidebar, click pom.xml to open it.
- Select all the content (Cmd+A on macOS, Ctrl+A on Windows) and replace it with the following:
<?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>
<relativeTo/>
</parent>
<groupId>com.payments</groupId>
<artifactId>payment-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>payment-service</name>
<description>Digital Payment Processing Microservice</description>
<properties>
<java.version>21</java.version>
<resilience4j.version>2.4.0</resilience4j.version>
</properties>
<dependencies>
<!-- Web starter for REST API endpoints -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JPA starter for database persistence -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Actuator for health checks and monitoring -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Validation for request body constraints -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- PostgreSQL JDBC driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Resilience4j for circuit breaker pattern -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>${resilience4j.version}</version>
</dependency>
<!-- AOP support for Resilience4j annotations -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Test starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
What did you add to the generated pom.xml?
You added two dependencies on top of what Spring Initializr generated.
- resilience4j-spring-boot3 (version 2.4.0) provides the circuit breaker pattern you will use later to protect against gateway failures.
- spring-boot-starter-aop enables annotation-based aspects, which is how Resilience4j applies its patterns to your methods.
- Save pom.xml by pressing Cmd+S (macOS) or Ctrl+S (Windows).
VS Code may prompt you to reload the Java project. Click Yes if you see this notification.
Start PostgreSQL with Docker
Your payment microservice needs a database to store payment records and idempotency keys. You will run PostgreSQL as a Docker container so you do not need to install it directly on your machine.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Start a PostgreSQL container by running this command:
docker run -d --name payments-db -e POSTGRES_DB=payments -e POSTGRES_USER=payments -e POSTGRES_PASSWORD=payments123 -p 5432:5432 postgres:16-alpine
What does this command do?
- docker run -d starts the container in detached mode (runs in the background).
- --name payments-db gives the container a memorable name you can reference later.
- The -e flags set environment variables that configure the database name, username, and password.
- -p 5432:5432 maps the container's PostgreSQL port to your machine so your app can connect.
- postgres:16-alpine is a lightweight PostgreSQL 16 image.
- Verify the container is running:
docker ps
You should see a row showing payments-db with status Up and port 5432 mapped.
Container not starting?
If you see "port is already allocated", another process is using port 5432. Stop it with docker stop payments-db && docker rm payments-db and try again.
If you see "Cannot connect to the Docker daemon", make sure Docker Desktop is running. Check for the Docker whale icon in your system tray.
Help me get my PostgreSQL Docker container running.
Configure the application and verify
Spring Boot needs to know how to connect to your PostgreSQL container. You will replace the generated application.properties file with an application.yml file that configures the datasource, enables virtual threads, and sets up monitoring endpoints.
Why YAML instead of properties?
Spring Boot supports both application.properties and application.yml for configuration. YAML is easier to read when you have deeply nested settings like database and Resilience4j configuration.
- In the VS Code Explorer, expand src then main then resources.
- Right-click application.properties and select Delete. Confirm the deletion.
- Right-click the resources folder and select New File.
- Name the file application.yml and press Enter.
- Paste the following configuration into application.yml:
spring:
application:
name: payment-service
threads:
virtual:
enabled: true
datasource:
url: jdbc:postgresql://localhost:5432/payments
username: payments
password: payments123
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,info,circuitbreakers,ratelimiters
endpoint:
health:
show-details: always
resilience4j:
circuitbreaker:
instances:
paymentGateway:
sliding-window-size: 10
failure-rate-threshold: 50
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
register-health-indicator: true
What is this configuration doing?
- spring.threads.virtual.enabled: true activates Java 21 virtual threads, letting the server handle thousands of concurrent requests without thread pool exhaustion.
- The datasource block points to the PostgreSQL container you just started.
- ddl-auto: update tells Hibernate to automatically create database tables from your Java entities.
- The management block exposes health and monitoring endpoints you will use to verify the app is running.
- The resilience4j block pre-configures a circuit breaker named paymentGateway that you will wire up in a later step.
- Save application.yml by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Start the application by running this command in the VS Code terminal:
./mvnw spring-boot:run
Maven will download dependencies on the first run (this takes 1-2 minutes). When you see a line containing Started PaymentServiceApplication, the app is ready.
- Open a second terminal tab by pressing Cmd+Shift+` (macOS) or Ctrl+Shift+` (Windows).
- Verify the health endpoint by running:
curl http://localhost:8080/actuator/health
You should see the response {"status":"UP"} confirming your application is connected to the database and running on port 8080.
Seeing a connection refused error?
If the curl command returns "Connection refused", the Spring Boot app may still be starting. Wait for the Started PaymentServiceApplication message in the first terminal tab before retrying.
If the app fails to start with a database connection error, confirm the PostgreSQL container is running with docker ps. You should see payments-db listed.
Help me get my Spring Boot app connected to PostgreSQL.
✔️ Awesome, I've got everything!
Great. Make sure you have saved both pom.xml and application.yml before moving on.
ⓧ I'd like to double check the full code
Here is the complete pom.xml you should have:
<?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.payments</groupId>
<artifactId>payment-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>payment-service</name>
<description>Digital Payment Processing Microservice</description>
<properties>
<java.version>21</java.version>
<resilience4j.version>2.4.0</resilience4j.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.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>${resilience4j.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Here is the complete application.yml:
spring:
application:
name: payment-service
threads:
virtual:
enabled: true
datasource:
url: jdbc:postgresql://localhost:5432/payments
username: payments
password: payments123
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,info,circuitbreakers,ratelimiters
endpoint:
health:
show-details: always
resilience4j:
circuitbreaker:
instances:
paymentGateway:
sliding-window-size: 10
failure-rate-threshold: 50
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
register-health-indicator: true
Here is the PaymentServiceApplication.java generated by Spring Initializr (you did not modify this file):
package com.payments;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class PaymentServiceApplication {
public static void main(String[] args) {
SpringApplication.run(PaymentServiceApplication.class, args);
}
}
Your project foundation is in place. You have a running Spring Boot application connected to a PostgreSQL database, ready to handle payment data. Next up, you will build the domain model that represents payments and idempotency keys.
Build the Payment Domain Model
Your Spring Boot application is running and connected to PostgreSQL. Now you need to define the data structures that represent a payment transaction.
In production payment systems, the domain model is everything. It determines how payments flow through states, how you prevent duplicate charges, and how you store transaction history. In this step, you will build the JPA entities, repository interfaces, and data transfer objects that form the backbone of your service.
In this step, get ready to:
- Create JPA entities for payments and idempotency keys.
- Build Spring Data repository interfaces for database access.
- Define DTO records for API request and response shapes.
Create the JPA entities
Every payment moves through a lifecycle: initiated, processing, completed, or failed. You will model this as a Java enum, then build two JPA entities. The Payment entity stores the transaction itself. The IdempotencyKey entity stores cached responses so duplicate requests never create duplicate charges.
- In VS Code, create a new folder at src/main/java/com/payments/model.
- Create a new file called PaymentStatus.java inside the model folder, then paste the following code:
package com.payments.model;
public enum PaymentStatus {
INITIATED,
PROCESSING,
COMPLETED,
FAILED
}
What does this code do?
This enum defines the four states a payment can exist in. A payment always starts as INITIATED, moves to PROCESSING when sent to the gateway, and ends as either COMPLETED or FAILED.
This state machine pattern prevents invalid transitions. You cannot go from FAILED back to PROCESSING, for example.
- Save PaymentStatus.java.
- You won't see a visible change yet. The next chunk uses this enum.
- Create a new file called Payment.java inside the model folder, then paste the following code:
package com.payments.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "payments")
public class Payment {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false)
private BigDecimal amount;
@Column(nullable = false, length = 3)
private String currency;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private PaymentStatus status;
@Column(nullable = false)
private String merchantId;
private String description;
private String gatewayTransactionId;
private String failureReason;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@Column(nullable = false)
private Instant updatedAt;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
updatedAt = Instant.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public PaymentStatus getStatus() { return status; }
public void setStatus(PaymentStatus status) { this.status = status; }
public String getMerchantId() { return merchantId; }
public void setMerchantId(String merchantId) { this.merchantId = merchantId; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getGatewayTransactionId() { return gatewayTransactionId; }
public void setGatewayTransactionId(String gatewayTransactionId) { this.gatewayTransactionId = gatewayTransactionId; }
public String getFailureReason() { return failureReason; }
public void setFailureReason(String failureReason) { this.failureReason = failureReason; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
}
What does this code do?
- @Entity and @Table(name = "payments") tell Hibernate to map this class to a payments database table.
- @GeneratedValue(strategy = GenerationType.UUID) auto-generates a unique UUID for every new payment.
- @Enumerated(EnumType.STRING) stores the status as a readable string (INITIATED) rather than an integer index.
- @PrePersist and @PreUpdate are JPA lifecycle hooks that automatically set timestamps when a record is created or updated.
- The failureReason field captures why a payment failed, which is critical for debugging production issues.
- Save Payment.java.
- You won't see a change yet. The next file completes the entity layer.
- Create a new file called IdempotencyKey.java inside the model folder, then paste the following code:
package com.payments.model;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "idempotency_keys")
public class IdempotencyKey {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false, unique = true)
private String keyHash;
@Column(nullable = false)
private UUID paymentId;
@Column(columnDefinition = "TEXT")
private String responseBody;
@Column(nullable = false)
private int httpStatus;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
}
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public String getKeyHash() { return keyHash; }
public void setKeyHash(String keyHash) { this.keyHash = keyHash; }
public UUID getPaymentId() { return paymentId; }
public void setPaymentId(UUID paymentId) { this.paymentId = paymentId; }
public String getResponseBody() { return responseBody; }
public void setResponseBody(String responseBody) { this.responseBody = responseBody; }
public int getHttpStatus() { return httpStatus; }
public void setHttpStatus(int httpStatus) { this.httpStatus = httpStatus; }
public Instant getCreatedAt() { return createdAt; }
}
What is an idempotency key?
When a network request fails mid-flight, the client retries. Without idempotency protection, the retry creates a second charge. The IdempotencyKey entity stores a hash of each unique request alongside the cached response.
The unique = true constraint on keyHash means the database itself enforces uniqueness. Even under concurrent requests, only one insert succeeds.
- Save IdempotencyKey.java.
Seeing red underlines in VS Code?
If VS Code shows errors on imports like jakarta.persistence.*, the Java extension may still be indexing. Wait a few seconds for the language server to finish loading the Maven dependencies.
help me fix Java import errors in VS Code
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved in the model folder.
ⓧ I'd like to double check the full code
package com.payments.model;
public enum PaymentStatus {
INITIATED,
PROCESSING,
COMPLETED,
FAILED
}
package com.payments.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "payments")
public class Payment {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false)
private BigDecimal amount;
@Column(nullable = false, length = 3)
private String currency;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private PaymentStatus status;
@Column(nullable = false)
private String merchantId;
private String description;
private String gatewayTransactionId;
private String failureReason;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@Column(nullable = false)
private Instant updatedAt;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
updatedAt = Instant.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now();
}
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public PaymentStatus getStatus() { return status; }
public void setStatus(PaymentStatus status) { this.status = status; }
public String getMerchantId() { return merchantId; }
public void setMerchantId(String merchantId) { this.merchantId = merchantId; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getGatewayTransactionId() { return gatewayTransactionId; }
public void setGatewayTransactionId(String gatewayTransactionId) { this.gatewayTransactionId = gatewayTransactionId; }
public String getFailureReason() { return failureReason; }
public void setFailureReason(String failureReason) { this.failureReason = failureReason; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
}
package com.payments.model;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "idempotency_keys")
public class IdempotencyKey {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false, unique = true)
private String keyHash;
@Column(nullable = false)
private UUID paymentId;
@Column(columnDefinition = "TEXT")
private String responseBody;
@Column(nullable = false)
private int httpStatus;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
protected void onCreate() {
createdAt = Instant.now();
}
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public String getKeyHash() { return keyHash; }
public void setKeyHash(String keyHash) { this.keyHash = keyHash; }
public UUID getPaymentId() { return paymentId; }
public void setPaymentId(UUID paymentId) { this.paymentId = paymentId; }
public String getResponseBody() { return responseBody; }
public void setResponseBody(String responseBody) { this.responseBody = responseBody; }
public int getHttpStatus() { return httpStatus; }
public void setHttpStatus(int httpStatus) { this.httpStatus = httpStatus; }
public Instant getCreatedAt() { return createdAt; }
}
Create the repository interfaces
Spring Data JPA generates database queries from interface method signatures. You define an interface, and Spring provides the implementation at runtime.
- Create a new folder at src/main/java/com/payments/repository.
- Create a new file called PaymentRepository.java inside the repository folder, then paste the following code:
package com.payments.repository;
import com.payments.model.Payment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface PaymentRepository extends JpaRepository<Payment, UUID> {
}
What does this code do?
By extending JpaRepository<Payment, UUID>, you get save(), findById(), findAll(), and delete() methods automatically. No implementation code needed.
- Save PaymentRepository.java.
- Create a new file called IdempotencyKeyRepository.java inside the repository folder, then paste the following code:
package com.payments.repository;
import com.payments.model.IdempotencyKey;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface IdempotencyKeyRepository extends JpaRepository<IdempotencyKey, UUID> {
Optional<IdempotencyKey> findByKeyHash(String keyHash);
}
What does this code do?
The findByKeyHash method is a custom query. Spring Data parses the method name and generates a SELECT ... WHERE key_hash = ? query automatically. The Optional return type means it returns empty if no matching key exists.
- Save IdempotencyKeyRepository.java.
- You won't see a change yet. These interfaces are used by the service layer in the next step.
Import errors on JpaRepository?
Make sure the package declaration matches your folder structure exactly: com.payments.repository. If VS Code can't resolve JpaRepository, press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) and run Java: Clean Java Language Server Workspace.
help me resolve JpaRepository import errors
✔️ Awesome, I've got everything!
Great. Make sure both files are saved in the repository folder.
ⓧ I'd like to double check the full code
package com.payments.repository;
import com.payments.model.Payment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface PaymentRepository extends JpaRepository<Payment, UUID> {
}
package com.payments.repository;
import com.payments.model.IdempotencyKey;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface IdempotencyKeyRepository extends JpaRepository<IdempotencyKey, UUID> {
Optional<IdempotencyKey> findByKeyHash(String keyHash);
}
Create the DTO classes
DTOs (Data Transfer Objects) define the shape of data flowing in and out of your API. Java 21 records are perfect for this. They give you immutable objects with built-in toString(), equals(), and hashCode() in a single line.
- Create a new folder at src/main/java/com/payments/dto.
- Create a new file called PaymentRequest.java inside the dto folder, then paste the following code:
package com.payments.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
public record PaymentRequest(
@NotNull @DecimalMin("0.01") BigDecimal amount,
@NotBlank @Size(min = 3, max = 3) String currency,
@NotBlank String merchantId,
String description
) {}
What do the validation annotations do?
- @NotNull @DecimalMin("0.01") ensures the amount exists and is at least one paisa/cent.
- @NotBlank @Size(min = 3, max = 3) enforces a 3-character ISO currency code like INR or USD.
- @NotBlank on merchantId means a payment must always be associated with a merchant.
- description has no annotations because it is optional.
- Save PaymentRequest.java.
- Create a new file called PaymentResponse.java inside the dto folder, then paste the following code:
package com.payments.dto;
import com.payments.model.PaymentStatus;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
public record PaymentResponse(
UUID id,
BigDecimal amount,
String currency,
PaymentStatus status,
String merchantId,
String description,
String gatewayTransactionId,
String failureReason,
Instant createdAt,
Instant updatedAt
) {}
What does this code do?
PaymentResponse is the shape of data your API sends back to clients. It includes everything a client needs: the payment ID for tracking, the current status, timestamps, and any failure reason. Records automatically serialize to JSON via Spring's Jackson integration.
- Save PaymentResponse.java.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved in the dto folder.
ⓧ I'd like to double check the full code
package com.payments.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
public record PaymentRequest(
@NotNull @DecimalMin("0.01") BigDecimal amount,
@NotBlank @Size(min = 3, max = 3) String currency,
@NotBlank String merchantId,
String description
) {}
package com.payments.dto;
import com.payments.model.PaymentStatus;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
public record PaymentResponse(
UUID id,
BigDecimal amount,
String currency,
PaymentStatus status,
String merchantId,
String description,
String gatewayTransactionId,
String failureReason,
Instant createdAt,
Instant updatedAt
) {}
Verify the database tables
Hibernate's ddl-auto: update setting automatically creates database tables from your JPA entities on startup. Restart the application to trigger table creation.
- Stop the running application in your terminal by pressing Ctrl+C.
- Restart the application by running:
./mvnw spring-boot:run
- Wait for the application to start. You should see Started PaymentServiceApplication in the terminal output.
- Open a second terminal and verify that Hibernate created the tables by running:
docker exec -it payments-db psql -U payments -d payments -c "\dt"
- You should see idempotency_keys and payments listed in the output table.
What should I see?
The output lists your database tables. You should see two rows: one for idempotency_keys and one for payments. Both will show public as the schema and payments as the owner.
Tables not showing up?
- Check that your payments-db container is still running with docker ps. If it is not listed, restart it with docker start payments-db.
- Make sure application.yml has ddl-auto: update under spring.jpa.hibernate. If it says none or validate, Hibernate will not create tables.
- Check the Spring Boot startup logs for any HibernateException errors. Package declarations in your Java files must match their folder structure exactly.
help me debug why Hibernate is not creating my database tables
Your domain model is locked in. You have entities that map to database tables, repositories that provide data access, and DTOs that define your API contract. Next up, you will wire these together into a working payment endpoint with idempotency protection.
Implement the Idempotent Payment API
In the previous step, you built the domain model that defines what a payment looks like in your system. Now it's time to expose that model to the outside world through a REST API.
The critical challenge in any payment API is preventing duplicate charges. If a network glitch causes a client to retry a request, your system must recognize the retry and return the original result instead of processing a second payment. This pattern is called idempotency, and it's the exact approach used by production payment systems like Stripe and Razorpay.
In this step, get ready to:
- Build a REST controller with a POST endpoint for creating payments.
- Implement the idempotency logic in a service layer that prevents duplicate charges.
- Test the idempotent behavior by sending the same request twice and verifying only one payment is created.
Build the PaymentController
The controller is the thin layer that receives HTTP requests and delegates work to the service. It reads the Idempotency-Key header from each request and passes it along with the payment details to the service layer for processing.
- Create a new file at src/main/java/com/payments/controller/PaymentController.java and add the following code:
package com.payments.controller;
import com.payments.dto.PaymentRequest;
import com.payments.dto.PaymentResponse;
import com.payments.service.PaymentService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/payments")
public class PaymentController {
private final PaymentService paymentService;
// Spring injects the PaymentService automatically via constructor injection
public PaymentController(PaymentService paymentService) {
this.paymentService = paymentService;
}
// POST endpoint that reads the Idempotency-Key header to prevent duplicate payments
@PostMapping
public ResponseEntity<String> createPayment(
@Valid @RequestBody PaymentRequest request,
@RequestHeader("Idempotency-Key") String idempotencyKey) {
PaymentService.PaymentResult result = paymentService.initiatePayment(request, idempotencyKey);
return ResponseEntity
.status(result.httpStatus())
.header("Content-Type", "application/json")
.header("X-Idempotent-Replay", String.valueOf(result.cached()))
.body(result.responseBody());
}
// GET endpoint to check the status of a specific payment
@GetMapping("/{id}")
public ResponseEntity<PaymentResponse> getPayment(@PathVariable UUID id) {
return paymentService.getPayment(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}
What does this code do?
The @RequestHeader("Idempotency-Key") annotation tells Spring to extract the value of the Idempotency-Key HTTP header and bind it to the method parameter. If the header is missing, Spring automatically returns a 400 Bad Request.
The response includes an X-Idempotent-Replay header that tells the client whether this response was served from cache (true) or freshly processed (false). This is a common pattern in production payment APIs.
- Save PaymentController.java.
- You won't see a change yet. The next chunk creates the service class this controller depends on.
Implement the idempotency logic in PaymentService
The service layer is where the idempotency magic happens. Before creating a new payment, it checks if the idempotency key already exists in the database. If it does, the cached response is returned immediately without touching the payment gateway.
First, you need a placeholder for the payment gateway client that the service depends on. You'll build the real gateway with circuit breakers in the next step.
- Create a new file at src/main/java/com/payments/gateway/PaymentGatewayClient.java and add this placeholder:
package com.payments.gateway;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
public class PaymentGatewayClient {
// Placeholder that always succeeds - we'll add circuit breaker logic in Step 4
public GatewayResponse processPayment(UUID paymentId, long amountInPaise) {
String gatewayTxnId = "gw_" + UUID.randomUUID().toString().substring(0, 12);
return new GatewayResponse(true, gatewayTxnId, null);
}
public record GatewayResponse(boolean success, String transactionId, String errorMessage) {}
}
Why a placeholder gateway?
The PaymentService depends on PaymentGatewayClient to process payments. For now, this placeholder always returns success so you can focus on testing idempotency. In Step 4, you'll replace this with a realistic simulation that includes random failures and a circuit breaker.
- Save PaymentGatewayClient.java.
Now create the service class that orchestrates the full idempotent payment flow.
- Create a new file at src/main/java/com/payments/service/PaymentService.java and add the following code:
package com.payments.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.payments.dto.PaymentRequest;
import com.payments.dto.PaymentResponse;
import com.payments.gateway.PaymentGatewayClient;
import com.payments.model.IdempotencyKey;
import com.payments.model.Payment;
import com.payments.model.PaymentStatus;
import com.payments.repository.IdempotencyKeyRepository;
import com.payments.repository.PaymentRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import java.util.UUID;
@Service
public class PaymentService {
private static final Logger log = LoggerFactory.getLogger(PaymentService.class);
private final PaymentRepository paymentRepository;
private final IdempotencyKeyRepository idempotencyKeyRepository;
private final PaymentGatewayClient gatewayClient;
private final ObjectMapper objectMapper;
public PaymentService(PaymentRepository paymentRepository,
IdempotencyKeyRepository idempotencyKeyRepository,
PaymentGatewayClient gatewayClient,
ObjectMapper objectMapper) {
this.paymentRepository = paymentRepository;
this.idempotencyKeyRepository = idempotencyKeyRepository;
this.gatewayClient = gatewayClient;
this.objectMapper = objectMapper;
}
// Atomic check-and-insert: prevents duplicate payments via idempotency key
@Transactional
public PaymentResult initiatePayment(PaymentRequest request, String idempotencyKey) {
// Check for existing idempotency key
Optional<IdempotencyKey> existingKey = idempotencyKeyRepository.findByKeyHash(idempotencyKey);
if (existingKey.isPresent()) {
log.info("Idempotency key found: {}. Returning cached response.", idempotencyKey);
return new PaymentResult(existingKey.get().getResponseBody(), existingKey.get().getHttpStatus(), true);
}
// Create the payment with INITIATED status
Payment payment = new Payment();
payment.setAmount(request.amount());
payment.setCurrency(request.currency());
payment.setStatus(PaymentStatus.INITIATED);
payment.setMerchantId(request.merchantId());
payment.setDescription(request.description());
payment = paymentRepository.save(payment);
// Transition to PROCESSING before calling the gateway
payment.setStatus(PaymentStatus.PROCESSING);
payment = paymentRepository.save(payment);
// Call the external payment gateway
PaymentGatewayClient.GatewayResponse gatewayResponse =
gatewayClient.processPayment(payment.getId(), request.amount().longValue());
// Update status based on gateway response
if (gatewayResponse.success()) {
payment.setStatus(PaymentStatus.COMPLETED);
payment.setGatewayTransactionId(gatewayResponse.transactionId());
} else {
payment.setStatus(PaymentStatus.FAILED);
payment.setFailureReason(gatewayResponse.errorMessage());
}
payment = paymentRepository.save(payment);
// Serialize and cache the response for future idempotent replays
PaymentResponse response = toResponse(payment);
int httpStatus = payment.getStatus() == PaymentStatus.COMPLETED ? 201 : 200;
String responseJson = serialize(response);
IdempotencyKey newKey = new IdempotencyKey();
newKey.setKeyHash(idempotencyKey);
newKey.setPaymentId(payment.getId());
newKey.setResponseBody(responseJson);
newKey.setHttpStatus(httpStatus);
idempotencyKeyRepository.save(newKey);
return new PaymentResult(responseJson, httpStatus, false);
}
// Look up a payment by its UUID
public Optional<PaymentResponse> getPayment(UUID id) {
return paymentRepository.findById(id).map(this::toResponse);
}
// Convert a Payment entity to a PaymentResponse DTO
private PaymentResponse toResponse(Payment payment) {
return new PaymentResponse(
payment.getId(),
payment.getAmount(),
payment.getCurrency(),
payment.getStatus(),
payment.getMerchantId(),
payment.getDescription(),
payment.getGatewayTransactionId(),
payment.getFailureReason(),
payment.getCreatedAt(),
payment.getUpdatedAt()
);
}
// Serialize a response to JSON for caching in the idempotency key table
private String serialize(PaymentResponse response) {
try {
return objectMapper.writeValueAsString(response);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize response", e);
}
}
// Immutable result record returned to the controller
public record PaymentResult(String responseBody, int httpStatus, boolean cached) {}
}
How does the idempotency logic work?
The @Transactional annotation ensures the entire method runs as an atomic database operation. Either everything commits together, or nothing does.
The flow is: check if the idempotency key exists. If yes, return the cached response immediately. If no, create the payment, call the gateway, store the response alongside the idempotency key, and return.
The unique = true constraint on the keyHash column (from your IdempotencyKey entity) prevents race conditions at the database level. If two identical requests arrive simultaneously, only one can insert the key.
- Save PaymentService.java.
- Restart your application by running:
./mvnw spring-boot:run
- You should see the application start on port 8080 without any compilation errors.
Seeing a compilation error?
- If you see an error about PaymentGatewayClient not being found, make sure you created the file in the com.payments.gateway package (the src/main/java/com/payments/gateway/ directory).
- If you see an error about PaymentService not being found, check that it's in the com.payments.service package (the src/main/java/com/payments/service/ directory).
- Double-check that all import statements at the top of each file match the package paths of your other classes.
Help me debug my Spring Boot compilation error.
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the full PaymentService.java:
package com.payments.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.payments.dto.PaymentRequest;
import com.payments.dto.PaymentResponse;
import com.payments.gateway.PaymentGatewayClient;
import com.payments.model.IdempotencyKey;
import com.payments.model.Payment;
import com.payments.model.PaymentStatus;
import com.payments.repository.IdempotencyKeyRepository;
import com.payments.repository.PaymentRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import java.util.UUID;
@Service
public class PaymentService {
private static final Logger log = LoggerFactory.getLogger(PaymentService.class);
private final PaymentRepository paymentRepository;
private final IdempotencyKeyRepository idempotencyKeyRepository;
private final PaymentGatewayClient gatewayClient;
private final ObjectMapper objectMapper;
public PaymentService(PaymentRepository paymentRepository,
IdempotencyKeyRepository idempotencyKeyRepository,
PaymentGatewayClient gatewayClient,
ObjectMapper objectMapper) {
this.paymentRepository = paymentRepository;
this.idempotencyKeyRepository = idempotencyKeyRepository;
this.gatewayClient = gatewayClient;
this.objectMapper = objectMapper;
}
@Transactional
public PaymentResult initiatePayment(PaymentRequest request, String idempotencyKey) {
Optional<IdempotencyKey> existingKey = idempotencyKeyRepository.findByKeyHash(idempotencyKey);
if (existingKey.isPresent()) {
log.info("Idempotency key found: {}. Returning cached response.", idempotencyKey);
return new PaymentResult(existingKey.get().getResponseBody(), existingKey.get().getHttpStatus(), true);
}
Payment payment = new Payment();
payment.setAmount(request.amount());
payment.setCurrency(request.currency());
payment.setStatus(PaymentStatus.INITIATED);
payment.setMerchantId(request.merchantId());
payment.setDescription(request.description());
payment = paymentRepository.save(payment);
payment.setStatus(PaymentStatus.PROCESSING);
payment = paymentRepository.save(payment);
PaymentGatewayClient.GatewayResponse gatewayResponse =
gatewayClient.processPayment(payment.getId(), request.amount().longValue());
if (gatewayResponse.success()) {
payment.setStatus(PaymentStatus.COMPLETED);
payment.setGatewayTransactionId(gatewayResponse.transactionId());
} else {
payment.setStatus(PaymentStatus.FAILED);
payment.setFailureReason(gatewayResponse.errorMessage());
}
payment = paymentRepository.save(payment);
PaymentResponse response = toResponse(payment);
int httpStatus = payment.getStatus() == PaymentStatus.COMPLETED ? 201 : 200;
String responseJson = serialize(response);
IdempotencyKey newKey = new IdempotencyKey();
newKey.setKeyHash(idempotencyKey);
newKey.setPaymentId(payment.getId());
newKey.setResponseBody(responseJson);
newKey.setHttpStatus(httpStatus);
idempotencyKeyRepository.save(newKey);
return new PaymentResult(responseJson, httpStatus, false);
}
public Optional<PaymentResponse> getPayment(UUID id) {
return paymentRepository.findById(id).map(this::toResponse);
}
private PaymentResponse toResponse(Payment payment) {
return new PaymentResponse(
payment.getId(),
payment.getAmount(),
payment.getCurrency(),
payment.getStatus(),
payment.getMerchantId(),
payment.getDescription(),
payment.getGatewayTransactionId(),
payment.getFailureReason(),
payment.getCreatedAt(),
payment.getUpdatedAt()
);
}
private String serialize(PaymentResponse response) {
try {
return objectMapper.writeValueAsString(response);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize response", e);
}
}
public record PaymentResult(String responseBody, int httpStatus, boolean cached) {}
}
And the full PaymentController.java:
package com.payments.controller;
import com.payments.dto.PaymentRequest;
import com.payments.dto.PaymentResponse;
import com.payments.service.PaymentService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/payments")
public class PaymentController {
private final PaymentService paymentService;
public PaymentController(PaymentService paymentService) {
this.paymentService = paymentService;
}
@PostMapping
public ResponseEntity<String> createPayment(
@Valid @RequestBody PaymentRequest request,
@RequestHeader("Idempotency-Key") String idempotencyKey) {
PaymentService.PaymentResult result = paymentService.initiatePayment(request, idempotencyKey);
return ResponseEntity
.status(result.httpStatus())
.header("Content-Type", "application/json")
.header("X-Idempotent-Replay", String.valueOf(result.cached()))
.body(result.responseBody());
}
@GetMapping("/{id}")
public ResponseEntity<PaymentResponse> getPayment(@PathVariable UUID id) {
return paymentService.getPayment(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}
Test the idempotent behavior
The real proof that idempotency works is sending the exact same request twice and confirming only one payment is created. The second request should return the cached response without hitting the payment gateway again.
- Open a new terminal window and send your first payment request by running:
curl -X POST http://localhost:8080/api/v1/payments \
-H "Content-Type: application/json" \
-H "Idempotency-Key: test-key-001" \
-d '{"amount":1000,"currency":"INR","merchantId":"merchant-123","description":"Test payment"}'
What should I see?
You should see a JSON response containing a payment id (a UUID), the status COMPLETED, and the amount 1000. Note down the id value from the response.
- Send the exact same request again (same Idempotency-Key header) by running:
curl -X POST http://localhost:8080/api/v1/payments \
-H "Content-Type: application/json" \
-H "Idempotency-Key: test-key-001" \
-d '{"amount":1000,"currency":"INR","merchantId":"merchant-123","description":"Test payment"}'
- Compare the id in both responses. They should be identical, proving that the second request returned the cached response.
- Verify that only one payment exists in the database by running:
docker exec -it payments-db psql -U payments -d payments -c "SELECT id, amount, status FROM payments;"
- You should see exactly one row with status set to COMPLETED and amount set to 1000.
Why is this important in production?
Imagine a customer's app freezes after clicking "Pay". Their phone retries the request automatically. Without idempotency, the customer gets charged twice. With your implementation, the second request returns the original result and no duplicate payment is created.
The X-Idempotent-Replay: true header in the second response tells the client explicitly that this was a cached replay, not a new transaction.
Getting a connection refused error?
- Make sure your Spring Boot app is still running in the other terminal window.
- Confirm the PostgreSQL container is running with docker ps. You should see the payments-db container listed.
- If you see a 400 error, make sure you included the Idempotency-Key header in your curl command.
Help me troubleshoot my payment API curl request.
Your payment API now guarantees that a customer is never charged twice. Next up, you'll add a circuit breaker to handle what happens when the payment gateway goes down.
Add a Circuit Breaker for the Payment Gateway
Your payment service handles idempotency perfectly. But there's another critical failure mode in production payment systems: what happens when the external payment gateway (Stripe, Razorpay) goes down?
Without protection, your service would keep sending requests to a dead gateway, queuing up threads, and eventually crashing itself. A circuit breaker detects repeated failures and short-circuits future calls, returning a fast fallback response instead of waiting for inevitable timeouts.
In this step, get ready to:
- Create a simulated payment gateway client with realistic failure behavior.
- Configure the Resilience4j circuit breaker to open after repeated failures.
- Test the circuit breaker by triggering failures and observing the fallback.
Create the simulated payment gateway client
In a production system, this class would call a real payment gateway API like Stripe or Razorpay over HTTP. For this project, you'll simulate that external call with random latency and a 30% failure rate to realistically trigger the circuit breaker.
- In the VS Code Explorer sidebar, right-click the src/main/java/com/payments folder.
- Select New Folder and name it gateway.
- Right-click the new gateway folder and select New File.
- Name the file PaymentGatewayClient.java.
- Copy the following code into the file:
package com.payments.gateway;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
@Component
public class PaymentGatewayClient {
private static final Logger log = LoggerFactory.getLogger(PaymentGatewayClient.class);
@CircuitBreaker(name = "paymentGateway", fallbackMethod = "gatewayFallback")
public GatewayResponse processPayment(UUID paymentId, long amountInPaise) {
log.info("Calling external payment gateway for payment: {}", paymentId);
// Simulate network latency
try {
Thread.sleep(ThreadLocalRandom.current().nextInt(100, 500));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// Simulate 30% failure rate
if (ThreadLocalRandom.current().nextInt(100) < 30) {
throw new RuntimeException("Gateway timeout: payment gateway did not respond");
}
String gatewayTxnId = "gw_" + UUID.randomUUID().toString().substring(0, 12);
log.info("Gateway returned success for payment: {}, txnId: {}", paymentId, gatewayTxnId);
return new GatewayResponse(true, gatewayTxnId, null);
}
private GatewayResponse gatewayFallback(UUID paymentId, long amountInPaise, Throwable t) {
log.warn("Circuit breaker fallback triggered for payment: {}. Reason: {}", paymentId, t.getMessage());
return new GatewayResponse(false, null, "Gateway unavailable: " + t.getMessage());
}
public record GatewayResponse(boolean success, String transactionId, String errorMessage) {}
}
What does this code do?
- @CircuitBreaker(name = "paymentGateway", fallbackMethod = "gatewayFallback") tells Resilience4j to monitor this method for failures. When failures exceed the threshold, the circuit "opens" and routes calls directly to the fallback method.
- The processPayment method simulates 100-500ms of network latency and a 30% random failure rate. This mimics real payment gateway behavior under load.
- The gatewayFallback method must match the original method's parameters, plus a Throwable argument. It returns a graceful failure response instead of throwing an exception.
- The GatewayResponse record uses Java 21's record syntax for a concise, immutable data carrier with success, transactionId, and errorMessage fields.
Why a circuit breaker instead of simple retries?
Retries help with transient blips, but when a gateway is truly down, retries pile up and exhaust your thread pool. A circuit breaker recognizes systemic failure and stops sending traffic entirely, giving the downstream service time to recover.
This is the same pattern Stripe and Razorpay recommend for integrators. Your service stays responsive even when the gateway is offline.
- Save PaymentGatewayClient.java.
Seeing a compile error?
- Check that the file is in the gateway subfolder. The package declaration com.payments.gateway must match the directory structure.
- Make sure you have resilience4j-spring-boot3 and spring-boot-starter-aop in your pom.xml dependencies. The AOP starter is required for annotation-based circuit breakers.
Need more help? Why is my PaymentGatewayClient not compiling in Spring Boot?
Verify the circuit breaker configuration
The circuit breaker configuration is already in your application.yml from when you set up the project. Let's walk through what each setting does.
- Open src/main/resources/application.yml and scroll to the resilience4j section at the bottom.
- Confirm you see the following configuration:
resilience4j:
circuitbreaker:
instances:
paymentGateway:
sliding-window-size: 10
failure-rate-threshold: 50
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
register-health-indicator: true
What do these settings mean?
- sliding-window-size: 10 means the circuit breaker evaluates the last 10 calls to decide whether to open.
- failure-rate-threshold: 50 means if 50% or more of those 10 calls fail, the circuit opens.
- wait-duration-in-open-state: 10s means the circuit stays open (blocking all calls) for 10 seconds before transitioning to half-open.
- permitted-number-of-calls-in-half-open-state: 3 means 3 test calls are allowed through in half-open state to see if the gateway has recovered.
- register-health-indicator: true exposes the circuit breaker state to Spring Boot Actuator so you can monitor it.
- Also confirm the management.endpoints.web.exposure.include line includes circuitbreakers so you can query the state via the Actuator API.
Test the circuit breaker behavior
Now you'll fire multiple payment requests to trigger the circuit breaker. Because the gateway has a 30% simulated failure rate, the circuit should open after enough failures accumulate.
- Restart your application by running this in the terminal:
./mvnw spring-boot:run
- Once the app starts, fire 15 payment requests in rapid succession by running this loop in a new terminal:
for i in $(seq 1 15); do
curl -s -X POST http://localhost:8080/api/v1/payments \
-H "Content-Type: application/json" \
-H "Idempotency-Key: circuit-test-$i" \
-d '{"amount":500,"currency":"INR","merchantId":"merchant-123","description":"Circuit breaker test"}' | python3 -m json.tool
echo "---"
done
What should I see?
You should see a mix of successful payments (status COMPLETED) and failed payments (status FAILED with failureReason containing "Gateway unavailable").
Once enough failures accumulate (5 out of 10 in the sliding window), subsequent requests immediately get the fallback response without any simulated latency. That's the circuit in OPEN state.
- Check the circuit breaker state by running:
curl http://localhost:8080/actuator/circuitbreakers | python3 -m json.tool
You should see the paymentGateway circuit breaker listed with its current state (CLOSED, OPEN, or HALF_OPEN depending on timing).
All requests succeeding with no failures?
- Make sure you restarted the application after saving PaymentGatewayClient.java. Stop the running process with Ctrl+C and run ./mvnw spring-boot:run again.
- Verify the @CircuitBreaker annotation name matches the YAML config exactly: paymentGateway (case-sensitive).
- The 30% failure rate is random, so you may need to run more requests to hit the threshold. Try running the loop again.
Still stuck? Help me debug why my Resilience4j circuit breaker is not triggering in Spring Boot.
✔️ Awesome, I've got everything!
Great. Double check you've saved PaymentGatewayClient.java and your application is running successfully.
ⓧ I'd like to double check the full code
Here's the complete PaymentGatewayClient.java file:
package com.payments.gateway;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
@Component
public class PaymentGatewayClient {
private static final Logger log = LoggerFactory.getLogger(PaymentGatewayClient.class);
@CircuitBreaker(name = "paymentGateway", fallbackMethod = "gatewayFallback")
public GatewayResponse processPayment(UUID paymentId, long amountInPaise) {
log.info("Calling external payment gateway for payment: {}", paymentId);
// Simulate network latency
try {
Thread.sleep(ThreadLocalRandom.current().nextInt(100, 500));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// Simulate 30% failure rate
if (ThreadLocalRandom.current().nextInt(100) < 30) {
throw new RuntimeException("Gateway timeout: payment gateway did not respond");
}
String gatewayTxnId = "gw_" + UUID.randomUUID().toString().substring(0, 12);
log.info("Gateway returned success for payment: {}, txnId: {}", paymentId, gatewayTxnId);
return new GatewayResponse(true, gatewayTxnId, null);
}
private GatewayResponse gatewayFallback(UUID paymentId, long amountInPaise, Throwable t) {
log.warn("Circuit breaker fallback triggered for payment: {}. Reason: {}", paymentId, t.getMessage());
return new GatewayResponse(false, null, "Gateway unavailable: " + t.getMessage());
}
public record GatewayResponse(boolean success, String transactionId, String errorMessage) {}
}
And here's the full application.yml for reference:
spring:
application:
name: payment-service
threads:
virtual:
enabled: true
datasource:
url: jdbc:postgresql://localhost:5432/payments
username: payments
password: payments123
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,info,circuitbreakers,ratelimiters
endpoint:
health:
show-details: always
resilience4j:
circuitbreaker:
instances:
paymentGateway:
sliding-window-size: 10
failure-rate-threshold: 50
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
register-health-indicator: true
Your service now handles gateway failures gracefully. When the external payment provider goes down, your circuit breaker trips and returns fast fallback responses instead of timing out. Next up, you'll containerize the entire stack with Docker Compose so everything runs in a single reproducible environment.
Containerize with Docker Compose
Your payment service handles failures gracefully with circuit breakers. But right now, running it requires starting PostgreSQL manually and configuring connection details every time.
In this step, you will containerize the entire stack with Docker Compose. This means packaging your Spring Boot app into a Docker image and defining both services (app + database) in a single file that anyone can spin up with one command.
In this step, get ready to:
- Create a multi-stage Dockerfile that builds and runs the payment service.
- Create a Docker Compose file that orchestrates both the app and PostgreSQL.
- Build and run the full stack as a containerized unit.
Create the Dockerfile
A Dockerfile is a recipe that tells Docker how to build an image of your application. You will use a multi-stage build, which separates the compilation step from the final runtime image.
- In VS Code, create a new file called Dockerfile in the root of your payment-service folder (the same level as pom.xml).
- Paste the following code into Dockerfile:
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY pom.xml .
COPY .mvn .mvn
COPY mvnw .
RUN chmod +x mvnw
RUN ./mvnw dependency:go-offline -B
COPY src src
RUN ./mvnw package -DskipTests -B
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
What does this Dockerfile do?
This is a two-stage build. The first stage (build) uses the full JDK and Maven to compile your application into a JAR file. The second stage copies only that JAR into a slim JRE image.
The dependency:go-offline step downloads all Maven dependencies before copying your source code. This exploits Docker layer caching so rebuilds are faster when only your code changes.
The final image contains just the JRE and your JAR. No compiler, no Maven, no source code. A single-stage JDK image would be around 700 MB. This multi-stage approach produces an image around 200 MB.
- Save Dockerfile.
- Confirm the file exists by running:
ls Dockerfile
You should see Dockerfile in the output.
Don't see the file?
Make sure you created the file in the project root (payment-service/), not inside src/ or another subfolder. The file must be named exactly Dockerfile with a capital D and no file extension.
Help me troubleshoot my Dockerfile location
Create docker-compose.yml
Docker Compose lets you define multiple containers that work together as a single application. Your compose file will declare both the PostgreSQL database and your payment service, along with how they connect.
- Create a new file called docker-compose.yml in the root of your payment-service folder.
- Paste the following code into docker-compose.yml:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: payments
POSTGRES_USER: payments
POSTGRES_PASSWORD: payments123
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U payments -d payments"]
interval: 5s
timeout: 5s
retries: 5
payment-service:
build: .
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/payments
SPRING_DATASOURCE_USERNAME: payments
SPRING_DATASOURCE_PASSWORD: payments123
depends_on:
postgres:
condition: service_healthy
volumes:
pgdata:
What does this compose file do?
The postgres service uses the same postgres:16-alpine image you ran standalone in Step 1. The healthcheck block uses pg_isready to verify the database is accepting connections before declaring itself healthy.
The payment-service uses build: . to build the image from your Dockerfile. The depends_on with condition: service_healthy ensures the app only starts after PostgreSQL is ready.
Notice the datasource URL uses postgres as the hostname instead of localhost. Inside Docker Compose, each service name becomes a DNS hostname that other services can resolve.
Why Docker Compose instead of manual containers?
You could run each container with separate docker run commands. But that means managing start order, network connections, and environment variables yourself. Compose encodes all of that into a single declarative file.
Any developer who clones your repository can run the entire stack with one command. No setup instructions, no manual container linking.
- Save docker-compose.yml.
- Confirm the file exists by running:
ls docker-compose.yml
You should see docker-compose.yml in the output.
File not showing up?
Verify the file is in the same directory as your Dockerfile and pom.xml. Run ls to see all files in your current directory.
Help me find my docker-compose.yml file
Build and run the full stack
Before starting Docker Compose, you need to stop the standalone PostgreSQL container from Step 1. Otherwise it will conflict on port 5432.
- First, stop the Spring Boot app if it is still running. Press Ctrl+C in the terminal where ./mvnw spring-boot:run is active.
- Stop and remove the standalone PostgreSQL container by running:
docker stop payments-db && docker rm payments-db
What does this command do?
The docker stop command gracefully shuts down the running container. The docker rm command removes it entirely so Docker Compose can create a fresh PostgreSQL instance on the same port.
- Build and start the full containerized stack by running:
docker compose up --build
What should I see?
Docker will first build your payment service image (this takes a few minutes on the first run as it downloads dependencies). Then it starts both containers. You will see interleaved logs from postgres and payment-service in the terminal.
Look for a line containing Started PaymentServiceApplication in the logs. This confirms your app booted successfully inside the container.
- Open a new terminal tab or window. Verify the health endpoint responds by running:
curl http://localhost:8080/actuator/health
You should see {"status":"UP"} with database details showing the PostgreSQL connection is active.
- Test the full payment flow against the containerized stack by running:
curl -X POST http://localhost:8080/api/v1/payments \
-H "Content-Type: application/json" \
-H "Idempotency-Key: docker-test-001" \
-d '{"amount":1000,"currency":"INR","merchantId":"merchant-123","description":"Docker test payment"}'
You should see a JSON response containing a payment ID and a status of either COMPLETED or FAILED (the simulated gateway has a 30% failure rate, so both are valid).
Seeing 'connection refused' on port 8080?
The payment service waits for PostgreSQL to pass its health check before starting. This can take 15-30 seconds after you run docker compose up. Wait for the Started PaymentServiceApplication log line before testing.
If the app never starts, check the logs for a Connection refused error pointing at the database. Make sure you stopped the old payments-db container so port 5432 is free for the Compose-managed PostgreSQL.
Help me troubleshoot my Docker Compose setup
✔️ Awesome, I've got everything!
Great. Double check you have saved both Dockerfile and docker-compose.yml and that your containerized stack is running.
ⓧ I'd like to double check the full code
Here is the complete Dockerfile:
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY pom.xml .
COPY .mvn .mvn
COPY mvnw .
RUN chmod +x mvnw
RUN ./mvnw dependency:go-offline -B
COPY src src
RUN ./mvnw package -DskipTests -B
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Here is the complete docker-compose.yml:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: payments
POSTGRES_USER: payments
POSTGRES_PASSWORD: payments123
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U payments -d payments"]
interval: 5s
timeout: 5s
retries: 5
payment-service:
build: .
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/payments
SPRING_DATASOURCE_USERNAME: payments
SPRING_DATASOURCE_PASSWORD: payments123
depends_on:
postgres:
condition: service_healthy
volumes:
pgdata:
Secret mission
Add Rate Limiting
Production payment APIs face a constant threat: too many requests hitting the endpoint at once, whether from a buggy client or a malicious actor. Your mission is to add Resilience4j rate limiting that caps requests at 10 per second and returns HTTP 429 when limits are exceeded.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally via 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:
- Docker containers: payment-service and postgres (running via Docker Compose).
- Docker images: payment-service and postgres:16-alpine.
- PostgreSQL data volume: pgdata (persists payment data between restarts).
- Local project folder: payment-service on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the payment service.
- Your Docker Compose stack continues running in the background, using approximately 500MB of RAM.
- Docker Desktop remains installed on your machine for future projects.
- The pgdata volume retains all payment records you created during testing.
Pause - I'll come back to this later
Stop the running containers to free up memory without losing any data. Your containers and volumes remain on disk, ready to resume.
- Stop the containers by running:
docker compose stop
- When you are ready to resume, start the containers again by running:
docker compose start
- Your database data and Docker images remain intact on disk.
Delete - I don't want to use this again
Remove all project containers, volumes, images, and local files to reclaim disk space entirely.
- Stop and remove the containers, network, and database volume by running:
docker compose down -v
- Remove the built Docker image by running:
docker rmi payment-service
Image name may differ
Docker Compose names built images as <directory>-<service>. If the command above returns an error, run docker images to find the exact image name, then use that name with docker rmi.
- Delete the local project folder. On macOS or Linux, run:
rm -rf ~/Desktop/payment-service
- On Windows, run:
Remove-Item -Recurse -Force ~\Desktop\payment-service
- Confirm the folder is gone by running:
ls ~/Desktop/payment-service
You should see a "No such file or directory" message confirming the folder has been removed.
That's a Wrap!
That's a Wrap!
Nice work. You just built a production-grade Payment Processing microservice that handles real-world failure scenarios the same way companies like Stripe and Razorpay do.
You've learned how to:
- Build a Payment Processing microservice with Spring Boot 3.5.15 and Java 21 virtual threads that implements the idempotency key pattern to prevent duplicate charges.
- Add a Resilience4j circuit breaker that protects against external payment gateway failures with automatic fallback responses.
- Containerize the entire stack with Docker Compose for consistent, reproducible deployments using a multi-stage Dockerfile and health checks.
- Secret Mission: Add rate limiting to protect the payment API from abuse, returning HTTP 429 when request limits are exceeded.
Ready to quiz yourself?