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 UserAlreadyExistsException
is 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:
Clarity: Commands and queries have different concerns and can be optimized differently
Scalability: Read and write models can scale independently
Testing: Each handler has a single responsibility, making unit tests straightforward
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