A concurrency-safe ledger and payment engine in Go with PostgreSQL, gRPC, Transactional Outbox, idempotent replay, and Kafka.
fintech-payment-engine demonstrates how to preserve monetary invariants under concurrency while propagating payment events through a Transactional Outbox. Domain rules are isolated from PostgreSQL, Kafka, and gRPC through explicit ports; the critical paths are covered by unit tests and real PostgreSQL integration tests.
- Concurrent Balance Safety - transfers lock both accounts in deterministic UUID order with
SELECT ... FOR UPDATE, then use optimistic version checks. A real PostgreSQL race test verifies that concurrent transfers never overspend and preserve the total balance. - Exact Money Domain - values use
decimal.Decimaland must be exactly representable as PostgreSQLNUMERIC(18,4). Sub-scale amounts, implicit rounding, precision overflow, and target-balance overflow are rejected before commit. - Idempotent Transfer Replay - an identical request returns the original transaction; the same key with a different payload is rejected. Concurrent duplicate requests commit one ledger record and one Outbox event.
- Atomic Ledger & Outbox - balance mutations, the transaction record, and the pending Outbox event are committed in one PostgreSQL transaction, avoiding a database/Kafka dual write.
- At-Least-Once Event Delivery - workers claim pending events through
FOR UPDATE SKIP LOCKED, publish synchronously with Kafkaacks=all, and persist retry or terminal failure state. - Reproducible Delivery Checks - CI runs race-enabled unit and PostgreSQL integration tests, fixed-version linting, binary builds, and both production container builds. API and Worker images are static multi-stage builds running as UID/GID
10001.
flowchart LR
C["gRPC Client"] -->|"ProcessTransfer"| H["gRPC Payment Handler"]
H --> U["Transfer Use Case"]
subgraph PG["Single PostgreSQL Transaction"]
direction TB
A["Accounts<br/>SELECT FOR UPDATE<br/>Optimistic Versioning"]
L["Transaction Ledger"]
O["Outbox Events<br/>PENDING"]
end
U -->|"Atomic commit"| PG
PG -->|"FOR UPDATE SKIP LOCKED"| W["Background Outbox Worker"]
W -->|"Key: Aggregate ID<br/>acks=all"| K["Apache Kafka / Redpanda"]
classDef edge fill:#0f172a,color:#fff,stroke:#38bdf8,stroke-width:2px;
classDef store fill:#ecfeff,color:#164e63,stroke:#0891b2;
class C,H,U,W,K edge;
class A,L,O store;
- The use case validates account identities, currency, idempotency key, and exact
NUMERIC(18,4)representation. - An exact idempotent replay returns the original transaction; a changed payload with the same key is rejected.
- Both account UUIDs are sorted before row locking, so concurrent transfers acquire locks in one order.
- Balance updates use
WHERE id = $1 AND version = $2; PostgreSQL increments the version atomically. - The ledger record and
payment.transferredOutbox event are inserted before commit. - The Worker publishes with at-least-once semantics, so consumers must deduplicate by event or transaction identity.
| Area | Technology | Role |
|---|---|---|
| Language | Go 1.24+ | Type-safe, concurrent API and background processing |
| Transport | gRPC + Protocol Buffers | Versioned, strongly typed payment API |
| Database | PostgreSQL 16 + pgx/v5 |
ACID ledger, account locks, optimistic versioning, Outbox |
| Money | shopspring/decimal |
Exact base-10 monetary arithmetic |
| Messaging | Apache Kafka API via Redpanda + kafka-go |
Durable event distribution with acks=all |
| Reliability | Transactional Outbox | Atomic database writes and reliable event propagation |
| Concurrency | FOR UPDATE, SKIP LOCKED |
Double-spend prevention and horizontally scalable workers |
| Containers | Docker, Alpine Linux | Static multi-stage builds running as non-root |
| Quality | Go race detector, real PostgreSQL tests, golangci-lint, GitHub Actions | Concurrency, replay, money-domain, lint, and build gates |
- Go 1.24 or newer
- Docker Engine with Docker Compose
- GNU Make
make upThis starts PostgreSQL 16, Redpanda, and Redpanda Console, then creates the configured Kafka topic (payment-events by default). PostgreSQL executes only the mounted up migrations when its data volume is initialized.
Use this command for an existing PostgreSQL volume or after adding a new migration:
make migrateThe idempotent seed migration creates deterministic USD and EUR accounts for Alice and Bob without resetting existing balances.
Open two terminals from the repository root:
go run ./cmd/apiKAFKA_BROKERS=localhost:19092 go run ./cmd/workerThe API listens on :50051 by default. Configuration can be overridden with POSTGRES_URL, KAFKA_BROKERS, KAFKA_TOPIC, GRPC_PORT, OUTBOX_BATCH_SIZE, OUTBOX_POLL_INTERVAL, and OUTBOX_MAX_RETRIES.
On Windows PowerShell, use
$env:KAFKA_BROKERS="localhost:19092"; go run ./cmd/worker.
make demomake test-race
make test-integrationmake test-integration uses the PostgreSQL started by make up, creates an isolated temporary schema, and verifies concurrent balance conservation, exact idempotent replay, and the NUMERIC(18,4) money boundary.
The demo connects to localhost:50051, reads the seeded USD balances, and launches concurrent transfers in both directions between Alice and Bob.
Alice USD account: aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaa0001
Bob USD account: bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbb0001
Initial total: 15,000.00 USD
After all RPCs finish, it reads both balances again and verifies the core ledger invariant:
Alice(before) + Bob(before) == Alice(after) + Bob(after)
The command exits with a non-zero status if any transfer fails or if money is created or destroyed. Set GRPC_ADDRESS to run it against a different endpoint.
fintech-payment-engine/
|-- api/proto/payment/v1/ # Protobuf contract and generated gRPC code
|-- cmd/
| |-- api/ # gRPC API composition root
| |-- worker/ # Outbox Worker composition root
| `-- demo/ # Concurrent transfer and invariant demo
|-- internal/
| |-- domain/ # Entities, statuses, errors, repository ports
| |-- usecase/ # Framework-independent transfer orchestration
| |-- delivery/grpc/ # Request validation and domain-to-gRPC mapping
| |-- repository/postgres/ # pgx repositories and transaction context adapter
| |-- integration/ # Real PostgreSQL concurrency and replay tests
| `-- worker/ # Transactional Outbox polling and retry policy
|-- pkg/
| |-- config/ # Environment-driven runtime configuration
| `-- kafka/ # Synchronous Kafka producer abstraction
|-- migrations/ # Schema and deterministic seed migrations
|-- deployments/
| |-- docker-compose.yml # PostgreSQL, Redpanda, and Redpanda Console
| |-- Dockerfile.api # Non-root API image
| `-- Dockerfile.worker # Non-root Worker image
|-- .github/workflows/ci.yml # Race tests, PostgreSQL integration, lint, builds
|-- Makefile # Local development commands
`-- go.mod # Go module and pinned dependencies
The Outbox Worker intentionally provides at-least-once, not fictional end-to-end exactly-once delivery. A Kafka publish can succeed while the subsequent PostgreSQL commit fails, causing the event to be sent again. Each message uses a stable aggregate key, and downstream consumers are expected to deduplicate by event or transaction identity.
Released under the MIT License.
