- What Is PayFlow
- Architecture
- Architecture Decisions
- Inter-Service Communication
- API Reference
- Quick Start
- Tests
An event-sourced, saga-orchestrated wallet and payments platform demonstrating production-grade fintech engineering patterns demonstrating idempotent APIs, double-entry ledger, outbox-based event publishing, CQRS, distributed tracing, and configurable resilience against a deliberately flaky funding source.
| Feature | Description |
|---|---|
| Wallet accounts | Per-user, single currency (GBP) |
| Top-up | Simulated external funding via fake card processor |
| Peer-to-peer transfer | Wallet to wallet |
| Merchant payment | Authorize (hold) then Capture or Release (saga) |
| Transaction history | CQRS read model / statement |
Infrastructure:
- PostgreSQL (:5432): one database per service. pgAdmin at :5050 (admin@payflow.dev / admin)
- RabbitMQ (:5672): message broker. Management UI at :15672 (guest / guest)
- Jaeger (:16686): distributed tracing via OTLP (:4317)
Context: Each service could be split into 4+ .csproj files (Domain, Application, Infrastructure, Api) to enforce dependency direction at the compiler level. That would result to as about 30 projects across 7 services.
Options:
- Strict Clean Architecture (4+ projects/service): Compiler-enforced isolation via separate assemblies. approx. 30 projects total. Slower builds, more ceremony.
- Vertical Slices: Feature-organized code with maximal cohesion. This can lead to infrastructure duplication.
- Feature-Folder Architecture (chosen): One project per service with layering enforced by folder convention and automated architecture tests.
Why: Production fintech services rarely split into 4 assemblies because the cost in build time and navigation outweighs the benefit. A single project with feature folders achieves the same architectural boundaries when paired with automated tests, while keeping developer iteration fast. The layering is preserved in the code (domain types don't reference EF), not in the build system.
Tradeoff: Requires discipline during code review. This can be managed or mitigated by architecture tests that fail CI if infrastructure leaks into domain.
Context: WalletService orchestrates top-up and transfer operations that must record double-entry pairs in LedgerService. The outbox pattern is required for atomic DB and event publishing.
Options:
- Synchronous HTTP: WalletService calls LedgerService REST API. Simple but couples service availability and skips the outbox pattern.
- MassTransit Request/Response: Sends a command and awaits a reply via RabbitMQ. This doesn't leverage outbox pattern, adds timeout complexity.
- MassTransit Fire-and-Forget and Outbox (chosen): Handler writes to an
outbox_messagestable in the same EF transaction as business data. A backgroundOutboxRelayServicepublishes pending messages to RabbitMQ. LedgerService consumes the command and publishes a result event.
Why: This decision checks three deliverables at once: async communication, the outbox pattern, and eventual consistency. The 202 Accepted and correlation ID pattern mirrors real fintech APIs. If LedgerService is down, the outbox holds messages until it recovers. Adding new consumers (StatementService, NotificationService) requires zero changes to the publisher.
Tradeoff: Clients receive 202 Accepted and must poll for completion. Not suitable for synchronous low-latency flows. Adds infrastructure complexity (outbox table, background relay, eventual consistency guarantees).
Context: NotificationService exists to demonstrate eventual consistency and fan-out from ledger events. It consumes LedgerEntryCreatedEvent and LedgerEntryFailedEvent and simulates email and push notifications.
Options:
- Store notifications in PostgreSQL: EF Core and repository pattern matching other services. Adds migrations and connection pool overhead for a service whose only job is logging simulated notifications.
- Pure fan-out consumer (chosen): No database. Consumers log structured messages. Notifications are observable via structured logs and OpenTelemetry traces.
Why: NotificationService is intentionally the simplest service which shows how event-driven architecture works (adding a new consumer requires zero changes to producers) without unnecessary infrastructure. The fan-out pattern is demonstrated by the fact that NotificationService subscribes to the same events as other consumers but operates independently. Real notification delivery would use SendGrid or Firebase, not a database.
Tradeoff: No persisted notification history. In production you would store notification status (sent, failed, retrying) in a database.
Context: The system has 7 services, each needing authentication, rate limiting, and idempotency enforcement. These concerns could be handled per-service or centralized.
Options:
- Per-service middleware: Each service independently implements API key auth, rate limiting, and idempotency checks. Duplicated code across 7 services, inconsistent behavior risk.
- ApiGateway as reverse proxy (chosen): YARP Reverse Proxy routes
/api/{service}/*to backend services. A single middleware pipeline (rate limiter, API key auth, idempotency-key check) runs before the proxy. Backend services are not exposed directly.
Why: Centralizing cross-cutting concerns eliminates duplication and ensures consistent enforcement. The gateway strips the service prefix before forwarding, so backend services are unaware of the routing layer and can be tested independently. The YARP config is driven by appsettings.json or env vars, making route changes a configuration operation not a code change.
Tradeoff: The gateway is a single point of entry and a potential bottleneck. In production,we might consider running multiple gateway instances behind a load balancer at scale. Latency is negligible because YARP adds approx. 1ms per request.
Context: PaymentService implements a merchant payment flow: authorize (hold funds), capture (complete payment), or release (compensating transaction). This is a saga.
Options:
- MassTransit saga state machine: Use
MassTransitStateMachinewithISagarepository. Provides built-in persistence, retry, and timeout handling. Adds a new persistence store and a steeper learning curve. - Application-level saga via outbox + consumers (chosen): Payment state is modeled as a domain enum (
PaymentStatus). API handlers write outbox messages to produce ledger commands. Event consumers advance the state.
Why: The saga has only 7 states and 4 transitions — not complex enough to justify a state machine framework. The outbox pattern already provides reliable message delivery. The application-level approach keeps the saga logic co-located with the domain code rather than in a separate saga definition file.
Tradeoff: No built-in timeout mechanism — a payment stuck in Authorized state would never auto-release. In production you would add a background job or use MassTransit's scheduled messages for timeout handling.
Context: The system has 6 PostgreSQL databases. Each data-owning service needs persistence.
Options:
- Single shared database: One PostgreSQL database with schema-per-service isolation. Simpler Docker setup but no data isolation.
- One database per service (chosen): Each service owns its schema and connection string. Services interact only through events or HTTP, never through shared database access.
Why: Database-per-service is the canonical microservice pattern. It enforces bounded context isolation at the data layer and the strongest form of service boundary. Each service can independently migrate its schema.
Tradeoff: More connection strings to manage (6 vs 1). Cross-service queries require API composition rather than a SQL join.
Context: FundingService simulates an external card processor with a configurable failure rate. It needs idempotency but has no persistent storage requirements.
Options:
- Full EF Core and PostgreSQL: Database-backed idempotency store. Adds migrations and latency for a service whose sole purpose is simulated flakiness.
- In-memory idempotency store (chosen): A
ConcurrentDictionarybacked byIIdempotencyStoreinterface. No database dependency. Failure rate is configurable viaFundingFailureRateenv var (default 0.3).
Why: FundingService exists to force resilience patterns in WalletService (Polly retry and circuit breaker). Adding a database would add operational weight without demonstrating a meaningful pattern. The IIdempotencyStore interface makes it trivial to swap to Redis or PostgreSQL for production.
Tradeoff: Idempotency cache is lost on service restart. In production you would use a shared cache (Redis) so the idempotency guarantee survives deployments.
Context: StatementService provides a paginated transaction history for wallets. This is a read-side concern separate from the write-side ledger.
Options:
- Query the LedgerService API: StatementService calls LedgerService's
/transactionsendpoint. Couples the read model to the write service's availability and schema. - CQRS read model consuming events (chosen): StatementService subscribes to
LedgerEntryCreatedEventon RabbitMQ. For each event, it writes twoStatementEntryrows to its own database.
Why: The read model is tailored to the query it serves (pagination, filtering, sorted by time) rather than the normalized write model. Building the statement from the same event stream that drives the saga ensures consistency without coupling to LedgerService's API.
Tradeoff: Eventually consistent because there is a delay (typically less than 1 second) between a ledger entry being created and the statement appearing. The read model could drift if the consumer falls behind.
The flow from a mutating request (top-up, transfer, authorize) to eventual consistency:
- Handler writes a message to the
outbox_messagestable (same DB transaction as business data) - OutboxRelayService (runs every 5 seconds) queries unprocessed messages
- It deserializes each message and publishes it to RabbitMQ
- The message (
CreateLedgerEntryCommand) is consumed by LedgerService - LedgerService creates a Debit and Credit ledger entry pair
- On success, LedgerService publishes
LedgerEntryCreatedEventwhich fans out to:- PaymentService: advances the saga state
- StatementService: writes 2 statement rows (CQRS read model)
- NotificationService: simulated email and push notification
- WalletService: logs the event
- On failure, LedgerService publishes
LedgerEntryFailedEventwhich fans out to:- PaymentService: marks the payment as Failed (compensation)
- NotificationService: simulated failure alert
The saga has 7 states, implemented at the application layer (no MassTransit state machine):
- PendingAuthorization (initial state after POST /authorize)
- Authorized (hold placed on payer funds via HoldsAccount)
- ProcessingCapture (moving funds from holds to merchant)
- Captured (terminal success state)
- ProcessingRelease (returning holds to payer; compensating transaction)
- Released (terminal success state)
- Failed (terminal state, reached if ledger entry fails)
The HoldsAccount (GUID 00000000-0000-0000-0000-000000000001) temporarily holds funds during authorization until capture or release.
| Layer | Mechanism |
|---|---|
| ApiGateway | Requires Idempotency-Key header on all POST/PUT/PATCH/DELETE (400 if missing) |
| FundingService | In-memory IdempotencyStore: returns cached response on replay |
| Outbox | Each outbox message is processed exactly once (marked ProcessedAtUtc after publish) |
- FundingService: configurable failure rate via
FundingFailureRateenv (default 0.3 / 30%) - WalletService to FundingService HTTP call: Polly retry (3 attempts, exponential backoff) and circuit breaker (5 failures, 30s break)
All endpoints are accessed through the ApiGateway on port 5600. Individual services are not meant to be called directly.
| Route Prefix | Backend | Port |
|---|---|---|
/api/ledger/* |
LedgerService | 5001 |
/api/wallet/* |
WalletService | 5002 |
/api/payment/* |
PaymentService | 5003 |
/api/funding/* |
FundingService | 5004 |
/api/notification/* |
NotificationService | 5005 |
/api/statement/* |
StatementService | 5006 |
| Method | Route | Description |
|---|---|---|
POST |
/api/ledger/entries |
Create a single ledger entry (debit or credit) |
POST |
/api/ledger/transactions |
Create a balanced double-entry pair |
GET |
/api/ledger/accounts/{id}/balance |
Get current balance |
GET |
/api/ledger/accounts/{id}/transactions |
Get transaction history |
| Method | Route | Description |
|---|---|---|
POST |
/api/wallet/wallets |
Create a new wallet |
GET |
/api/wallet/wallets/{id} |
Get wallet details |
PATCH |
/api/wallet/wallets/{id}/status |
Update wallet status (Active/Frozen/Closed) |
POST |
/api/wallet/wallets/{id}/top-up |
Initiate async top-up (requires Idempotency-Key) |
POST |
/api/wallet/wallets/{id}/transfer |
Initiate async transfer to another wallet |
| Method | Route | Description |
|---|---|---|
POST |
/api/payment/payments/authorize |
Authorize (hold funds) |
POST |
/api/payment/payments/{id}/capture |
Capture authorized hold |
POST |
/api/payment/payments/{id}/release |
Release hold (compensating transaction) |
GET |
/api/payment/payments/{id} |
Get payment status |
| Method | Route | Description |
|---|---|---|
POST |
/api/funding/funding/charge |
Simulate charging external source (requires Idempotency-Key header) |
| Method | Route | Description |
|---|---|---|
GET |
/api/statement/api/statements/{walletId}?page=1&pageSize=20 |
Get paginated statement |
Every service exposes GET /health (bypasses ApiGateway auth).
# Start full stack
cd docker
docker compose up -d
# Check everything is healthy
docker compose ps
# Tail logs for a service
docker compose logs -f wallet-service
# Stop everything
docker compose down -v # -v deletes volumes (reset data)| Service | URL | Credentials |
|---|---|---|
| ApiGateway | http://localhost:5600 |
X-Api-Key: payflow-demo-key |
| Swagger (via gateway) | http://localhost:5600/swagger |
(none) |
| pgAdmin | http://localhost:5050 |
admin@payflow.dev / admin |
| RabbitMQ Management | http://localhost:15672 |
guest / guest |
| Jaeger UI | http://localhost:16686 |
(none) |
236 tests
| Test Project | Count |
|---|---|
| LedgerService.Tests | 64 |
| WalletService.Tests | 65 |
| PaymentService.Tests | 61 |
| FundingService.Tests | 15 |
| StatementService.Tests | 9 |
| NotificationService.Tests | 11 |
| ApiGateway.Tests | 11 |
| Test | File | What It Proves |
|---|---|---|
| Idempotency double-fire | tests/FundingService.Tests/HandlerTests.cs |
Same idempotency key causes handler to execute once |
| Concurrent transfer | tests/WalletService.Tests/HandlerTests.cs |
5 parallel transfers between same wallets all succeed |
| Saga compensation | tests/PaymentService.Tests/HandlerTests.cs |
Authorize then Release (compensating transaction) works end-to-end |
dotnet test