Building a Scalable .NET 8 Web API: Clean Architecture + CQRS + JWT + PostgreSQL + Redis – A Complete Guide



This content originally appeared on DEV Community and was authored by Odumosu Matthew

Every .NET developer has been there, starting a new project with the best intentions, following SOLID principles, writing clean code. Fast forward six months, and you’re staring at a monolithic controller with 47 methods, business logic scattered across layers, and a test suite that breaks every time you breathe on the database. Sound familiar?

The problem isn’t your coding skills; it’s the architecture. Most .NET tutorials show you how to build a “simple” API that works perfectly for demos but crumbles under real-world pressure. Today, we’re going to change that.

In this comprehensive guide, we’ll build a production-ready .NET 8 Web API from the ground up using Clean Architecture, CQRS pattern, JWT authentication, PostgreSQL for persistence, and Redis for distributed caching. We’re not building another “todo app” we’re creating a bakery application that could handle real business requirements, scale to thousands of users, and remain maintainable as your team grows.

By the end of this article, you’ll have a complete understanding of how to structure enterprise-grade applications, implement proper separation of concerns, handle authentication securely, and leverage caching for optimal performance. More importantly, you’ll have working code you can use as a foundation for your next project.

Why This Architecture Matters

Traditional .NET applications often suffer from what I call “layered architecture syndrome” everything depends on everything else, making testing nearly impossible and changes risky. Clean Architecture, combined with CQRS (Command Query Responsibility Segregation), solves these problems by enforcing strict boundaries and single responsibilities.

Here’s what we’re building:

┌─────────────────────────────────┐
│         Presentation Layer       │  ← Controllers, Middleware, DTOs
│         (BakingApp.API)         │
├─────────────────────────────────┤
│         Application Layer        │  ← Use Cases, CQRS Handlers
│      (BakingApp.Application)    │    Validation, Business Logic
├─────────────────────────────────┤
│       Infrastructure Layer      │  ← Database, Caching, External
│    (BakingApp.Infrastructure)   │    Services, Authentication
├─────────────────────────────────┤
│          Domain Layer           │  ← Entities, Value Objects
│        (BakingApp.Domain)       │    Domain Rules, Exceptions
└─────────────────────────────────┘

This isn’t just theoretical, each layer has a specific job, clear boundaries, and testable components. The domain layer contains pure business logic with zero dependencies. The application layer orchestrates use cases without caring about databases or external services. The infrastructure layer handles the messy details of data persistence and external integrations.

Technology Stack Deep Dive

  • NET 8: Latest LTS version with performance improvements and new features

  • PostgreSQL: Robust, feature-rich database with excellent .NET support

  • Redis: High-performance in-memory data store for caching and sessions

  • Entity Framework Core: ORM with advanced features and performance optimizations

  • MediatR: Implements mediator pattern for CQRS and cross-cutting concerns

  • FluentValidation: Powerful validation library with expressive syntax

  • JWT Bearer Authentication: Stateless authentication perfect for APIs

  • Docker: Containerization for development and deployment consistency

Project Structure and Setup

Let’s start by creating the solution structure. This isn’t arbitrary each project represents a distinct architectural layer with specific responsibilities:

BakingApp/
├── src/
│   ├── BakingApp.Domain/           # Core business entities and rules
│   ├── BakingApp.Application/      # Use cases, CQRS, Validators
│   ├── BakingApp.Infrastructure/   # Data access, external services
│   └── BakingApp.API/             # Web API controllers
├── tests/
│   ├── BakingApp.Domain.Tests/
│   ├── BakingApp.Application.Tests/
│   └── BakingApp.API.Tests/
└── docker-compose.yml             # PostgreSQL & Redis containers

The dependency flow is crucial: Domain has no dependencies, Application depends only on Domain, Infrastructure depends on Application and Domain, and API depends on all layers. This creates a stable architecture where changes to external concerns don’t ripple through your business logic.

Domain Layer: The Heart of Clean Architecture

The domain layer is the foundation of our application, it contains the core business entities and rules with zero external dependencies. This isn’t just a data container; it’s where your business logic lives.

// BakingApp.Domain/Entities/User.cs
using System.ComponentModel.DataAnnotations;

namespace BakingApp.Domain.Entities;

