Build Robust, Maintainable APIs in C# - Real Production Systems
Source: Dev.to
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, notOrderDbContext) - 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
{
private readonly IOrderRepository _repository;
public CreateOrderHandler(IOrderRepository repository)
{
_repository = repository;
}
public async Task 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");
}
}
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 SummarizeAsync(string text, CancellationToken cancellationToken);
}
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.