A distributed, event-driven payment processing system built on the Transactional Outbox Pattern. Guarantees exactly-once event delivery and idempotent consumer processing using Kafka (Redpanda), PostgreSQL, and Spring Boot 3.
┌─────────────────────────────────────────────────────┐
│ payment-service │
Client ──── REST ──────► │ PaymentController → PaymentService │
│ ↓ (same @Transactional) │
│ payments table + outbox_events table (PENDING) │
└──────────────────────────┬──────────────────────────┘
│ polls every 500ms
┌──────────────────────────▼──────────────────────────┐
│ outbox-relay │
│ OutboxPoller ──► OutboxPublisher │
│ FOR UPDATE SKIP LOCKED (batch 100) │
│ marks PUBLISHED / FAILED (→ DLT after 3×) │
└──────────────────────────┬──────────────────────────┘
│ Avro over Kafka
┌───────────▼────────────┐
│ payments.events │
│ (Redpanda topic) │
└─────┬─────┬──────┬─────┘
│ │ │
┌────────────────────▼─┐ ┌─▼──┐ ┌▼──────────────────┐
│ ledger-consumers │ │ │ │notification- │
│ fraud-consumers │ │DLT │ │consumers │
│ (idempotent) │ │ │ │(idempotent) │
└───────────────────────┘ └────┘ └────────────────────┘
┌──────────┐
POST ──► │ INITIATED│
└────┬─────┘
PATCH /authorize │
┌────▼──────┐
│AUTHORIZED │
└────┬──────┘
PATCH /settle │
┌────▼───────────────┐
│ SETTLEMENT_PENDING │
└────┬───────────────┘
│
┌────▼──────┐
│ SETTLED │ ← terminal
└───────────┘
PATCH /fail ──► FAILED (from any non-terminal state)
Transitions are enforced at the domain layer via PaymentStatus.canTransitionTo(). Any illegal transition throws IllegalStateException.
| Module | Port | Role |
|---|---|---|
shared |
— | Avro schema, Kafka topic constants, PaymentStatus enum |
payment-service |
8080 | REST API, domain logic, outbox writes |
outbox-relay |
8085 | Polls outbox table, publishes to Kafka |
consumer-stubs |
8083 | Ledger, Fraud, Notification Kafka consumers |
Payment state change and outbox event write happen in one atomic transaction. The relay service polls and publishes independently, ensuring events are never lost even on service crash.
BEGIN TRANSACTION
UPDATE payments SET status = 'AUTHORIZED'
INSERT INTO outbox_events (status = 'PENDING') -- always both or neither
COMMIT
The outbox poller acquires row-level locks on pending events and skips rows locked by other relay instances. Safe for horizontal scaling of the relay.
Each consumer group writes (eventId, consumerGroup) to processed_events before processing completes. Duplicate deliveries are silently skipped.
Events that fail publishing 3 times are routed to payments.events.DLT. Consumers also have per-topic retry queues (suffix -0, -1) with a final DLT.
┌──────────────────────────────────────────────────────┐
│ docker-compose │
│ │
│ PostgreSQL 16 ← shared by all three services │
│ Redpanda ← Kafka-compatible broker │
│ Schema Registry ← Avro schema management │
│ Prometheus ← scrapes /actuator/prometheus │
│ Grafana ← dashboards (admin/admin) │
└──────────────────────────────────────────────────────┘
| Service | Port |
|---|---|
| PostgreSQL | 5432 |
| Kafka (Redpanda) | 9092 |
| Schema Registry | 8081 |
| Prometheus | 9090 |
| Grafana | 3000 |
payments
├── id UUID PK
├── idempotency_key VARCHAR UNIQUE ← prevents duplicate payments
├── merchant_id UUID
├── amount NUMERIC(19,4)
├── currency CHAR(3)
├── status VARCHAR ← enforced by CHECK constraint
├── failure_reason TEXT
└── version BIGINT ← optimistic locking (@Version)
outbox_events
├── id UUID PK
├── payment_id UUID FK → payments.id
├── event_type VARCHAR ← e.g. PAYMENT_AUTHORIZED
├── payload JSONB
├── status VARCHAR ← PENDING | PUBLISHED | FAILED
└── retry_count INT
processed_events (consumer idempotency)
├── event_id UUID ┐ composite PK
└── consumer_group VARCHAR ┘
PaymentEvent {
eventId: string
paymentId: string
merchantId: string
eventType: string // PAYMENT_INITIATED, PAYMENT_AUTHORIZED, ...
status: string
amount: string // string preserves NUMERIC precision
currency: string
occurredAt: long (timestamp-millis)
failureReason: string | null
}Kafka topic: payments.events | DLT: payments.events.DLT
- Java 21
- Maven 3.6+
- Docker + Docker Compose
docker compose up -dmvn clean install -DskipTests# 1. Payment Service
java -jar payment-service/target/payment-service-*-exec.jar
# 2. Outbox Relay
java -jar outbox-relay/target/outbox-relay-*-exec.jar
# 3. Consumer Stubs
java -jar consumer-stubs/target/consumer-stubs-*-exec.jarThe test suite is self-contained — no running Docker Compose infrastructure required. All containers (PostgreSQL, Redpanda) are managed by Testcontainers and started automatically.
mvn testmvn test -pl payment-service # 10 tests — domain + service layer
mvn test -pl outbox-relay # 6 tests — outbox polling and Kafka publish
mvn test -pl consumer-stubs # 4 tests — consumer idempotencyEach module has a shared abstract base class that owns the Testcontainers lifecycle:
| Module | Base Class | Containers |
|---|---|---|
payment-service |
AbstractIntegrationTest |
PostgreSQL 16 |
outbox-relay |
AbstractRelayIntegrationTest |
PostgreSQL 16, Redpanda |
consumer-stubs |
AbstractConsumerIntegrationTest |
PostgreSQL 16, Redpanda |
Redpanda's built-in Schema Registry is used instead of a separate cp-schema-registry container — simpler setup, no Docker networking complexity.
| Test Class | Tests |
|---|---|
PaymentStateMachineTest |
Pure unit — all valid/invalid transitions, terminal states (@ParameterizedTest) |
PaymentServiceIntegrationTest |
Atomic outbox write, idempotency key, per-transition events, illegal transition rejection, failPayment, optimistic locking with CountDownLatch |
@EnableSchedulingis disabled in the test context (TestOutboxRelayApplication). Tests callOutboxPoller.poll()directly for full determinism — no timing races.
| Test | What it proves |
|---|---|
poll_shouldPublishPendingEventsToKafka |
Happy path: PENDING → PUBLISHED, message on topic, partition key = paymentId |
poll_shouldMarkEventPublished_onlyAfterKafkaAck |
.get() semantics: status only flips after broker ACK |
poll_withKafkaFailure_shouldIncrementRetryCountAndStayPending |
@SpyBean forces publish failure; retry_count increments, status stays PENDING |
poll_afterMaxRetries_shouldRouteToDeadLetterTopic |
event at retry_count=2 → one more failure → FAILED + message on DLT |
poll_skipLocked_concurrentPollers_shouldNotDuplicatePublish |
Two concurrent poll() calls on 10 events → exactly 10 Kafka messages, no duplicates |
relay_restartAfterCrash_shouldResumeFromUnpublishedEvents |
Partial batch publish, spy reset ("restart"), second poll completes all 5 events |
| Test | What it proves |
|---|---|
ledgerConsumer_shouldProcessEventExactlyOnce |
Same eventId published twice → exactly one processed_events row |
allThreeConsumers_shouldProcessSameEventIndependently |
One event → three rows, one per consumer group |
consumer_onProcessingFailure_shouldRetryAndRouteToDLT |
@SpyBean forces consumer throws → @RetryableTopic exhausts retries → message on DLT |
consumer_afterRestart_shouldNotReprocessAlreadyProcessedEvents |
@DirtiesContext + offset reset → idempotency prevents double insertion |
@SpyBean OutboxPublishersimulates Kafka failures in relay tests without stopping the shared Redpanda container.Propagation.NOT_SUPPORTEDon the optimistic-locking test allows concurrent threads to hold independent DB transactions.- Awaitility is used for all async assertions —
Thread.sleep()is never used. @BeforeEachtable cleanup ensures test isolation without@Transactionalrollback, which would hide cross-thread visibility issues.
Run: ./load-test.sh
Sends 100 RPS for 30 seconds (3000 total requests).
Verifies: outbox_pending_count spikes and recovers to 0, all 3000 payments created with status INITIATED, no events lost. Results vary by machine — architecture supports horizontal scaling for higher throughput.
All endpoints on localhost:8080.
POST /api/v1/payments
Content-Type: application/json
{
"idempotencyKey": "order-abc-123",
"merchantId": "550e8400-e29b-41d4-a716-446655440000",
"amount": 99.99,
"currency": "USD"
}
Response 201 Created:
{
"id": "...",
"status": "INITIATED",
"amount": 99.99,
"currency": "USD",
"idempotencyKey": "order-abc-123",
"createdAt": "2026-03-27T10:00:00Z"
}PATCH /api/v1/payments/{id}/authorize
PATCH /api/v1/payments/{id}/settle
PATCH /api/v1/payments/{id}/fail?reason=insufficient_funds
Sending the same idempotencyKey twice returns the existing payment (no duplicate creation).
| URL | Purpose |
|---|---|
localhost:9090 |
Prometheus |
localhost:3000 |
Grafana (admin / admin) |
localhost:8080/actuator/health |
Payment service health |
localhost:8085/actuator/health |
Outbox relay health |
localhost:8083/actuator/health |
Consumer stubs health |
Key metric: outbox.pending.count — tracks unpublished event backlog. Spike indicates relay is behind or failing.
| Layer | Technology |
|---|---|
| Language | Java 21 (virtual threads via Project Loom) |
| Framework | Spring Boot 3.3.4 |
| Database | PostgreSQL 16 + Flyway migrations |
| Messaging | Redpanda (Kafka-compatible) + Confluent Schema Registry |
| Serialization | Apache Avro 1.11 |
| Metrics | Micrometer + Prometheus + Grafana |
| Testing | JUnit 5, Testcontainers 1.19.8, Awaitility, Mockito (@SpyBean) |
| Build | Maven (multi-module) |
- Debezium CDC: replace the
@Scheduledpoller with a Debezium connector reading PostgreSQL WAL — eliminates polling latency entirely - Schema evolution: add an optional
correlationIdfield toPaymentEvent.avscand verify backward compatibility with existing consumers - Horizontal relay scaling: run 3
outbox-relayinstances simultaneously and verifySKIP LOCKEDprevents duplicate publishing - Kafka Streams: add a topology that aggregates payment counts by merchant and status in real time