public class User
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string Email { get; set; } = string.Empty;
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public string PasswordHash { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public DateTime? UpdatedAt { get; set; }
    public bool IsActive { get; set; } = true;
    public string Role { get; set; } = "Customer"; // Customer, Baker, Admin
}

Notice how the User entity is a plain C# class with business-relevant properties. We’re not inheriting from DbContext entities or implementing repository interfaces this is pure domain logic.

Domain exceptions are equally important. They express business rule violations in the language of your domain:

// BakingApp.Domain/Exceptions/DomainException.cs
namespace BakingApp.Domain.Exceptions;

public class DomainException : Exception
{
    public DomainException(string message) : base(message) { }
    public DomainException(string message, Exception innerException) : base(message, innerException) { }
}

public class UserAlreadyExistsException : DomainException
{
    public UserAlreadyExistsException(string email) 
        : base($"User with email '{email}' already exists.") { }
}

These exceptions communicate business rules clearly. When a UserAlreadyExistsExceptionis thrown, everyone on the team immediately understands what went wrong without digging through database error codes.

Application Layer: CQRS with MediatR

The application layer is where Clean Architecture really shines. Using CQRS (Command Query Responsibility Segregation), we separate read operations (queries) from write operations (commands), leading to cleaner, more maintainable code.

Understanding CQRS Benefits

CQRS isn’t just an academic pattern, it solves real problems:

  1. Clarity: Commands and queries have different concerns and can be optimized differently

  2. Scalability: Read and write models can scale independently

  3. Testing: Each handler has a single responsibility, making unit tests straightforward

  4. Performance: Queries can be optimized for reading, commands for consistency

Let’s implement user registration using the command pattern:

// BakingApp.Application/Users/Commands/RegisterUser/RegisterUserCommand.cs
using MediatR;

namespace BakingApp.Application.Users.Commands.RegisterUser;

public record RegisterUserCommand(
    string Email,
    string FirstName,
    string LastName,
    string Password,
    string ConfirmPassword
) : IRequest<RegisterUserResponse>;

public record RegisterUserResponse(
    Guid UserId,
    string Email,
    string FirstName,
    string LastName,
    string Token
);

Using records for commands and responses isn’t just trendy, it gives us immutability, structural equality, and concise syntax. The command represents intent (what the user wants to do), while the response provides the result.

FluentValidation: Enterprise-Grade Input Validation

Input validation is where many APIs fall apart in production. FluentValidation provides a fluent interface for building complex validation rules:

// BakingApp.Application/Users/Commands/RegisterUser/RegisterUserCommandValidator.cs
using FluentValidation;

namespace BakingApp.Application.Users.Commands.RegisterUser;

public class RegisterUserCommandValidator : AbstractValidator<RegisterUserCommand>
{
    public RegisterUserCommandValidator()
    {
        RuleFor(x => x.Email)
            .NotEmpty().WithMessage("Email is required")
            .EmailAddress().WithMessage("Invalid email format")
            .MaximumLength(100).WithMessage("Email must not exceed 100 characters");

        RuleFor(x => x.FirstName)
            .NotEmpty().WithMessage("First name is required")
            .MaximumLength(50).WithMessage("First name must not exceed 50 characters")
            .Matches(@"^[a-zA-Z\s]+$").WithMessage("First name can only contain letters and spaces");

        RuleFor(x => x.LastName)
            .NotEmpty().WithMessage("Last name is required")
            .MaximumLength(50).WithMessage("Last name must not exceed 50 characters")
            .Matches(@"^[a-zA-Z\s]+$").WithMessage("Last name can only contain letters and spaces");

        RuleFor(x => x.Password)
            .NotEmpty().WithMessage("Password is required")
            .MinimumLength(8).WithMessage("Password must be at least 8 characters long")
            .Matches(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]")
            .WithMessage("Password must contain at least one lowercase letter, one uppercase letter, one digit, and one special character");

        RuleFor(x => x.ConfirmPassword)
            .NotEmpty().WithMessage("Confirm password is required")
            .Equal(x => x.Password).WithMessage("Passwords do not match");
    }
}

These aren’t just basic checks, we’re enforcing real business rules. The password validation ensures complexity requirements that would satisfy most enterprise security policies. The regex patterns prevent common injection attacks and ensure data consistency.

