What separates endpoints that work from APIs that scale
You ship your first WebAPI controller. It compiles. It runs. Postman shows data. Your manager says "looks good."
Six months later, that API is a nightmare. The mobile team can't figure out error responses. The frontend caches stale data. Support tickets pile up because clients don't know what went wrong. The API works, but nobody can build against it reliably.
The controller compiled. The problem was you treated it like a function that returns JSON.
I've reviewed hundreds of API controllers across companies of all sizes. The issues repeat:
- Exposing database entities directly
- Returning
200 OKfor everything - No validation
- Error messages that help nobody
- Synchronous calls blocking threads
- No logging
- Breaking changes with every deployment
Your controller is the first code that touches every request and the last code before every response. It's the contract between your system and everyone who depends on it.
This post shows the patterns that separate endpoints that compile from APIs that scale.
⚠️ The Controller That Works (Until It Doesn't)
AI generates this in 30 seconds:
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly ApplicationDbContext _db;
public OrdersController(ApplicationDbContext db)
{
_db = db;
}
[HttpGet]
public List<Order> GetOrders()
{
return _db.Orders.ToList();
}
[HttpGet("{id}")]
public Order GetOrder(int id)
{
return _db.Orders.Find(id);
}
[HttpPost]
public IActionResult CreateOrder(Order order)
{
_db.Orders.Add(order);
_db.SaveChanges();
return Ok();
}
[HttpPut("{id}")]
public IActionResult UpdateOrder(int id, Order order)
{
var existing = _db.Orders.Find(id);
existing.Status = order.Status;
_db.SaveChanges();
return Ok();
}
[HttpDelete("{id}")]
public IActionResult DeleteOrder(int id)
{
var order = _db.Orders.Find(id);
_db.Orders.Remove(order);
_db.SaveChanges();
return Ok();
}
}
It works. You test it locally. Data flows. The frontend team integrates it. You ship it.
Three months later:
- Missing orders return an empty 204 on GET — the mobile app chokes parsing a body that isn't there. PUT or DELETE one and the server null-refs into a 500
- No caching contract — every client team invents their own, and the stale-data bugs land on your API
- Support doesn't know why orders fail to create (no validation feedback)
- Order creation grows a second step — inventory, payment — and now you have half-completed orders, because nothing spans the steps in a transaction
- Order confirmation emails sometimes don't send (no error handling)
- Someone adds an
Include(o => o.Customer)and theOrder → Customer → Orderscycle kills serialization — server-side, as a 500, on every request - Deleted orders break reporting (hard delete)
- No audit trail of who changed what
Every pattern here is wrong for production. But it compiled, so you shipped it.
🎯 What Professional Controllers Look Like
The Boundary Principle
Your controller is a boundary. It sits between the HTTP world and your application logic.
Three responsibilities: 1. Translate HTTP → Domain - Turn requests into commands your application understands 2. Execute Application Logic - Through services, not directly 3. Translate Domain → HTTP - Turn results into proper HTTP responses
What controllers should NEVER do: * Touch the database directly * Contain business logic * Expose internal entities * Make decisions about data * Handle exceptions from lower layers
DTOs: The Barrier Between Worlds
The rule: Public APIs receive and return DTOs. Entities stay internal.
Why?
Entity (Internal):
public class Order
{
public int Id { get; set; }
public string Status { get; set; }
public decimal Total { get; set; }
public Customer Customer { get; set; } // Navigation property
public List<OrderLine> Lines { get; set; }
public DateTime CreatedAt { get; set; }
public string CreatedBy { get; set; }
public DateTime? ModifiedAt { get; set; }
public string ModifiedBy { get; set; }
public bool IsDeleted { get; set; }
}
DTO (Public Contract):
public record OrderDto(
int Id,
string Status,
decimal Total,
DateTime OrderDate
);
public record CreateOrderRequest(
int CustomerId,
List<OrderLineDto> Lines
);
public record OrderLineDto(
int ProductId,
int Quantity,
decimal UnitPrice
);
What DTOs prevent:
- Circular references - DTOs are flat. No object graph, no cycle waiting to blow up the serializer the day someone loads the full graph
- Over-posting attacks - Client can't set
CreatedBy,IsDeleted, audit fields - Breaking changes - You can change internal structure without breaking the API
- Exposing internals - Clients don't see
IsDeleted, change tracking fields - Unintended data leaks - Every entity field isn't automatically public
If you expose entities directly, you've coupled your database schema to your public API. Every internal change becomes a breaking change.
🔍 HTTP Status Codes Matter
Common anti-pattern:
return Ok(); // For everything
Clients can't tell success from failure without parsing response bodies.
The Status Codes You Need
2xx Success:
* 200 OK - Request succeeded, returning data
* 201 Created - Resource created, includes Location header
* 204 No Content - Request succeeded, no body to return (updates, deletes)
4xx Client Errors: * 400 Bad Request - Validation failed, malformed request * 401 Unauthorized - Authentication required or failed * 403 Forbidden - Authenticated but lacks permission * 404 Not Found - Resource doesn't exist * 409 Conflict - Request conflicts with current state (duplicate email, version mismatch)
5xx Server Errors: * 500 Internal Server Error - Unexpected server failure
Example - Create with proper status:
[HttpPost]
public async Task<ActionResult<OrderDto>> CreateOrder(
CreateOrderRequest request,
CancellationToken cancellationToken)
{
var result = await _orderService.CreateOrderAsync(request, cancellationToken);
if (!result.IsSuccess)
return BadRequest(new { error = result.Error });
return CreatedAtAction(
nameof(GetOrder),
new { id = result.Value.Id },
result.Value
);
}
What this does:
* Returns 201 Created on success
* Includes Location header: https://api.example.com/api/orders/123
* Client knows exactly where to find the new resource
* Returns 400 Bad Request with error details on failure
* Accepts CancellationToken so the server stops processing if the client disconnects
Note on Result<T>: This isn't a built-in .NET type — it's a custom pattern for handling errors without exceptions. We cover it in detail in Exception Handling That Survives Production. For now, just know it has IsSuccess, Value, and Error properties.
✅ Validation Before Work Happens
Bad requests get rejected before anything changes. Not after the order is half-created. Before. The controller is the HTTP boundary — it translates and delegates. Business validation lives one layer in, at the top of the service.
FluentValidation
public record CreateOrderRequest(
int CustomerId,
List<OrderLineDto> Lines
);
public class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerId).GreaterThan(0)
.WithMessage("Customer ID is required");
RuleFor(x => x.Lines).NotEmpty()
.WithMessage("Order must contain at least one line");
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(x => x.Quantity).GreaterThan(0);
line.RuleFor(x => x.UnitPrice).GreaterThan(0);
});
}
}
Register validators in Program.cs:
builder.Services.AddValidatorsFromAssemblyContaining<CreateOrderValidator>();
Validation happens in the service layer:
public class OrderService : IOrderService
{
private readonly IValidator<CreateOrderRequest> _validator;
public OrderService(IValidator<CreateOrderRequest> validator)
{
_validator = validator;
}
public async Task<Result<OrderDto>> CreateOrderAsync(
CreateOrderRequest request,
CancellationToken cancellationToken)
{
var validationResult = await _validator.ValidateAsync(request, cancellationToken);
if (!validationResult.IsValid)
return Result<OrderDto>.Failure(validationResult.Errors);
// ... create the order ...
return Result<OrderDto>.Success(dto);
}
}
Your controller stays focused on HTTP concerns:
[HttpPost]
public async Task<ActionResult<OrderDto>> CreateOrder(
CreateOrderRequest request,
CancellationToken cancellationToken)
{
var result = await _orderService.CreateOrderAsync(request, cancellationToken);
return result.IsSuccess
? CreatedAtAction(nameof(GetOrder), new { id = result.Value.Id }, result.Value)
: BadRequest(result.Error);
}
Validation lives in the service layer, where the business rules live. The controller stays a pure HTTP boundary — translate in, translate out. No work happens on a request that hasn't earned it.
🚨 Error Handling That Helps
Bad error response:
{
"error": "An error occurred"
}
Clients get: Nothing useful. They don't know what went wrong or how to fix it.
Good error response:
{
"type": "https://api.example.com/errors/validation-failed",
"title": "Validation Failed",
"status": 400,
"errors": {
"CustomerId": ["Customer with ID 999 does not exist"],
"Lines[0].Quantity": ["Quantity must be greater than 0"]
},
"traceId": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00"
}
Clients get: Exactly what failed, which field, how to fix it, and a trace ID for support tickets.
Problem Details Pattern
This format isn't new. [ApiController] has emitted RFC 7807 (now RFC 9457) Problem Details for client errors since ASP.NET Core 2.2 — automatic 400s already come back in this shape. What .NET 7 added is AddProblemDetails(), which extends the same format to the exception handler and status-code middleware.
Setup in Program.cs:
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
This automatically wraps all error responses in the standardized format. For custom error details from your service layer, you can still return Problem Details directly:
[HttpPost]
public async Task<ActionResult<OrderDto>> CreateOrder(
CreateOrderRequest request,
CancellationToken cancellationToken)
{
var result = await _orderService.CreateOrderAsync(request, cancellationToken);
if (!result.IsSuccess)
{
// Problem() writes application/problem+json (the RFC 7807 media type).
// BadRequest(new ProblemDetails {...}) would emit application/json instead,
// which doesn't advertise the body as Problem Details to clients.
return Problem(
title: "Order creation failed",
detail: result.Error,
statusCode: StatusCodes.Status400BadRequest);
}
return CreatedAtAction(nameof(GetOrder), new { id = result.Value.Id }, result.Value);
}
The global middleware handles unhandled exceptions and status codes automatically. For domain-specific errors from your service layer, use Problem(...) (or ValidationProblem(...) for input errors) so the response is correctly typed as application/problem+json.
⚡ Async All The Way
Synchronous blocks threads:
[HttpGet]
public List<OrderDto> GetOrders()
{
return _orderService.GetOrders(); // Blocks thread while database queries
}
Under load, this causes thread pool exhaustion. Requests queue. Site slows down or crashes.
Async releases threads:
[HttpGet]
public async Task<ActionResult<List<OrderDto>>> GetOrders(
CancellationToken cancellationToken)
{
var orders = await _orderService.GetOrdersAsync(cancellationToken);
return Ok(orders);
}
Two critical patterns:
1. async/await everywhere - Never use .Result or .Wait()
2. CancellationToken on every action - When a client disconnects, the server stops processing
Without cancellation tokens, the server wastes resources on work nobody's waiting for. Under heavy load, this compounds into serious performance issues.
🏗️ The Complete Professional Controller
Putting it all together:
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IOrderService _orderService;
public OrdersController(IOrderService orderService)
{
_orderService = orderService;
}
// ActionResult<T> — success returns a body
[HttpGet]
[ProducesResponseType(typeof(PagedResult<OrderDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResult<OrderDto>>> GetOrders(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken cancellationToken = default)
{
var orders = await _orderService.GetOrdersAsync(page, pageSize, cancellationToken);
return Ok(orders);
}
[HttpGet("{id}")]
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<OrderDto>> GetOrder(
int id,
CancellationToken cancellationToken = default)
{
var result = await _orderService.GetOrderAsync(id, cancellationToken);
if (!result.IsSuccess)
return NotFound();
return Ok(result.Value);
}
// 201 has a body, so ActionResult<OrderDto> — CreatedAtAction works fine with it
[HttpPost]
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<OrderDto>> CreateOrder(
CreateOrderRequest request,
CancellationToken cancellationToken = default)
{
var result = await _orderService.CreateOrderAsync(request, cancellationToken);
if (!result.IsSuccess)
return Problem(
title: "Order creation failed",
detail: result.Error,
statusCode: StatusCodes.Status400BadRequest);
return CreatedAtAction(
nameof(GetOrder),
new { id = result.Value.Id },
result.Value
);
}
// IActionResult — success is 204 No Content, no body
[HttpPut("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateOrder(
int id,
UpdateOrderRequest request,
CancellationToken cancellationToken = default)
{
var result = await _orderService.UpdateOrderAsync(id, request, cancellationToken);
if (!result.IsSuccess)
{
return result.Error switch
{
NotFoundError => NotFound(),
_ => BadRequest(result.Error)
};
}
return NoContent();
}
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteOrder(
int id,
CancellationToken cancellationToken = default)
{
var result = await _orderService.DeleteOrderAsync(id, cancellationToken);
return result.IsSuccess
? NoContent()
: NotFound();
}
}
Note on custom types: This controller uses three patterns that aren't built-in to .NET:
- Result<T> - Handles errors without exceptions (covered in Exception Handling That Survives Production)
- PagedResult<T> - Wraps a list with total count and page metadata — a common pattern for paginated APIs
- NotFoundError - A marker error type carried by Result<T> failures, so the controller can pattern-match errors to status codes
What this controller does right:
✅ Uses DTOs, never exposes entities
✅ Proper HTTP status codes for every scenario
✅ Async all the way with CancellationToken
✅ Pagination prevents unbounded queries
✅ Delegates to service layer (logging happens there)
✅ Documents responses with ProducesResponseType
✅ Returns proper error information
✅ No business logic in the controller
✅ No database access from controller
✅ ActionResult<T> for responses with bodies, IActionResult for 204/redirect responses
🎓 Why These Patterns Matter
Your API is a contract. Every client — mobile apps, frontend SPAs, third-party integrations, internal microservices — depends on that contract being stable, predictable, and well-documented.
When you return proper status codes, clients can handle errors programmatically. When you use DTOs, you can refactor internals without breaking the world. When you validate before any work happens, you give actionable feedback instead of cryptic database errors.
These aren't "nice to have" patterns. They're the difference between:
- An API that ships once and breaks constantly
- An API that evolves over years without breaking clients
The companies I've worked at — healthcare processing millions of claims, real estate platforms serving tens of millions of homes — they ship APIs that can't break. Mobile apps in production depend on them. Partner integrations process millions in transactions. Internal systems make critical business decisions.
You can't hotfix a mobile app.
You can't roll back a partner's integration.
You can't tell customers "sorry, the API changed."
Professional API design means backwards compatibility, predictable behavior, and clear contracts. These patterns give you that.
⚠️ Common Gotchas
1. Exposing Entities "Just This Once"
The trap:
// "It's just for internal use, we control both sides"
[HttpGet]
public async Task<List<Order>> GetOrders()
{
return await _db.Orders.ToListAsync();
}
What happens:
- Six months later, "internal use" is now powering the mobile app
- Navigation properties look harmless — until someone actually loads the object graph. Then the cycle throws server-side and every client starts eating 500s
- Database changes break clients — you add an audit field, mobile app explodes
- Security issues —
IsDeletedflag is now public information - Can't version — every entity change is a breaking API change
The fix: DTOs always. No exceptions.
2. Swallowing Errors
The trap:
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
try
{
var result = await _orderService.CreateOrderAsync(request);
return Ok(result);
}
catch
{
return BadRequest("Something went wrong");
}
}
What happens:
- Client gets "Something went wrong" (useless)
- Developer debugging at 2 AM has no idea what actually failed
- Support tickets say "Order creation doesn't work" (no trace ID, no context)
- Production issues from silent failures accumulate
The fix: Let exceptions propagate to global exception handler. Return structured errors from service layer using Result<T>.
3. Synchronous Database Calls
The trap:
[HttpGet]
public List<OrderDto> GetOrders()
{
return _orderService.GetOrders(); // Blocks thread
}
What happens:
- Under normal load, works fine
- Under high load, thread pool exhaustion causes requests to queue and site crashes
- On Black Friday, your API becomes the bottleneck that takes down the entire platform
The fix: async/await everywhere. No .Result, no .Wait().
4. No Versioning Strategy
The trap:
[Route("api/orders")] // No version
public class OrdersController : ControllerBase
{
// Change response shape whenever you want
}
What happens:
- You rename a field — mobile app v1.0 crashes (can't find it)
- You remove a field — mobile app v1.0 crashes (depends on it)
- You change a field's type — existing integrations fail
The fix: Version from day one. URL versioning is one common approach:
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/orders")]
public class OrdersV1Controller : ControllerBase
{
// V1 contract locked, never breaks existing clients
}
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/orders")]
public class OrdersV2Controller : ControllerBase
{
// V2 can evolve independently
}
Note: URL versioning shown here is one strategy. Header-based (api-version: 1.0) and query string (?api-version=1.0) are equally valid. The Asp.Versioning.Mvc package supports all three approaches.
5. Missing Documentation
The trap:
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
// What does this return? What errors? Client has to guess.
}
What happens:
- Frontend developers resort to trial and error to figure out the contract
- Integration partners can't build against your API confidently
- Support can't help clients debug issues
- Future you forgot how your own API works
The fix: Document with Swagger/OpenAPI:
/// <summary>
/// Creates a new order
/// </summary>
/// <param name="request">Order details</param>
/// <returns>Created order with assigned ID</returns>
/// <response code="201">Order created successfully</response>
/// <response code="400">Validation failed - check errors for details</response>
/// <response code="401">Authentication required</response>
[HttpPost]
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<OrderDto>> CreateOrder(CreateOrderRequest request)
{
// Implementation
}
Now Swagger generates interactive docs clients can rely on.
🧭 Key Takeaways
- Controllers are boundaries between HTTP and your application logic
- DTOs protect you from coupling your database to your public API
- HTTP status codes matter — clients need to know what happened programmatically
- Validation before work happens gives actionable feedback instead of cryptic errors — business validation lives in the service layer
- Problem Details (RFC 7807) provides standardized error responses
- Async with CancellationToken prevents wasted work and thread exhaustion
- Pagination prevents unbounded queries that fail at scale
ActionResult<T>for responses with bodies,IActionResultfor 204/redirect responses- Document your API with Swagger — teams need to discover the contract
- Version from day one — you can't unbreak existing clients
🚀 Next Steps
Review your controllers:
- Do you expose entities directly? Add DTOs
- Do you return
Ok()for everything? Use proper status codes - Do you validate requests? Add FluentValidation
- Are actions synchronous? Make them async with CancellationToken
- Do unbounded queries exist? Add pagination
- Do errors help debugging? Use built-in Problem Details (
AddProblemDetails()on .NET 7+) - Can clients discover your API? Add Swagger documentation
- What happens when you need to change the contract? Add versioning
Start with one controller. Apply these patterns. See how it changes the developer experience for teams building against your API.
In the next post: what happens when the CRUD hits the fan — timeouts, retries, circuit breakers, and building controllers that stay standing when the services they depend on go down.