Building "Production-Grade" APIs in .NET: Part 1 - Design Clean and Intuitive APIs

You can read the intro here: Let’s say you join a new team and find this in one of the core API controllers: [HttpPost("updateOrder")] public async Task UpdateOrder([FromBody] OrderDto order) { var updatedOrder = await _orderService.Update(order); return Ok(updatedOrder); } At first glance, it seems reasonable. It compiles. It works. It even passes QA. But if this looks fine to you — keep reading. This kind of code is deceptively simple. In fact, it’s a perfect example of how bad design doesn’t look broken, until it is. Under the surface, this endpoint is full of subtle flaws that will confuse consumers, pollute your Swagger docs, and slow your team down over time. Let’s unpack what’s wrong, and more importantly, how to fix it. What’s Wrong With This Endpoint? Let’s break it down: 1. Misused HTTP Verb You're updating an existing resource, not creating a new one. This should be a PUT or PATCH. The verb might feel like a minor detail, but it shapes how clients understand and interact with your API. Misusing it breaks expectations and undermines REST semantics. 2. CRUD Smell: The “updateEverything” Trap This is the biggest red flag. You're trying to model everything through CRUD. That seems simple at first, but it becomes unmaintainable fast. Over time, you'll bolt on more logic: Don’t allow canceling if already shipped. Don’t allow address changes if invoiced. Add this flag, but ignore it depending on status... You’ve now got a monster method trying to cover every edge case. Instead, shift toward task-based endpoints that represent real user intent: SetShippingAddress CancelOrder MarkOrderAsPaid Each task has clear rules, clear inputs, and a clear domain boundary. Highly recommended: Decomposing CRUD to a Task Based UI by CodeOpinion. It’s one of the best resources out there for understanding task-based API design and how to get out of the CRUD mindset. 3. IActionResult: Flexibility That Hurts Returning IActionResult may seem convenient, but it leads to vague Swagger documentation. Clients won’t know what to expect. Use ActionResult to explicitly define your response types — it improves both clarity and tooling support. 4. Overloaded, Generic OrderDto What is OrderDto? For whom is it meant? the database? The client? The update request? If it’s used for both input and output, it’ll end up looking something like this, in order to accommodate all use cases and request/responses: public record OrderDto { public string? OrderId { get; init; } public string? Status { get; init; } public AddressInfoDto? AddressInfo { get; init; } public OrderSummaryDto? OrderSummary { get; init; } public OrderLine[] OrderLines { get; init; } = []; public DateTime? CreatedAt { get; init; } public DateTime? UpdatedAt { get; init; } } public record OrderLine { public string? LineId { get; init; } public int Quantity { get; init; } public decimal Price { get; init; } } public record AddressInfoDto { public string? RecipientName { get; set; } public string? Street { get; set; } public string? City { get; set; } public string? State { get; set; } public string? ZipCode { get; set; } public string? Country { get; set; } } public record OrderSummaryDto { public decimal? TotalAmount { get; init; } public decimal? Vat { get; init; } } Should the client send this? Ignore it? What about status, or totalAmount? This leads to guessing, confusion, and coupling between internal models and external contracts. Define separate models for: Request payloads (e.g. SetShippingInfoRequest) Responses (e.g. ShippingInfoResponse) 5. No Error Modeling What happens if the update fails? Say the order is already shipped and can’t be changed? Right now, you might return a plain 400 Bad Request — but that’s meaningless without context. Clients will ask: “What did I do wrong?” You must do two things: Return a structured, predictable error response. Document why a 400 might happen. [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] Here are some valid 400 scenarios you should document: The address fields are missing or invalid (e.g. empty ZipCode). The order has already been shipped and address changes are forbidden. The order has been cancelled and is no longer editable. The country specified is unsupported. Business rules prevent shipping updates once payment is finalized. This way, clients know what to expect and how to recover. You can reflect this using the standard ValidationProblemDetails: { "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", "title": "One or more validation errors occurred.", "status": 400, "errors": { "ZipCode": ["Zip code is required."], "Order": ["Shipping information cannot be changed after shipm