Command Handler Implementation

The command handler is where the magic happens, it orchestrates the use case without worrying about infrastructure details:

// BakingApp.Application/Users/Commands/RegisterUser/RegisterUserCommandHandler.cs
using BakingApp.Application.Common.Interfaces;
using BakingApp.Domain.Entities;
using BakingApp.Domain.Exceptions;
using MediatR;
using Microsoft.EntityFrameworkCore;

namespace BakingApp.Application.Users.Commands.RegisterUser;

public class RegisterUserCommandHandler : IRequestHandler<RegisterUserCommand, RegisterUserResponse>
{
    private readonly IApplicationDbContext _context;
    private readonly IPasswordHasher _passwordHasher;
    private readonly IJwtTokenGenerator _jwtTokenGenerator;
    private readonly ICacheService _cacheService;

    public RegisterUserCommandHandler(
        IApplicationDbContext context,
        IPasswordHasher passwordHasher,
        IJwtTokenGenerator jwtTokenGenerator,
        ICacheService cacheService)
    {
        _context = context;
        _passwordHasher = passwordHasher;
        _jwtTokenGenerator = jwtTokenGenerator;
        _cacheService = cacheService;
    }

    public async Task<RegisterUserResponse> Handle(RegisterUserCommand request, CancellationToken cancellationToken)
    {
        // Check if user already exists
        var existingUser = await _context.Users
            .FirstOrDefaultAsync(u => u.Email.ToLower() == request.Email.ToLower(), cancellationToken);

        if (existingUser != null)
        {
            throw new UserAlreadyExistsException(request.Email);
        }

        // Create new user
        var user = new User
        {
            Email = request.Email.ToLower(),
            FirstName = request.FirstName,
            LastName = request.LastName,
            PasswordHash = _passwordHasher.HashPassword(request.Password),
            Role = "Customer"
        };

        _context.Users.Add(user);
        await _context.SaveChangesAsync(cancellationToken);

        // Generate JWT token
        var token = _jwtTokenGenerator.GenerateToken(user.Id, user.Email, user.Role);

        // Cache user info for quick lookups
        var cacheKey = $"user:{user.Id}";
        await _cacheService.SetAsync(cacheKey, new { user.Id, user.Email, user.FirstName, user.LastName, user.Role }, 
            TimeSpan.FromHours(1), cancellationToken);

        return new RegisterUserResponse(
            user.Id,
            user.Email,
            user.FirstName,
            user.LastName,
            token
        );
    }
}

This handler demonstrates several important patterns:

  • Dependency Injection: All external concerns are injected as interfaces

  • Error Handling: Business rule violations throw domain exceptions

  • Performance: User data is cached immediately after creation

  • Security: Passwords are properly hashed, emails are normalized

  • Async Operations: Everything is async for better scalability

Validation Pipeline Behavior

One of MediatR’s most powerful features is pipeline behaviors cross-cutting concerns that run before or after handlers. Our validation behavior ensures all commands are validated automatically:

// BakingApp.Application/Common/Behaviours/ValidationBehavior.cs
using FluentValidation;
using MediatR;

namespace BakingApp.Application.Common.Behaviours;

public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext<TRequest>(request);

        var validationResults = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, cancellationToken)));

        var failures = validationResults
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

        if (failures.Any())
            throw new ValidationException(failures);

        return await next();
    }
}

This behavior runs before every command handler, automatically validating input and throwing validation exceptions if rules are violated. It’s a perfect example of the decorator pattern in action.

Infrastructure Layer: The Heavy Lifting

The infrastructure layer handles all the messy details of external systems—databases, caching, authentication, and third-party services. This is where architectural boundaries really matter because changes here shouldn’t affect your business logic.

PostgreSQL with Entity Framework Core

PostgreSQL is an excellent choice for .NET applications, it’s open source, feature-rich, and has outstanding performance characteristics. Our DbContext configuration demonstrates several best practices:

// BakingApp.Infrastructure/Data/ApplicationDbContext.cs
using BakingApp.Application.Common.Interfaces;
using BakingApp.Domain.Entities;
using Microsoft.EntityFrameworkCore;

namespace BakingApp.Infrastructure.Data;

