Erik Mostert

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

A 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 queriesLazy-loaded navigation properties everywhere, no `.Include()` calls at all.
  • Over-fetchingEvery 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 SingletonEF 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 cachingNot even for near-static reference data like categories and products.
  • No rate limitingNothing throttles clients or applies backpressure as load increases.
  • Chatty surfaceOrder 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.

The 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 everywhereOFFSET/FETCH paging in the Dapper read repositories — never loads more rows than a page needs.
  • Flat DTO projectionsNo 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 queryHeader and line items come back from a single multi-result query (QueryMultipleAsync), not N+1 per line item.
  • Index-friendly queriesOrderDate is indexed, and the queries are written to avoid wrapping it in a function that would defeat that index.
  • Pooled DbContextAddDbContextPool avoids both per-request allocation churn and the Singleton misuse the bad-api repo demonstrates.
  • Caching via decoratorAn 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 limitingA per-IP token-bucket limiter returns 429 instead of piling up unbounded load.
  • Response compressionBrotli/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 insertPATCH /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 indexOutboxMessages 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 historyOrderSummaryRead 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 tablesDapperOrderReadRepository now serves both endpoints from OrderSummaryRead/OrderDetailRead directly — zero INNER JOINs to Customers, Orders, OrderItems, or Products in either query.
  • Backfill vs. ongoing syncThe 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 phaseReadDbContext 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

Bad APIGood API

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”:

  1. 30s50 VUs
  2. 1m50 VUs
  3. 30s200 VUs
  4. 1m200 VUs
  5. 30s500 VUs
  6. 1m500 VUs
  7. 30s0 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)

p95 latency
247ms
46ms
Throughput
189.4 req/s
220.5 req/s
Avg response size
21.0 KB
2.8 KB
Error rate at 500 max VUs100.0%vs0.00%
Requests captured over the run62,498 vs 66,318

GET /api/orders/{id} (detail)

p95 latency
164ms
68ms
Throughput
212.2 req/s
219.2 req/s
Avg response size
2.1 KB
552 B
Error rate at 500 max VUs96.1%vs0.00%
Requests captured over the run63,789 vs 65,943

Last captured 7/28/2026.