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:

  1. Accept the request

  2. Call a service

  3. 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:

You don't need a complex architecture from day one. Start with these fundamentals and your codebase will thank you later.