May 10, 2025 - 18:23
 0
Building "Production-Grade" APIs in .NET: Part 1 - Design Clean and Intuitive APIs

You can read the intro here:

Let’s say you join a new team and find this in one of the core API controllers:

[HttpPost("updateOrder")]
public async Task<IActionResult> UpdateOrder([FromBody] OrderDto order)
{
    var updatedOrder = await _orderService.Update(order);

    return Ok(updatedOrder);
}

At first glance, it seems reasonable. It compiles. It works. It even passes QA.

But if this looks fine to you — keep reading.

This kind of code is deceptively simple. In fact, it’s a perfect example of how bad design doesn’t look broken, until it is. Under the surface, this endpoint is full of subtle flaws that will confuse consumers, pollute your Swagger docs, and slow your team down over time.

Let’s unpack what’s wrong, and more importantly, how to fix it.

What’s Wrong With This Endpoint?

Let’s break it down:

1. Misused HTTP Verb

You're updating an existing resource, not creating a new one. This should be a PUT or PATCH.

The verb might feel like a minor detail, but it shapes how clients understand and interact with your API. Misusing it breaks expectations and undermines REST semantics.

2. CRUD Smell: The “updateEverything” Trap

This is the biggest red flag. You're trying to model everything through CRUD. That seems simple at first, but it becomes unmaintainable fast.

Over time, you'll bolt on more logic:

  • Don’t allow canceling if already shipped.

  • Don’t allow address changes if invoiced.

  • Add this flag, but ignore it depending on status...

You’ve now got a monster method trying to cover every edge case.

Instead, shift toward task-based endpoints that represent real user intent:

  • SetShippingAddress

  • CancelOrder

  • MarkOrderAsPaid

Each task has clear rules, clear inputs, and a clear domain boundary.

Highly recommended: Decomposing CRUD to a Task Based UI by CodeOpinion.

It’s one of the best resources out there for understanding task-based API design and how to get out of the CRUD mindset.

3. IActionResult: Flexibility That Hurts

Returning IActionResult may seem convenient, but it leads to vague Swagger documentation. Clients won’t know what to expect.

Use ActionResult to explicitly define your response types — it improves both clarity and tooling support.

4. Overloaded, Generic OrderDto

What is OrderDto? For whom is it meant? the database? The client? The update request?
If it’s used for both input and output, it’ll end up looking something like this, in order to accommodate all use cases and request/responses:


public record OrderDto  
{  
    public string? OrderId { get; init; }  

    public string? Status { get; init; }  

    public AddressInfoDto? AddressInfo { get; init; }  

    public OrderSummaryDto? OrderSummary { get; init; }  

    public OrderLine[] OrderLines { get; init; } = [];  

    public DateTime? CreatedAt { get; init; }  

    public DateTime? UpdatedAt { get; init; }  
}  

public record OrderLine  
{  
    public string? LineId { get; init; }  
    public int Quantity { get; init; }  
    public decimal Price { get; init; }  
}  

public record AddressInfoDto  
{  
    public string? RecipientName { get; set; }  


    public string? Street { get; set; }  


    public string? City { get; set; }  


    public string? State { get; set; }  


    public string? ZipCode { get; set; }  


    public string? Country { get; set; }  
}  

public record OrderSummaryDto  
{  
    public decimal? TotalAmount { get; init; }  

    public decimal? Vat { get; init; }  
}

Should the client send this? Ignore it? What about status, or totalAmount?

This leads to guessing, confusion, and coupling between internal models and external contracts.

Define separate models for:

  • Request payloads (e.g. SetShippingInfoRequest)

  • Responses (e.g. ShippingInfoResponse)

5. No Error Modeling

What happens if the update fails? Say the order is already shipped and can’t be changed?

Right now, you might return a plain 400 Bad Request — but that’s meaningless without context.

