Building Professional WebAPI Controllers
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:
- Mobile app crashes when the order doesn't exist (null reference)
- Frontend caches forever because no cache headers
- Support doesn't know why orders fail to create (no validation feedback)
- Database contains half-completed orders (no transactions)
- Order confirmation emails sometimes don't send (no error handling)
- Navigation properties cause circular reference errors that crash JSON serialization
- 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 - Navigation properties cause circular reference errors that crash JSON serialization
- 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<IActionResult> 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 ResultIsSuccess, Value, and Error properties.
✅ Validation at the Boundary
Controllers validate requests before executing logic.
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);
// Process valid request
}
}
Your controller stays focused on HTTP concerns:
[HttpPost]
public async Task<IActionResult> 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);
}
This keeps validation logic in the service layer where it belongs, maintaining the boundary principle.
🚨 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-abc123-def456-00"
}
Clients get: Exactly what failed, which field, how to fix it, and a trace ID for support tickets.
Problem Details Pattern (.NET 7+)
.NET 7 and later have built-in Problem Details support that automatically formats errors according to RFC 9457.
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<IActionResult> CreateOrder(
CreateOrderRequest request,
CancellationToken cancellationToken)
{
var result = await _orderService.CreateOrderAsync(request, cancellationToken);
if (!result.IsSuccess)
{
return BadRequest(new ProblemDetails
{
Title = "Order creation failed",
Detail = result.Error,
Status = StatusCodes.Status400BadRequest
});
}
return CreatedAtAction(nameof(GetOrder), new { id = result.Value.Id }, result.Value);
}
The global middleware handles unhandled exceptions and status codes automatically. You only need to construct Problem Details for domain-specific errors from your service layer.
⚡ 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;
}
/// <summary>
/// Returns ActionResult<T> when success path returns a body
/// </summary>
[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);
}
/// <summary>
/// Returns IActionResult when using CreatedAtAction
/// (response type is inferred from the body parameter)
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateOrder(
CreateOrderRequest request,
CancellationToken cancellationToken = default)
{
var result = await _orderService.CreateOrderAsync(request, cancellationToken);
if (!result.IsSuccess)
return BadRequest(result.Error);
return CreatedAtAction(
nameof(GetOrder),
new { id = result.Value.Id },
result.Value
);
}
/// <summary>
/// Returns IActionResult when success is 204 No Content (no body)
/// </summary>
[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 two patterns that aren't built-in to .NET:
- Result<T> - Handles errors without exceptions (covered in the next post on error handling)
- PagedResult<T> - Wraps a list with total count and page metadata — a common pattern for paginated APIs
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
🎓 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 at the boundary, 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 serialize, causing circular references that crash clients
- 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<IActionResult> 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 at the boundary gives actionable feedback instead of cryptic errors
- Problem Details (RFC 9457) provides standardized error responses
- Async with CancellationToken prevents wasted work and thread exhaustion
- Pagination prevents unbounded queries that fail at scale
- ActionResult
for responses with bodies, IActionResult for 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 (.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: exception handling in production — why exceptions for control flow fail audits, how Result<T> patterns prevent it, and building error handling that survives regulatory review.