TL;DR: Code that works on your laptop is maybe 30% of the job. Before it goes to production, check: security (who can call it, what can they inject), data access (N+1, pagination, indexes), external calls (timeouts, retries, HttpClient), shared state, observability, config and secrets, and how you deploy and roll it back. This post is a checklist for that, plus one example review.

Why this post

Sometimes you get code you didn’t write: a prototype, a PR from another team, an old service nobody owns. The task is “make it production-ready”. Where do you even start?

Without a list, you fix the first thing you see and miss the dangerous ones. So this is the list I’d use, and it also ties together the other posts in this series.

Before: it works on my machine

app.MapGet("/report/{customerId}", (string customerId, AppDbContext db) =>
{
    var http = new HttpClient();
    var sql = $"SELECT * FROM Orders WHERE CustomerId = '{customerId}'";
    var orders = db.Orders.FromSqlRaw(sql).ToList();

    foreach (var o in orders)
        o.Items = db.Items.Where(i => i.OrderId == o.Id).ToList();

    var rates = http.GetStringAsync("https://fx.example.com/rates?key=sk_live_abc123").Result;

    Console.WriteLine("report done for " + customerId);
    return orders;
});

It returns the right data. The demo went well. Now let’s review it.

The review, problem by problem

  1. No authorization. Anyone can call /report/42, then /report/43, and read every customer’s orders. This is number one in the OWASP API Security Top 10 (broken object level authorization, also called IDOR).
  2. SQL injection. customerId goes straight into SQL. ' OR 1=1 -- returns all orders. FromSqlRaw with string building is the trap. Use LINQ, or FromSql($"...") which turns the values into parameters.
  3. Secret in code. The API key is in the URL, in source control, and probably in some proxy logs now. Move it to a secret store (Key Vault, user secrets locally) and rotate it.
  4. new HttpClient() per request. Under load this can run out of sockets, and it ignores DNS changes if you keep one forever. Use IHttpClientFactory or a typed client.
  5. No timeout, no resilience. If the FX service hangs, this request waits 100 seconds. Add a timeout, retries, a circuit breaker.
  6. .Result blocks a thread. Under load: thread pool starvation. Make it async.
  7. N+1 queries. One query for orders, then one per order for items. 500 orders = 501 queries. Load them in one query with a projection or Include.
  8. No pagination, SELECT *. A big customer has 200,000 orders. The endpoint loads all of them, every column, into memory.
  9. Sync DB calls (ToList) and no CancellationToken. If the user closes the page, we keep working for nothing.
  10. Returns EF entities. You leak internal fields, can hit circular references in JSON, and any new column becomes part of your public API. Return DTOs.
  11. Console.WriteLine for logging. No levels, no structure, no trace id. Use ILogger with message templates.
  12. The result of the FX call is never used. Small thing, but it says the code wasn’t really reviewed. Delete what you don’t need.

After

app.MapGet("/customers/{customerId:int}/orders", async (
        int customerId,
        ClaimsPrincipal user, AppDbContext db,
        ILogger<Program> log, CancellationToken ct,
        int page = 0) =>
{
    if (!user.CanAccessCustomer(customerId))     // your own authorization check
        return Results.Forbid();

    var orders = await db.Orders
        .AsNoTracking()
        .Where(o => o.CustomerId == customerId)
        .OrderByDescending(o => o.CreatedAt)
        .Skip(page * 50).Take(50)
        .Select(o => new OrderDto(
            o.Id, o.Total,
            o.Items.Select(i => new ItemDto(i.Sku, i.Quantity)).ToList()))
        .ToListAsync(ct);

    log.LogInformation("Loaded {Count} orders for customer {CustomerId}", orders.Count, customerId);
    return Results.Ok(orders);
})
.RequireAuthorization();

One query, parameterized, paged, only the columns we need, async, cancellable, authorized, logged properly. The FX call is gone because nothing used it. If we need it, it goes into a typed HttpClient with a resilience handler and a key from configuration.

The checklist

Print it, or keep it next to your PR template.

Security

  • Authentication on every endpoint that needs it. Authorization on the object, not only “is logged in”.
  • All input validated. No string-built SQL, HTML, or shell commands.
  • No secrets in code, config files, or logs.
  • No sensitive data (tokens, card numbers, personal info) in logs or error messages.

Data access

  • No N+1. Look for queries inside loops.
  • Pagination on anything that returns a list.
  • Indexes for the WHERE and ORDER BY you actually use.
  • Transactions where several writes must succeed together.
  • Migrations compatible with the previous version of the code (expand and contract).

External calls

  • IHttpClientFactory, timeouts, retries with backoff, circuit breaker.
  • Retried writes are idempotent.
  • What does the user see when the dependency is down?

Concurrency and async

  • No mutable state in singletons or statics (or it is thread-safe).
  • No .Result / .Wait(), no async void, no fire-and-forget Task.Run.
  • CancellationToken passed down.

Observability

  • Structured logs with useful fields, no Console.WriteLine.
  • Metrics: rate, errors, latency p95/p99.
  • Traces across services.
  • An alert that will actually fire if this breaks.

Config and operations

  • Config per environment, secrets in a secret store.
  • Health checks (liveness and readiness).
  • Graceful shutdown: in-flight requests and background jobs finish or stop cleanly.
  • A deploy strategy and a tested rollback. Risky changes behind a feature flag.

Performance

  • Measure before optimizing. Cache only what is safe to be stale.
  • Response size: DTOs, not entities. Compression for large responses.

Tests

  • The main path and the main failure paths are tested.
  • One test for the bug you just fixed, so it doesn’t come back.

How to review code you don’t know

Not line by line from the top. In this order:

  1. Entry points. What can be called from outside? Endpoints, message handlers, scheduled jobs.
  2. Trust boundaries. Where does user input come in? Follow it to the DB, the file system, other services.
  3. Remote calls. Every DB query and HTTP call. Timeout? Loop? Pagination?
  4. Shared state. Singletons, statics, caches.
  5. Ask “what if”: what if this list has a million items? What if this service is down? What if two requests come at the same time? What if we deploy in the middle of it?

Fix the worst problems first: security and data loss, then outages, then performance, then style.

The 30-second summary

To make unknown code production-ready, I go from the outside in. First security: authentication, authorization on each object, input validation, no SQL injection, no secrets in code. Then data access: N+1 queries, pagination, indexes, async calls with cancellation. Then external calls: HttpClientFactory, timeouts, retries, circuit breaker, idempotency. Then shared state and blocking async code. Then observability: structured logs, metrics with p99, traces, alerts. And finally operations: config and secrets, health checks, a deploy strategy and a tested rollback. I fix security and data loss first, then reliability, then performance.

That’s it. What is on your checklist that is missing here? Tell me in comments. 🙂

Leave a Reply