public class ApplicationDbContext : DbContext, IApplicationDbContext
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { }

    public DbSet<User> Users { get; set; } = null!;

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // User entity configuration
        modelBuilder.Entity<User>(entity =>
        {
            entity.HasKey(e => e.Id);

            entity.Property(e => e.Email)
                .IsRequired()
                .HasMaxLength(100);

            entity.Property(e => e.FirstName)
                .IsRequired()
                .HasMaxLength(50);

            entity.Property(e => e.LastName)
                .IsRequired()
                .HasMaxLength(50);

            entity.Property(e => e.PasswordHash)
                .IsRequired();

            entity.Property(e => e.Role)
                .IsRequired()
                .HasMaxLength(20)
                .HasDefaultValue("Customer");

            entity.Property(e => e.IsActive)
                .HasDefaultValue(true);

            entity.Property(e => e.CreatedAt)
                .HasDefaultValueSql("CURRENT_TIMESTAMP");

            // Create unique index on email
            entity.HasIndex(e => e.Email)
                .IsUnique()
                .HasDatabaseName("IX_Users_Email");
        });

        base.OnModelCreating(modelBuilder);
    }

    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        // Update timestamps for modified entities
        var entries = ChangeTracker.Entries<User>()
            .Where(e => e.State == EntityState.Modified);

        foreach (var entry in entries)
        {
            entry.Entity.UpdatedAt = DateTime.UtcNow;
        }

        return await base.SaveChangesAsync(cancellationToken);
    }
}

Key configuration points:

  • Explicit constraints: We define maximum lengths and required fields explicitly

  • Database defaults: Some fields have database-level defaults for consistency

  • Unique indexes: Email uniqueness is enforced at the database level

  • Automatic timestamps: Modified entities get updated timestamps automatically

JWT Authentication Implementation

JWT (JSON Web Token) authentication is perfect for stateless APIs. Our implementation includes all the security best practices you’d expect in production:

// BakingApp.Infrastructure/Authentication/JwtTokenGenerator.cs
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using BakingApp.Application.Common.Interfaces;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;

namespace BakingApp.Infrastructure.Authentication;

public class JwtSettings
{
    public string Secret { get; set; } = string.Empty;
    public string Issuer { get; set; } = string.Empty;
    public string Audience { get; set; } = string.Empty;
    public int ExpirationInHours { get; set; } = 24;
}

public class JwtTokenGenerator : IJwtTokenGenerator
{
    private readonly JwtSettings _jwtSettings;

    public JwtTokenGenerator(IOptions<JwtSettings> jwtSettings)
    {
        _jwtSettings = jwtSettings.Value;
    }

    public string GenerateToken(Guid userId, string email, string role)
    {
        var claims = new List<Claim>
        {
            new(ClaimTypes.NameIdentifier, userId.ToString()),
            new(ClaimTypes.Email, email),
            new(ClaimTypes.Role, role),
            new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
            new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64)
        };

        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret));
        var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer: _jwtSettings.Issuer,
            audience: _jwtSettings.Audience,
            claims: claims,
            expires: DateTime.UtcNow.AddHours(_jwtSettings.ExpirationInHours),
            signingCredentials: credentials
        );

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

This implementation includes:

  • Standard claims: User ID, email, and role for authorization

  • JWT ID: Unique identifier for token tracking and revocation

  • Issued at time: For audit trails and time-based validation

  • Configurable expiration: Different environments can have different token lifespans

  • Strong signing: HMAC SHA-256 with a sufficiently long secret

Redis Distributed Caching

Distributed caching is essential for scalable applications. Redis provides high-performance caching with features like data persistence, pub/sub, and various data structures:

// BakingApp.Infrastructure/Caching/CacheService.cs
using System.Text.Json;
using BakingApp.Application.Common.Interfaces;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;

namespace BakingApp.Infrastructure.Caching;

public class CacheService : ICacheService
{
    private readonly IDistributedCache _distributedCache;
    private readonly ILogger<CacheService> _logger;
    private readonly JsonSerializerOptions _jsonOptions;

