DEV Community

Cover image for Build Robust, Maintainable APIs in C# - Real Production Systems
Saber Amani
Saber Amani

Posted on

Build Robust, Maintainable APIs in C# - Real Production Systems

Building APIs That Don’t Rot: Lessons from Shipping Production Systems in C

Rotten APIs are everywhere, and I’ve been guilty of shipping more than a few. The problems rarely show up on day one. Instead, they creep in as features grow, teams change, and edge cases pile up. Here’s what I’ve learned (the hard way) about designing APIs in C#/.NET that survive more than one release cycle.

API Design: Pragmatism Over Purity

REST is great on paper, but most real APIs end up somewhere between “textbook REST” and “just make it work.” I used to obsess over purity, but I’ve learned to prioritize:

  • Consistent resource naming, even if you bend REST rules occasionally

  • Returning actionable error messages, not just HTTP status codes

  • Making endpoints idempotent by default, especially for write operations

For example, when building a payment API, we made the POST /payments endpoint idempotent by requiring a client-generated Idempotency-Key. This stopped duplicate charges when clients retried requests, but it also forced us to store and manage those keys. The trade-off? Slightly more backend complexity, but far fewer support tickets about “why did I get double-charged?”

Structuring .NET Projects for Sanity (and Scale)

“Just put it in the Controllers folder” is how most projects start. But once your codebase hits double-digit projects, this falls apart fast. Clean Architecture saved us when the business logic got gnarly. What actually worked:

  • Separate core domain logic from infrastructure and API layers

  • Use interfaces to decouple data access (IOrderRepository, not OrderDbContext)

  • Lean on dependency injection for testability and swapping out implementations

Here’s a simplified example of a command handler for creating an order:

public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, OrderResult>
{
    private readonly IOrderRepository _repository;
    public CreateOrderHandler(IOrderRepository repository)
    {
        _repository = repository;
    }
    public async Task<OrderResult> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
    {
        var order = new Order(request.CustomerId, request.Items);
        await _repository.AddAsync(order, cancellationToken);
        return new OrderResult(order.Id, "Order created");
    }
}

Enter fullscreen mode Exit fullscreen mode

This pattern kept our core logic testable and portable, even when we swapped out SQL for CosmosDB later.

Deployment Isn’t Done When It Works on Your Machine

Early on, I treated deployment like a black box. AWS or Azure would “just work,” right? Wrong. The most painful bugs I’ve debugged were caused by:

  • Missing environment variables in production

  • Accidentally deploying dev branches to staging (or worse, prod)

  • Not setting up proper logging or alerting, leaving us blind when things failed

The fix? Automate everything you can, and make environment separation a first-class concern. We now use explicit config files per environment and lock down deployment pipelines so only tested branches get near production. This isn’t glamorous, but it’s saved us from the “it worked yesterday, but prod is on fire” scenario more than once.

AI Integrations: Useful, Not Magical

When we first integrated OpenAI into our product, we treated prompt design like software config, not magic. Every prompt (and its expected output shape) lives in version-controlled files, and we run integration tests just like with any other external dependency. The biggest lesson: don’t let AI turn your codebase into spaghetti. Always wrap AI calls with interfaces and keep business logic out of your prompt strings.

Example interface:

public interface ITextSummarizer
{
    Task<string> SummarizeAsync(string text, CancellationToken cancellationToken);
}

Enter fullscreen mode Exit fullscreen mode

This abstraction let us swap between OpenAI, Azure Cognitive Services, or even mock implementations for testing.

Actionable Takeaway

Next time you’re designing an API, force yourself to write the error responses and idempotency logic before you ship. You’ll thank yourself the first time your frontend retries a request and doesn’t break something in production.

What’s the one design decision in your last project you wish you could go back and redo? Share your scars, not just your successes. Let’s learn from each other.

Top comments (0)