A .NET 10 microservice that manages player wallet balances for a PAM (Player Account Management) platform. Built with .NET Aspire, Microsoft Orleans, PostgreSQL, and Kafka.
- Orleans — actor-model grain per player (
PlayerWalletGrain). One activation cluster-wide eliminates balance race conditions without locks. - PostgreSQL — grain state (JSON via ADO.NET provider), transaction audit log (
wallet_transactions), balance projection (wallet_balances), and Orleans clustering membership. - Kafka — wallet domain events (
FundsAdded,FundsDeducted,DeductionRejected) published towallet-events. - Aspire — orchestrates Postgres and Kafka containers in development; injects typed connection strings.
- Clean Architecture — Domain → Application → Infrastructure / Api. MediatR CQRS, FluentValidation pipeline behaviour, ErrorOr result type in handlers.
See architecture.md for component diagrams and decisions.md for ADRs.
- .NET 10 SDK
- Docker Desktop (Aspire spins up Postgres and Kafka)
- .NET Aspire workload:
dotnet workload install aspire
dotnet run --project src/PlayerWallet.AppHostAspire starts Postgres, Kafka, the one-shot migrator, and the API. The dashboard is available at https://localhost:15888 (port may vary — check the AppHost console output).
Swagger UI: https://localhost:<api-port>/swagger
All endpoints require X-Player-Id (UUID). Mutating endpoints require Idempotency-Key (UUID v4).
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/wallets/deposit |
Credit player balance |
POST |
/api/v1/wallets/withdraw |
Debit player balance |
GET |
/api/v1/wallets/balance |
Read current balance |
Deposit / Withdraw request body
{ "amount": 10.00, "currencyCode": "USD" }Headers
X-Player-Id: <player-guid> (required on all endpoints)
Idempotency-Key: <uuid-v4> (required on POST)
X-Correlation-Id: <uuid-v4> (optional — generated server-side if absent)
src/
├── PlayerWallet.AppHost/ # Aspire orchestrator
├── PlayerWallet.ServiceDefaults/ # OTEL, health checks, resilience
├── PlayerWallet.Api/ # Minimal API endpoints, middleware, Swagger
├── PlayerWallet.Application/ # MediatR commands/queries, FluentValidation
├── PlayerWallet.Domain/ # Grain interfaces, events, exceptions
├── PlayerWallet.Grains/ # Orleans grain implementations
├── PlayerWallet.Infrastructure/ # Dapper repos, Kafka producer, DbUp migrations
└── PlayerWallet.Migrator/ # One-shot worker: runs DbUp then exits
tests/
├── PlayerWallet.ComponentTests/ # xUnit + Testcontainers (33 tests)
└── PlayerWallet.Benchmarks/ # NBomber sustained-load benchmarks
dotnet test tests/PlayerWallet.ComponentTests33 component tests covering deposit, withdraw, balance, idempotency, error cases, and Kafka event publishing. Postgres and Kafka run in Testcontainers — no manual setup required.
1 000 rps × 5 minutes per scenario, 300 000 requests, zero errors.
# Run individually — each scenario takes ~6 minutes
dotnet test tests/PlayerWallet.Benchmarks --filter "Balance_1000rps_5min_MeetsSLA"
dotnet test tests/PlayerWallet.Benchmarks --filter "Deposit_1000rps_5min_MeetsSLA"
dotnet test tests/PlayerWallet.Benchmarks --filter "Withdraw_1000rps_5min_MeetsSLA"| Scenario | Mean | StdDev | p95 | p99 | SLA (p99 < 100 ms) |
|---|---|---|---|---|---|
| Balance | 1.00 ms | 2.20 ms | 1.64 ms | 12.07 ms | ✅ |
| Deposit | 98.24 ms | 66.57 ms | 225.41 ms | 338.18 ms | ❌ |
| Withdraw | 108.54 ms | 84.73 ms | 260.35 ms | 434.43 ms | ❌ |
| Decision | Choice | Why |
|---|---|---|
| Idempotency | L1 in-memory cache + L2 INSERT ... ON CONFLICT DO NOTHING RETURNING * |
Two-layer, one DB round-trip, no extra infrastructure |
| Persistence | Dapper (runtime) + DbUp (migrations) | Minimal overhead at 15K writes/s; DbUp handles Orleans tables EF Core cannot |
| Clustering | Orleans ADO.NET (all environments) | Same code path in dev and production |
| Error handling | Exceptions at grain boundary, ErrorOr in handlers |
Orleans serialiser constraint on grain return types |
| Kafka delivery | Direct from grain, CDC-ready outbox | Transactional outbox requires sharing a DB transaction with WriteStateAsync, which the ADO.NET provider does not support |
See decisions.md for full ADRs with alternatives and tradeoffs.