A production-grade payment gateway built in Go that handles card payment operations with robust state management, automatic failure recovery, and guaranteed idempotency. This gateway integrates with a mock banking API and implements enterprise-level patterns for handling distributed system failures.
Payment processing is deceptively hard. When you authorize a payment, your request might succeed at the bank but fail to save in your database. Or the bank might return a 500 error even though the authorization succeeded. This gateway solves these problems:
- State Consistency: Your database always reflects reality, even across crashes
- Automatic Recovery: Background workers detect and fix stuck payments
- Idempotency Guarantees: Retry-safe operations that never double-charge customers
- Failure Classification: Intelligent retry logic that knows when to give up
- Authorize: Reserve funds on a customer's card
- Capture: Charge previously authorized funds
- Void: Cancel authorization before capture
- Refund: Return money after capture
- Background workers detect payments stuck in intermediate states (
CAPTURING,VOIDING,REFUNDING) - Exponential backoff with jitter prevents API overload
- Smart error classification: transient errors are retried, permanent errors fail fast
- Database-level idempotency enforcement using unique constraints
- Request hash validation prevents key reuse with different parameters
- Concurrent request handling with lock-based coordination
PENDING β AUTHORIZED β CAPTURED β REFUNDED
β
VOIDED
Invalid transitions (e.g., voiding after capture) are rejected at the domain level.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β FicMart (Client) β
βββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Payment Gateway (REST API) β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β HTTP Handlers Layer β β
β ββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββ β
β β β
β ββββββββββββββββββββββββΌβββββββββββββββββββββββββββββ β
β β Application Services Layer β β
β β β’ AuthorizeService β’ CaptureService β β
β β β’ VoidService β’ RefundService β β
β ββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββ β
β β β
β ββββββββββββββββββββββββΌβββββββββββββββββββββββββββββ β
β β Domain Layer (Pure) β β
β β β’ Payment Entity β’ State Machine β β
β β β’ Business Rules β’ Domain Errors β β
β ββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββ β
β β β
β ββββββββββββββββββββββββΌβββββββββββββββββββββββββββββ β
β β Infrastructure Layer β β
β β β’ PostgreSQL Repos β’ Bank HTTP Client β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Background Workers β β
β β β’ RetryWorker (stuck payments) β β
β β β’ ExpirationWorker (expired auths) β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββ
β Mock Bank API β
ββββββββββββββββββββ
Key Design Decisions:
- Domain-Driven Design: Business logic lives in the domain layer, completely isolated from HTTP/database concerns
- Write-Ahead Pattern: Every payment is saved as
PENDINGbefore calling the bank, ensuring we have a record to reconcile - Intermediate States: States like
CAPTURINGsignal intent, allowing workers to resume operations after crashes
See TRADEOFFS.md for detailed rationale.
- Docker & Docker Compose: 20.10+
- Go: 1.25+ (for local development)
- Make: Optional (macOS/Linux)
The gateway requires the mock bank to be running:
cd bank
make upVerify at: http://localhost:8787/docs
make upThe gateway will be available at: http://localhost:8081
# View API docs
open http://localhost:8081/docscurl -X POST http://localhost:8081/authorize \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"order_id": "order-12345",
"customer_id": "cust-67890",
"amount": 5000,
"card_number": "4111111111111111",
"cvv": "123",
"expiry_month": 12,
"expiry_year": 2030
}'Response:
{
"success": true,
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "AUTHORIZED",
"amount_cents": 5000,
"bank_auth_id": "auth-abc123",
"expires_at": "2024-01-22T10:30:01Z"
}
}curl -X POST http://localhost:8081/capture \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"payment_id": "550e8400-e29b-41d4-a716-446655440000",
"amount": 5000
}'# By payment ID
curl http://localhost:8081/payments/550e8400-e29b-41d4-a716-446655440000
# By order ID
curl http://localhost:8081/payments/order/order-12345
# By customer ID
curl http://localhost:8081/payments/customer/cust-67890?limit=10&offset=0| Card Number | CVV | Expiry | Balance | Use Case |
|---|---|---|---|---|
| 4111111111111111 | 123 | 12/2030 | $10,000 | Happy path |
| 4242424242424242 | 456 | 06/2030 | $500 | Limited balance |
| 5555555555554444 | 789 | 09/2030 | $0 | Insufficient funds |
| 5105105105105100 | 321 | 03/2020 | $5,000 | Expired card |
cd docker
docker compose up
# In another terminal, attach to the container
docker compose exec gateway sh
air # Hot reload on file changes# Unit tests
go test ./internal/... -v
# Integration tests (requires DB)
go test ./internal/application/services/... -v
# E2E tests (requires gateway + bank running)
RUN_E2E_TESTS=true go test ./internal/tests/e2e/... -vgo test ./internal/... -coverprofile=coverage.out
go tool cover -html=coverage.outMigrations run automatically on startup. Files are in internal/db/migrations/.
.
βββ cmd/gateway/ # Application entry point
βββ internal/
β βββ domain/ # Business logic & state machine (zero dependencies)
β βββ application/ # Service orchestration & error handling
β β βββ services/ # AuthorizeService, CaptureService, etc.
β βββ infrastructure/ # External integrations
β β βββ bank/ # Bank API client with retry logic
β β βββ persistence/ # PostgreSQL repositories
β βββ handlers/ # HTTP handlers & middleware
β βββ worker/ # Background retry & expiration workers
βββ internal/db/migrations/ # SQL migration files
βββ docker/ # Docker & docker-compose setup
βββ internal/tests/ # Integration & E2E tests
Configuration is loaded from environment variables with the GATEWAY_ prefix. Key settings:
# Server
GATEWAY_SERVER__PORT=8080
GATEWAY_SERVER__READ_TIMEOUT=15s
# Database
GATEWAY_DATABASE__HOST=localhost
GATEWAY_DATABASE__PORT=5432
GATEWAY_DATABASE__MAX_OPEN_CONNS=25
# Bank API
GATEWAY_BANK_CLIENT__BANK_BASE_URL=http://localhost:8787
GATEWAY_BANK_CLIENT__BANK_CONN_TIMEOUT=30s
# Retry Behavior
GATEWAY_RETRY__BASE_DELAY=1 # Initial delay in seconds
GATEWAY_RETRY__MAX_RETRIES=3 # Max retry attempts
GATEWAY_RETRY__MAX_BACKOFF=10 # Max backoff duration
# Workers
GATEWAY_WORKER__INTERVAL=30s # How often to check for stuck payments
GATEWAY_WORKER__BATCH_SIZE=100 # Max payments to process per cycleSee .env.example for the complete list.
- Gateway saves payment as
PENDING - Bank call fails with 500
- Payment stays
PENDING(no state change) - Not retried (authorization requires card details we don't store)
- Marked
FAILEDfor manual reconciliation
- Payment transitions to
CAPTURING - Bank responds with success
- Gateway crashes before updating DB
- Retry worker finds payment stuck in
CAPTURING - Retries with same idempotency key
- Bank returns cached success (idempotent!)
- Gateway updates payment to
CAPTURED
- Payment is in
VOIDING - Void call times out
- Worker schedules retry with exponential backoff: 1s β 2s β 4s
- Retry succeeds on second attempt
- Payment marked
VOIDED
This gateway prioritizes correctness over performance:
- β Never double-charge a customer
- β Always reconcile with the bank's state
- β Prefer database transactions over in-memory state
- β Fail loud (return errors) rather than fail silent
For a deep dive into architecture decisions, retry strategies, and production considerations, see TRADEOFFS.md.
# View logs
make logs
# Restart gateway
make restart
# Connect to database
docker compose exec payment-postgres psql -U postgres -d payment_gateway_db
# Run linter
cd docker && docker compose exec gateway golangci-lint run
# Generate mocks
mockery --config .mockery.yaml- No Partial Captures/Refunds: Must capture/refund the full authorized amount
- Single Currency: Only USD is supported
- No Card Tokenization: Card details are not stored (by design)
- Authorize Retry Limitation: Failed authorizations cannot be automatically retried (requires card details)
This is a portfolio project demonstrating payment gateway patterns. While it's not accepting external contributions, feedback via issues is welcome.
Part of the Backend Engineer Path assessment by benx421.