    public CacheService(IDistributedCache distributedCache, ILogger<CacheService> logger)
    {
        _distributedCache = distributedCache;
        _logger = logger;
        _jsonOptions = new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
            WriteIndented = false
        };
    }

    public async Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default) where T : class
    {
        try
        {
            var cached = await _distributedCache.GetStringAsync(key, cancellationToken);

            if (string.IsNullOrEmpty(cached))
                return null;

            return JsonSerializer.Deserialize<T>(cached, _jsonOptions);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error retrieving data from cache for key: {Key}", key);
            return null;
        }
    }

    public async Task SetAsync<T>(string key, T value, TimeSpan? expiration = null, CancellationToken cancellationToken = default) where T : class
    {
        try
        {
            var options = new DistributedCacheEntryOptions();

            if (expiration.HasValue)
                options.SetSlidingExpiration(expiration.Value);
            else
                options.SetSlidingExpiration(TimeSpan.FromMinutes(30)); // Default 30 minutes

            var serialized = JsonSerializer.Serialize(value, _jsonOptions);
            await _distributedCache.SetStringAsync(key, serialized, options, cancellationToken);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error storing data in cache for key: {Key}", key);
        }
    }

    public async Task RemoveAsync(string key, CancellationToken cancellationToken = default)
    {
        try
        {
            await _distributedCache.RemoveAsync(key, cancellationToken);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error removing data from cache for key: {Key}", key);
        }
    }
}

The cache service demonstrates several important patterns:

  • Graceful failure: Cache errors don’t break the application—they’re logged and handled

  • Configurable expiration: Different data can have different cache lifetimes

  • Generic implementation: Works with any serializable object

  • Consistent JSON serialization: Uses camelCase for JavaScript compatibility

Password Hashing Security

Password security is non-negotiable in production applications. We use ASP.NET Core Identity’s password hasher, which implements industry best practices:

// BakingApp.Infrastructure/Authentication/PasswordHasher.cs
using BakingApp.Application.Common.Interfaces;
using Microsoft.AspNetCore.Identity;

namespace BakingApp.Infrastructure.Authentication;

public class PasswordHasher : IPasswordHasher
{
    private readonly IPasswordHasher<object> _passwordHasher;

    public PasswordHasher()
    {
        _passwordHasher = new PasswordHasher<object>();
    }

    public string HashPassword(string password)
    {
        return _passwordHasher.HashPassword(new object(), password);
    }

    public bool VerifyPassword(string password, string hashedPassword)
    {
        var result = _passwordHasher.VerifyHashedPassword(new object(), hashedPassword, password);
        return result == PasswordVerificationResult.Success || 
               result == PasswordVerificationResult.SuccessRehashNeeded;
    }
}

This implementation uses PBKDF2 with SHA-256, includes salt generation, and supports hash upgrades—exactly what you need for enterprise security.

API Layer: Bringing It All Together

The API layer is the entry point to our application. It’s responsible for HTTP concerns, routing, model binding, authentication, and response formatting, while delegating business logic to the application layer.

Controller Implementation

Our controllers are thin by design—they validate input, delegate to handlers, and format responses:

// BakingApp.API/Controllers/AuthController.cs
using BakingApp.Application.Users.Commands.LoginUser;
using BakingApp.Application.Users.Commands.RegisterUser;
using BakingApp.Domain.Exceptions;
using MediatR;
using Microsoft.AspNetCore.Mvc;

namespace BakingApp.API.Controllers;

[Route("api/[controller]")]
[ApiController]
public class AuthController : ControllerBase
{
    private readonly IMediator _mediator;

    public AuthController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost("register")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
    public async Task<IActionResult> Register([FromBody] RegisterUserCommand command)
    {
        var result = await _mediator.Send(command);
        return Ok(new
        {
            success = true,
            message = "User registered successfully",
            data = result
        });
    }

    [HttpPost("login")]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
    public async Task<IActionResult> Login([FromBody] LoginUserCommand command)
    {
        var result = await _mediator.Send(command);
        return Ok(new
        {
            success = true,
            message = "Login successful",
            data = result
        });
    }
}

Global Exception Handling

Exception handling middleware provides consistent error responses across the entire API:

// BakingApp.API/Middleware/ExceptionHandlingMiddleware.cs
using BakingApp.Domain.Exceptions;
using FluentValidation;
using System.Net;
using System.Text.Json;

namespace BakingApp.API.Middleware;

