Introduction
After 5+ years of working with .NET, I've seen a pattern: most REST APIs work, but they're hard to maintain, hard to test, and painful to hand over to another developer.
Controllers stuffed with business logic. No consistent error responses. No logging. No validation structure.
In this article, I'll show you the practical patterns I use to build clean, production-ready REST APIs in .NET 8 — patterns you can apply to your existing or new projects starting today.
1. Project Structure — Organize Before You Code
A clean API starts with a clean folder structure. Here's what I follow:
MyApi/
├── Controllers/
├── Services/
│ ├── Interfaces/
│ └── Implementations/
├── Repositories/
│ ├── Interfaces/
│ └── Implementations/
├── Models/
│ ├── Entities/
│ ├── DTOs/
│ └── Responses/
├── Middleware/
└── Extensions/
Golden Rule: Controllers should only do 3 things:
Accept the request
Call a service
Return a response
No business logic. Ever.
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(int id)
{
var result = await _userService.GetByIdAsync(id);
return Ok(result);
}
That's it. Clean, readable, testable.
2. Global Exception Handling — One Place for All Errors
Instead of wrapping every method in try-catch, handle all exceptions in one middleware.
Create ExceptionMiddleware.cs
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionMiddleware> _logger;
public ExceptionMiddleware(
RequestDelegate next,
ILogger<ExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception occurred.");
await HandleExceptionAsync(context, ex);
}
}
private static Task HandleExceptionAsync(
HttpContext context,
Exception ex)
{
context.Response.ContentType = "application/json";
context.Response.StatusCode = ex switch
{
KeyNotFoundException => StatusCodes.Status404NotFound,
UnauthorizedAccessException => StatusCodes.Status401Unauthorized,
_ => StatusCodes.Status500InternalServerError
};
var response = new ApiResponse<string>
{
Success = false,
Message = ex.Message,
Data = null
};
return context.Response.WriteAsJsonAsync(response);
}
}
Register in Program.cs
app.UseMiddleware<ExceptionMiddleware>();
Now every unhandled exception returns a clean JSON response — no ugly stack traces to the client.
3. Response Wrapper Pattern — Consistent API Responses
Every API endpoint should return the same response shape. This makes frontend integration predictable.
Create ApiResponse.cs
public class ApiResponse<T>
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public T? Data { get; set; }
public static ApiResponse<T> Ok(
T data,
string message = "Success")
=> new()
{
Success = true,
Message = message,
Data = data
};
public static ApiResponse<T> Fail(string message)
=> new()
{
Success = false,
Message = message,
Data = default
};
}
Join the conversation! Your thoughts help the community grow.