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
};
}
Use It in Your Controller
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(int id)
{
var user = await _userService.GetByIdAsync(id);
if (user == null)
return NotFound(
ApiResponse<UserDto>.Fail("User not found"));
return Ok(ApiResponse<UserDto>.Ok(user));
}
Sample Response Your Frontend Always Gets
{
"success": true,
"message": "Success",
"data": {
"id": 1,
"name": "John Doe",
"email": "[email protected]"
}
}
No surprises. No inconsistency.
4. Validation with FluentValidation — Cleaner than DataAnnotations
Install the package:
dotnet add package FluentValidation.AspNetCore
Create a Validator
public class CreateUserValidator : AbstractValidator<CreateUserDto>
{
public CreateUserValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name is required")
.MaximumLength(100)
.WithMessage("Name cannot exceed 100 characters");
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress()
.WithMessage("Invalid email format");
RuleFor(x => x.Password)
.NotEmpty().WithMessage("Password is required")
.MinimumLength(8)
.WithMessage("Password must be at least 8 characters");
}
}
Register in Program.cs
builder.Services.AddValidatorsFromAssemblyContaining<CreateUserValidator>();
builder.Services.AddFluentValidationAutoValidation();
Now invalid requests are automatically rejected before they even hit your controller. Clean and declarative.
5. Logging with Serilog — Set Up in 5 Minutes
Install packages:
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
Configure in Program.cs
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.Console()
.WriteTo.File(
"logs/api-.txt",
rollingInterval: RollingInterval.Day)
.CreateLogger();
builder.Host.UseSerilog();
Use It Anywhere via DI
public class UserService
{
private readonly ILogger<UserService> _logger;
public UserService(
ILogger<UserService> logger)
{
_logger = logger;
}
public async Task<UserDto?> GetByIdAsync(int id)
{
_logger.LogInformation(
"Fetching user with ID: {UserId}",
id);
// your logic here
}
}
Logs roll daily automatically. No manual cleanup needed.
Putting It All Together
Here's what your Program.cs looks like with everything wired up:
var builder = WebApplication.CreateBuilder(args);
// Serilog
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.Console()
.WriteTo.File(
"logs/api-.txt",
rollingInterval: RollingInterval.Day)
.CreateLogger();
builder.Host.UseSerilog();
// Services
builder.Services.AddControllers();
builder.Services.AddValidatorsFromAssemblyContaining<CreateUserValidator>();
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddScoped<IUserService, UserService>();
var app = builder.Build();
// Middleware
app.UseMiddleware<ExceptionMiddleware>();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Clean. Minimal. Production-ready.
Conclusion
These 5 patterns alone will make your .NET 8 API:
✅ Easier to read and maintain
✅ Consistent in responses
✅ Safe from unhandled crashes
✅ Easy to debug with proper logging
✅ Validated before hitting business logic
You don't need a complex architecture from day one. Start with these fundamentals and your codebase will thank you later.
Jasen FiciPosted Jul 4, 2026, 1:59 PM
We featured this post in DotNetNews here: https://dotnetnews.co/archive/the-net-news-daily-issue-489/