API Demo: bad design vs. good design
Two companion .NET APIs against the same conceptual e-commerce schema — one built the way a junior developer might build it under deadline pressure, one built with the practices a senior developer would insist on. Same k6 load profile against both, so the numbers below are a direct comparison, not two separate stories.
Both repos are self-contained — clone either one and run it (and its own k6 scenarios) independently.
Architecture
Bad API
bad-api repoA deliberately junior-quality ASP.NET Core Web API against a realistic e-commerce schema. It answers one question with real k6 numbers instead of a hand-wave: what actually happens under load when an API is built without ever load-testing it?
- Sync-over-async — `.Result` instead of `await`, `.ToList()` instead of `.ToListAsync()` in the hot paths.
- N+1 queries — Lazy-loaded navigation properties everywhere, no `.Include()` calls at all.
- Over-fetching — Every endpoint serializes full EF entity graphs directly — no DTOs, no projection.
- No pagination — `GET /api/orders` and `GET /api/products` return the entire table, every single call.
- Index-hostile queries — `?year=2024` compares `OrderDate.Year` in a way that defeats the existing index; `?search=` filters after `.ToList()` has already pulled the whole table into memory.
- DbContext as a Singleton — EF Core's DbContext is not thread-safe. This is the single biggest reason the API falls over under concurrent load.
- Connection pooling disabled — `Pooling=False` in the connection string — every lazy-load query opens a brand-new, unpooled connection.
- No caching — Not even for near-static reference data like categories and products.
- No rate limiting — Nothing throttles clients or applies backpressure as load increases.
- Chatty surface — Order detail forces a database round trip per related entity — order, then customer, then items, then a product lookup per item.
Verified: Verified against a dev-scale database (2k customers / 10k orders) on a single unloaded connection: GET /api/orders took ~230 seconds to stream ~6.7MB before the SQL client itself threw a Connection Timeout Expired error mid-response. Pooling=False plus a Singleton DbContext means every one of the thousands of lazy-load queries this endpoint triggers opens a new connection, and eventually the database stops keeping up. That failure is real and reproducible, not staged for effect.
Good API
good-api repoThe same conceptual schema, built the way a senior developer would insist on: layered (Domain -> Application -> Infrastructure -> Api, dependencies only ever pointing inward), test-first, and optimized specifically against the anti-patterns the bad-api repo demonstrates.
- Pagination everywhere — OFFSET/FETCH paging in the Dapper read repositories — never loads more rows than a page needs.
- Flat DTO projections — No entity graphs on the read path. Dapper projects straight into DTOs, so there's no N+1 to trigger in the first place.
- Order detail as one query — Header and line items come back from a single multi-result query (QueryMultipleAsync), not N+1 per line item.
- Index-friendly queries — OrderDate is indexed, and the queries are written to avoid wrapping it in a function that would defeat that index.
- Pooled DbContext — AddDbContextPool avoids both per-request allocation churn and the Singleton misuse the bad-api repo demonstrates.
- Caching via decorator — An in-memory cache wraps the order read repository (Open/Closed Principle) without either class knowing about the other's concern, plus 10-second server-side output caching.
- Rate limiting — A per-IP token-bucket limiter returns 429 instead of piling up unbounded load.
- Response compression — Brotli/Gzip enabled for both HTTP and HTTPS.
Verified: Every layer is covered by tests written before the code that makes them pass: unit tests for the query handlers against mocked repositories, integration tests running the Dapper repositories against a real SQL Server container (Testcontainers), and architecture tests that mechanically enforce the dependency-direction rules above — Domain cannot depend on Infrastructure or EF Core, Application cannot depend on Infrastructure.
The read/write split, in detail
A real, end-to-end CQRS-plus-outbox pipeline behind the good-api's read path: a write endpoint, a transactional outbox, an outbox dispatcher, a genuinely separate read-model migration history, and read repositories that serve their queries from that read model with zero joins back to the OLTP tables. An integration test proves the whole chain against a real SQL Server container — write, outbox dispatch, and read — not just one piece in isolation.
- Atomic write + outbox insert — PATCH /api/orders/{id}/status loads the order, applies the new status, and adds an outbox message to the same tracked DbContext — one SaveChangesAsync() commits both, so the status change and the outbox event can never partially fail.
- Outbox table with a filtered index — OutboxMessages keeps Type, Payload, OccurredAtUtc and a nullable ProcessedAtUtc, with a filtered index on unprocessed rows so the dispatcher's scan stays cheap regardless of table size.
- Dispatch logic split from its timer (SRP) — OutboxProcessor contains the actual dispatch logic and is unit/integration-testable with no timer involved; a thin BackgroundService just calls it every 5 seconds.
- A genuinely separate read-model migration history — OrderSummaryRead and OrderDetailRead are flat, denormalized tables owned by their own ReadDbContext and migration history, tracked in a distinct __EFMigrationsHistory_Read table — applied independently of the write side's migrations.
- Read repositories query the read model, not the OLTP tables — DapperOrderReadRepository now serves both endpoints from OrderSummaryRead/OrderDetailRead directly — zero INNER JOINs to Customers, Orders, OrderItems, or Products in either query.
- Backfill vs. ongoing sync — The seed script bulk-inserts historical data straight into the read-model tables alongside the OLTP tables (bypassing the outbox, which only reacts to writes through the API); the outbox then keeps everything after that seed point in sync. Both mechanisms are necessary — one for one-time catch-up, one for steady state.
- Honest caveat, unchanged from the MVP phase — ReadDbContext and WriteDbContext still point at the same physical database today. The separation that exists is schema/migration-history-level, not yet a separate server — swapping to a real replica later is a connection-string change, because nothing but migration tooling touches ReadDbContext directly.
Load test results
Test methodology
VU ramp (k6 ramping-vus executor)
Identical stage-for-stage in both repos' k6 scripts, so both APIs are hit with the same offered load, not just “both ramped up”:
- 30s → 50 VUs
- 1m → 50 VUs
- 30s → 200 VUs
- 1m → 200 VUs
- 30s → 500 VUs
- 1m → 500 VUs
- 30s → 0 VUs
~4.5 min ramp per scenario; each virtual user sleeps 1s between requests.
Thresholds & setup
- Good API:p95 < 300ms, error rate < 1% — real production-style gates.
- Bad API:deliberately loose thresholds (p95 < 30–60s) that exist only to document how badly it fails, not to gate a passing build.
- Every VU sends a distinct simulated client IP (
X-Forwarded-For) so 500 concurrent VUs from one test machine are accounted as 500 distinct users against the good API's per-client rate limiter, not one client hammering it 500×. - Both APIs seeded with the same
dev-scale dataset (10,000 orders) before each run.
GET /api/orders (list)
GET /api/orders/{id} (detail)
Last captured 7/28/2026.