public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;

    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
    {
        _logger = logger;
    }

    public async ValueTask<bool> TryHandleAsync(
        HttpContext httpContext,
        Exception exception,
        CancellationToken cancellationToken)
    {
        _logger.LogError(exception, "An unhandled exception occurred: {Message}", exception.Message);

        // This ProblemDetails object will be the foundation for our response
        var problemDetails = new ProblemDetails
        {
            Instance = $"{httpContext.Request.Method} {httpContext.Request.Path}"
        };

        // Map exceptions to status codes and titles
        (problemDetails.Status, problemDetails.Title, problemDetails.Detail) = exception switch
        {
            DomainException domainEx =>
                ((int)HttpStatusCode.BadRequest, "Bad Request", domainEx.Message),

            ValidationException validationEx =>
                ((int)HttpStatusCode.BadRequest, "Validation Error", "One or more validation errors occurred."),

            UnauthorizedAccessException unauthorizedEx =>
                ((int)HttpStatusCode.Unauthorized, "Unauthorized", unauthorizedEx.Message),

            _ =>
                ((int)HttpStatusCode.InternalServerError, "An internal server error has occurred.", "Please try again later.")
        };

        // For validation exceptions, we can add the specific errors to the extensions
        if (exception is ValidationException validationException)
        {
            problemDetails.Extensions["errors"] = validationException.Errors
                .Select(e => new { property = e.PropertyName, error = e.ErrorMessage });
        }

        httpContext.Response.StatusCode = problemDetails.Status.Value;

        await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);

        // Return true to signal that the exception has been handled.
        return true;
    }
}

This middleware ensures that:

  • Validation errors return 400 with detailed field-level errors

  • Domain exceptions return 400 with business-friendly messages

  • Authorization failures return 401 with clear messaging

  • Unexpected errors return 500 without exposing internal details

PIPELINE CONFIGURATION

// program.cs
using FluentValidation;
using MediatR;
using MyBank.API.Middleware;
using MyBank.Application.Behaviours;
using MyBank.Infrastructure;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

// Application Layer
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
builder.Services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly);
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

// Add Infrastructure Layer
builder.Services.AddInfrastructure(builder.Configuration);

// Add CORS
builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowAll", builder =>
    {
        builder.AllowAnyOrigin()
               .AllowAnyMethod()
               .AllowAnyHeader();
    });
});

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}


app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

Infrastructure Setup: Docker Compose

Development environment setup should be effortless. Our Docker Compose configuration provides PostgreSQL and Redis with a single command:

# docker-compose.yml
version: '3.8'

services:
  postgres:
    image: postgres:15-alpine
    container_name: bakingapp-postgres
    environment:
      POSTGRES_DB: bakingapp_db
      POSTGRES_USER: bakingapp_user
      POSTGRES_PASSWORD: bakingapp_password123
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - bakingapp-network

  redis:
    image: redis:7-alpine
    container_name: bakingapp-redis
    command: redis-server --appendonly yes --requirepass redis_password123
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    networks:
      - bakingapp-network

volumes:
  postgres_data:
  redis_data:

networks:
  bakingapp-network:
    driver: bridge

This configuration provides:

  • PostgreSQL 15: Latest stable version with excellent performance

  • Redis 7: Latest version with persistence enabled

  • Data persistence: Volumes ensure data survives container restarts

  • Network isolation: Services communicate on a private network

  • Security: Password protection for both services

Configuration and Secrets Management

Production applications need flexible configuration management. Our approach separates development and production settings while keeping secrets secure:

{
  "ConnectionStrings": {
    "DefaultConnection": "Host=localhost;Database=bakingapp_db;Username=bakingapp_user;Password=bakingapp_password123",
    "Redis": "localhost:6379,password=redis_password123"
  },
  "JwtSettings": {
    "Secret": "ThisIsAVerySecureSecretKeyForJWTTokenGenerationThatIs32CharactersLong!",
    "Issuer": "BakingApp",
    "Audience": "Bak

GITHUB RESPOSITORY: GitHub

LinkedIn Account : LinkedIn
Twitter Account: Twitter
Credit: Graphics sourced from Dev.To


This content originally appeared on DEV Community and was authored by Odumosu Matthew