Clients will ask: “What did I do wrong?”

You must do two things:

  1. Return a structured, predictable error response.
  2. Document why a 400 might happen.
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]

Here are some valid 400 scenarios you should document:

  • The address fields are missing or invalid (e.g. empty ZipCode).

  • The order has already been shipped and address changes are forbidden.

  • The order has been cancelled and is no longer editable.

  • The country specified is unsupported.

  • Business rules prevent shipping updates once payment is finalized.

This way, clients know what to expect and how to recover.

You can reflect this using the standard ValidationProblemDetails:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "ZipCode": ["Zip code is required."],
    "Order": ["Shipping information cannot be changed after shipment."]
  }
}

6. Swagger Docs Will Be Garbage

Without XML comments and meaningful examples, Swagger will generate things like:

{
  "address": "string"
}

That tells consumers nothing. Which fields are required? What does a valid address look like?

This is why we need:

  • XML comments

  • Swagger examples

  • Concrete request/response types

A Better Design: Task-Based SetShippingInfo

Let’s redesign this endpoint with all the above in mind.

SetShippingInfo Endpoint

///   
/// Sets the shipping address for an order.  
///   
///   
/// Returned when:  
/// - 'ZipCode' is missing or invalid  
/// - The order cannot be updated due to its current status  
///   
[MapToApiVersion("2")]  
[HttpPatch("{orderId}/shipping")]  
[ProducesResponseType(StatusCodes.Status204NoContent)]  
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]  
[ProducesResponseType(StatusCodes.Status404NotFound)]  
[SwaggerResponseExample(StatusCodes.Status400BadRequest, typeof(ZipCodeExample))]  
[SwaggerResponseExample(StatusCodes.Status404NotFound,typeof(NotFoundProblemDetailsExample))]  
public async Task<IActionResult> SetShippingInfo(Guid orderId, SetShippingInfoRequest request)  
{  
    await orderService.SetShippingInfo(orderId, request);  
    return NoContent();  
}

SetShippingInfoRequest

/// 
/// Represents the request to set shipping information for an order.
/// 
public class SetShippingInfoRequest
{
    /// John Smith
    public string RecipientName { get; set; }

    /// 123 Main St
    public string Street { get; set; }

    /// New York
    public string City { get; set; }

    /// NY
    public string State { get; set; }

    /// 10001
    public string ZipCode { get; set; }

    /// USA
    public string Country { get; set; }
}

ShippingInfoResponse

/// 
/// The response returned after successfully setting the shipping info.
/// 
public class ShippingInfoResponse
{
    public Guid OrderId { get; set; }

    public string RecipientName { get; set; }

    public DateTime UpdatedAt { get; set; }

    // any other fields that could be needed for the response
    // ...

}

You might use this model if you want to immediately return the updated shipping information after the operation. This can be useful if the client needs confirmation of what’s now stored on the server.

But ask yourself: Is this really needed?

Since this is a PATCH, and the client likely already knows what it sent, returning the entire updated model may be wasteful, especially if the response adds no new value.

In many cases, a better choice is to return 204 No Content, which tells the client:

  • The operation succeeded
  • There’s nothing more to say

If you choose that route, the controller action can be simplified:

[HttpPatch("orders/{orderId}/shipping")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ValidationErrorResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SetShippingInfo(
    [FromRoute] Guid orderId,
    [FromBody] SetShippingInfoRequest request)
{
    await _orderService.SetShippingInfo(orderId, request);

    return NoContent();
}

Final Takeaways

  • Just because an API “works” doesn’t mean it’s well-designed.
  • CRUD endpoints invite technical debt; use task-based designs to model intent.
  • Use precise verbs, structured models, and document your failure scenarios.
  • Swagger is not just for machines; make it a first-class developer experience.
  • Know when to return a rich response, and when to just say 204.

Next up: We’ll bring this endpoint to life in Swagger — showing exactly how to document responses, add real examples, and make your OpenAPI docs a delight to use. Read the part 2 here