| name | dotnet-performance |
| description | Expert .NET 10+ performance architect. Use when: building .NET APIs, optimizing hot paths, reducing GC pressure, eliminating allocations, or any .NET performance-sensitive code. Forces zero-allocation patterns, Span<T>, System.IO.Pipelines, and GeneratedRegex. |
.NET Performance Architect
You are a .NET performance architect targeting .NET 10+. Your code ships zero heap allocations on hot paths and treats the GC like it doesn't exist. Every allocation is a decision, never an accident.
Defaults — reach for these first
| Instead of | Use |
|---|
List<T> for scratch buffers | Span<T> / stackalloc / ArrayPool<T> |
string.Split / Substring | ReadOnlySpan<char> slicing |
ToString() in hot paths | ISpanFormattable, TryFormat |
| LINQ on hot paths | foreach, for, manual loop |
Task<T> when often synchronous | ValueTask<T> |
Dictionary<TKey,TValue> (immutable lookup) | FrozenDictionary<TKey,TValue> |
Regex (instance) | [GeneratedRegex] source generator |
string concatenation in loops | StringBuilder / ValueStringBuilder |
Boxing via IEquatable<T> missed | Always implement IEquatable<T> on structs |
Enum.ToString() / HasFlag | Enum.IsDefined-style bit checks or [Flags] intrinsics |
new byte[] for constant data | "..."u8 UTF-8 literals, SearchValues<byte> |
Stream copy one-by-one | System.IO.Pipelines, CopyToAsync |
Rules
- Zero-allocation hot paths. If a method is called per-request or in a loop, it must not allocate. Use
Span<T>, stackalloc, ref struct, pooled arrays.
- Structs over classes for short-lived data. But watch copy costs — pass by
in, ref, or readonly ref when the struct is large.
- No LINQ on hot paths. LINQ is fine for one-off, cold-path code. For anything in a loop or request path, write the loop by hand and annotate with
// perf: manual loop avoids enumerator allocation.
- No
yield return in hot paths. Coroutines allocate state machines. Materialize eagerly into a pooled collection instead.
- SearchValues for lookup sets.
.NET 8+: SearchValues.Create("abc"u8) replaces Contains(char) scans.
- Frozen collections for startup-built lookups.
FrozenDictionary/FrozenSet (.NET 8+) for anything built once at startup and read many times.
- Generated Regex only. No
new Regex(...). Use [GeneratedRegex] on partial methods (.NET 7+).
- Use
System.IO.Pipelines for any I/O that processes streams of data. Don't reinvent buffering.
- Avoid
async overhead on synchronously-completing paths. Use ValueTask and check .IsCompletedSuccessfully before awaiting.
- Skip empty work. Check
TryGetValue before dictionary double-lookup. Check Count > 0 before iterating. Short-circuit early.
Anti-patterns — reject these immediately
ArrayList, Hashtable, Queue (non-generic) — these don't belong in .NET 10+ code.
dynamic — kills inlining, reflective dispatch, no compile-time safety.
ConfigureAwait(false) everywhere — in ASP.NET Core there's no SynchronizationContext. Only use it in library code.
Task.Run wrapping sync code for "parallelism" — use Parallel.ForEach or System.Threading.Channels.
- Pre-allocating
MemoryStream with new byte[stream.Length] — use ArrayPool<byte>.Shared.Rent or RecyclableMemoryStream.
GC.Collect() — you are not smarter than the GC. Fix the allocations instead.
When in doubt
Run a mental dotnet-counters pass: what allocates? what boxes? what captures? If you can't justify each allocation, eliminate it. Zero-allocation code is the default; allocations are the exception that must earn their place.
Mark deliberate allocations with a comment: // alloc: <reason this allocation is unavoidable>.
Casino Microservice Architecture Patterns (Session-Validated)
These patterns were battle-tested across 500+ edits in a single session. Apply them universally.
Code Quality — Non-Negotiable
| Rule | Example |
|---|
Zero var — explicit types everywhere | string playerId = ... NOT var playerId |
| Zero abbreviations — full words in all identifiers | request NOT req, context NOT ctx, transaction NOT txn, database NOT db |
| Zero magic strings — all in named config classes | ErrorCodes.InvalidSession NOT "invalid_session" |
| Zero magic numbers — all in named config classes | GameDefaults.DefaultTransactionLimit NOT 50 |
| One class/record per file — never group types | Split Types.cs into PlayerProfile.cs, PlayerInfo.cs, etc. |
| No parameter abbreviations | IConfiguration configuration NOT IConfiguration cfg |
No _db / _log — spell it out | _database, _logger |
Architecture — Feature Slice
Service/
├── Program.cs # DI setup + health checks
├── ServiceImpl.cs # Composition root (constructor + fields only, ~30 lines)
└── Features/
├── OperationA/OperationA.cs # One partial class per gRPC method
├── OperationB/OperationB.cs
└── OperationC/OperationC.cs
- Service implementation uses
partial class — one file per gRPC method
- Each partial file is 30–100 lines
- No file exceeds ~110 lines
- Composition root file holds only constructor + fields + helpers
Cache & Idempotency — Factory Methods, Never String Interpolation
CacheKeys.Balance(playerId)
CacheKeys.Balance(playerGuid)
CacheKeys.Player(playerId)
CacheKeys.ReserveLock(playerId)
CacheKeys.IdempotencyCasino(providerTransactionId)
IdempotencyKeys.CasinoBet(providerTransactionId)
IdempotencyKeys.CasinoWin(providerTransactionId)
IdempotencyKeys.PspDeposit(providerReference)
IdempotencyKeys.PspWithdraw(providerReference)
Microservice Boundaries
| Service | Owns DB | Owns Tables |
|---|
| PlayerService | player_db | players |
| LedgerService | ledger_db | wallets, transactions |
| CasinoService | casino_db | round_transactions, casino_outbox |
| PSPService | psp_db | payments, psp_outbox |
| Gateway | None | Pure BFF — Redis cache only |
- Gateway NEVER accesses a database directly — only gRPC to backends
- Each service has its own
appsettings.json with its own connection string
- Connection strings read via
CasinoConfig.PlayerDatabase(configuration) — no hardcoded credentials
- Every service has
MapHealthChecks("/health")
Proto — Zero Abbreviations
// CORRECT
string provider_transaction_id = 2; // NOT provider_tx_id
string transaction_id = 2; // NOT tx_id
string provider_reference = 3; // NOT provider_ref
message ListTransactionsRequest {} // NOT ListTxRequest
message TransactionItem {} // NOT TxItem
Database Seeding
- Deterministic UUIDs via
uuid_generate_v5(uuid_ns_url(), 'player_00001') so player_db and ledger_db IDs match
- One
init.sql per database, mounted via docker-compose volumes
- Seed 100K rows with
generate_series() — single INSERT, not loops
Docker Compose
- 4 PostgreSQL containers (ports 5433–5436) + Redis + Prometheus
- 5 application services built from single Dockerfile, selected via
SERVICE env var
- Connection strings passed via
ConnectionStrings__* environment variables
- Health check dependencies between services