| name | dotnet-minimal-api-templates |
| description | Create production-ready ASP.NET Core Minimal API projects with async patterns, dependency injection, and comprehensive error handling. Use when building new .NET API applications or setting up backend API projects with .NET 8+. |
.NET Minimal API Project Templates
Production-ready ASP.NET Core Minimal API project structures with async patterns, dependency injection, middleware, and best practices for building high-performance APIs.
When to Use This Skill
- Starting new .NET API projects from scratch
- Implementing async REST APIs with C#
- Building high-performance web services and microservices
- Creating async applications with SQL Server, PostgreSQL, MongoDB
- Setting up API projects with proper structure and testing
- Migrating from Controller-based APIs to Minimal APIs
Core Concepts
1. Project Structure
Recommended Layout:
src/
├── MyApi.Api/ # API layer
│ ├── Endpoints/
│ │ ├── Users/
│ │ │ ├── CreateUser.cs
│ │ │ ├── GetUser.cs
│ │ │ └── UpdateUser.cs
│ │ └── Orders/
│ │ ├── CreateOrder.cs
│ │ └── GetOrders.cs
│ ├── Filters/
│ │ ├── ValidationFilter.cs
│ │ └── ExceptionFilter.cs
│ ├── Extensions/
│ │ └── EndpointExtensions.cs
│ └── Program.cs
├── MyApi.Application/ # Business logic
│ ├── Commands/
│ │ └── CreateUserCommand.cs
│ ├── Queries/
│ │ └── GetUserQuery.cs
│ ├── Handlers/
│ │ └── CreateUserHandler.cs
│ └── DTOs/
│ └── UserDto.cs
├── MyApi.Domain/ # Domain entities
│ ├── Entities/
│ │ ├── User.cs
│ │ └── Order.cs
│ └── Interfaces/
│ └── IUserRepository.cs
└── MyApi.Infrastructure/ # Data access
├── Data/
│ └── ApplicationDbContext.cs
├── Repositories/
│ └── UserRepository.cs
└── Configurations/
└── UserConfiguration.cs
2. Dependency Injection
ASP.NET Core's built-in DI system:
- Scoped services for database contexts
- Singleton services for caching
- Transient services for lightweight operations
- Keyed services (new in .NET 8)
3. Async Patterns
Proper async/await usage:
- Async endpoint handlers
- Async database operations (EF Core)
- Async HTTP clients
- Background services
Implementation Patterns
Pattern 1: Complete Minimal API Application
// Program.cs
using Microsoft.EntityFrameworkCore;
using MyApi.Infrastructure.Data;
var builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Database
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
// CORS
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.WithOrigins("http://localhost:3000")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
// Authentication
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
// Application services
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
builder.Services.AddScoped<IUserRepository, UserRepository>();
var app = builder.Build();
// Middleware pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
// Map endpoints
app.MapUserEndpoints();
app.MapOrderEndpoints();
app.Run();
// Make Program accessible for testing
public partial class Program { }
// Extensions/EndpointExtensions.cs
public static class UserEndpointExtensions
{
public static RouteGroupBuilder MapUserEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/users")
.WithTags("Users")
.WithOpenApi();
group.MapGet("/", GetUsers)
.WithName("GetUsers")
.Produces<PagedResult<UserDto>>(StatusCodes.Status200OK);
group.MapGet("/{id:guid}", GetUserById)
.WithName("GetUserById")
.Produces<UserDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/", CreateUser)
.WithName("CreateUser")
.Produces<UserDto>(StatusCodes.Status201Created)
.ProducesValidationProblem();
group.MapPut("/{id:guid}", UpdateUser)
.WithName("UpdateUser")
.Produces<UserDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization();
group.MapDelete("/{id:guid}", DeleteUser)
.WithName("DeleteUser")
.Produces(StatusCodes.Status204NoContent)
.RequireAuthorization("AdminOnly");
return group;
}
private static async Task<IResult> GetUsers(
[AsParameters] PaginationParams pagination,
IMediator mediator,
CancellationToken ct)
{
var query = new GetUsersQuery(pagination.Page, pagination.PageSize);
var result = await mediator.Send(query, ct);
return Results.Ok(result);
}
private static async Task<IResult> GetUserById(
Guid id,
IMediator mediator,
CancellationToken ct)
{
var query = new GetUserByIdQuery(id);
var result = await mediator.Send(query, ct);
return result is not null
? Results.Ok(result)
: Results.NotFound();
}
private static async Task<IResult> CreateUser(
CreateUserRequest request,
IMediator mediator,
CancellationToken ct)
{
var command = new CreateUserCommand(request.Email, request.Name);
var result = await mediator.Send(command, ct);
return Results.Created($"/api/users/{result.Id}", result);
}
private static async Task<IResult> UpdateUser(
Guid id,
UpdateUserRequest request,
IMediator mediator,
CancellationToken ct)
{
var command = new UpdateUserCommand(id, request.Name, request.Email);
var result = await mediator.Send(command, ct);
return result is not null
? Results.Ok(result)
: Results.NotFound();
}
private static async Task<IResult> DeleteUser(
Guid id,
IMediator mediator,
CancellationToken ct)
{
var command = new DeleteUserCommand(id);
await mediator.Send(command, ct);
return Results.NoContent();
}
}
public record PaginationParams(int Page = 1, int PageSize = 20);
public record CreateUserRequest(string Email, string Name);
public record UpdateUserRequest(string Name, string Email);
Pattern 2: CQRS with MediatR
// Application/Commands/CreateUserCommand.cs
public record CreateUserCommand(string Email, string Name) : IRequest<UserDto>;
// Application/Handlers/CreateUserHandler.cs
public class CreateUserHandler : IRequestHandler<CreateUserCommand, UserDto>
{
private readonly IUserRepository _repository;
private readonly ILogger<CreateUserHandler> _logger;
public CreateUserHandler(
IUserRepository repository,
ILogger<CreateUserHandler> logger)
{
_repository = repository;
_logger = logger;
}
public async Task<UserDto> Handle(
CreateUserCommand request,
CancellationToken cancellationToken)
{
// Validate
if (await _repository.ExistsByEmailAsync(request.Email, cancellationToken))
{
throw new ValidationException("Email already exists");
}
// Create entity
var user = new User
{
Id = Guid.NewGuid(),
Email = request.Email,
Name = request.Name,
CreatedAt = DateTime.UtcNow
};
// Save
await _repository.AddAsync(user, cancellationToken);
_logger.LogInformation("User created: {UserId}", user.Id);
// Return DTO
return new UserDto(user.Id, user.Email, user.Name);
}
}
// Application/Queries/GetUserByIdQuery.cs
public record GetUserByIdQuery(Guid Id) : IRequest<UserDto?>;
public class GetUserByIdHandler : IRequestHandler<GetUserByIdQuery, UserDto?>
{
private readonly IUserRepository _repository;
public GetUserByIdHandler(IUserRepository repository)
{
_repository = repository;
}
public async Task<UserDto?> Handle(
GetUserByIdQuery request,
CancellationToken cancellationToken)
{
var user = await _repository.GetByIdAsync(request.Id, cancellationToken);
return user is not null
? new UserDto(user.Id, user.Email, user.Name)
: null;
}
}
Pattern 3: Repository Pattern with EF Core
// Domain/Interfaces/IUserRepository.cs
public interface IUserRepository
{
Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<User?> GetByEmailAsync(string email, CancellationToken ct = default);
Task<PagedResult<User>> GetPagedAsync(
int page,
int pageSize,
CancellationToken ct = default);
Task<bool> ExistsByEmailAsync(string email, CancellationToken ct = default);
Task AddAsync(User user, CancellationToken ct = default);
Task UpdateAsync(User user, CancellationToken ct = default);
Task DeleteAsync(Guid id, CancellationToken ct = default);
}
// Infrastructure/Repositories/UserRepository.cs
public class UserRepository : IUserRepository
{
private readonly ApplicationDbContext _context;
public UserRepository(ApplicationDbContext context)
{
_context = context;
}
public async Task<User?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
return await _context.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == id, ct);
}
public async Task<User?> GetByEmailAsync(string email, CancellationToken ct = default)
{
return await _context.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Email == email, ct);
}
public async Task<PagedResult<User>> GetPagedAsync(
int page,
int pageSize,
CancellationToken ct = default)
{
var query = _context.Users.AsNoTracking();
var total = await query.CountAsync(ct);
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);
return new PagedResult<User>
{
Items = items,
Total = total,
Page = page,
PageSize = pageSize
};
}
public async Task<bool> ExistsByEmailAsync(string email, CancellationToken ct = default)
{
return await _context.Users
.AnyAsync(u => u.Email == email, ct);
}
public async Task AddAsync(User user, CancellationToken ct = default)
{
await _context.Users.AddAsync(user, ct);
await _context.SaveChangesAsync(ct);
}
public async Task UpdateAsync(User user, CancellationToken ct = default)
{
_context.Users.Update(user);
await _context.SaveChangesAsync(ct);
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
await _context.Users
.Where(u => u.Id == id)
.ExecuteDeleteAsync(ct);
}
}
Pattern 4: Authentication & Authorization
// appsettings.json
{
"Jwt": {
"Key": "your-secret-key-here-min-32-chars",
"Issuer": "your-api",
"Audience": "your-clients",
"ExpiryMinutes": 60
}
}
// Program.cs - JWT configuration
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireRole("Admin"));
options.AddPolicy("UserOrAdmin", policy =>
policy.RequireRole("User", "Admin"));
});
// Auth endpoints
app.MapPost("/api/auth/login", async (
LoginRequest request,
IUserRepository repository,
IConfiguration config) =>
{
// Validate credentials
var user = await repository.GetByEmailAsync(request.Email);
if (user is null || !VerifyPassword(request.Password, user.PasswordHash))
{
return Results.Unauthorized();
}
// Generate token
var token = GenerateJwtToken(user, config);
return Results.Ok(new { Token = token });
})
.WithName("Login")
.WithOpenApi();
static string GenerateJwtToken(User user, IConfiguration config)
{
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(config["Jwt:Key"]!));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Name, user.Name),
new Claim(ClaimTypes.Role, user.Role)
};
var token = new JwtSecurityToken(
issuer: config["Jwt:Issuer"],
audience: config["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(
int.Parse(config["Jwt:ExpiryMinutes"]!)),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
record LoginRequest(string Email, string Password);
Pattern 5: Endpoint Filters (Middleware)
// Filters/ValidationFilter.cs
public class ValidationFilter<T> : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var requestBody = context.Arguments
.OfType<T>()
.FirstOrDefault();
if (requestBody is null)
{
return Results.BadRequest("Request body is required");
}
// Validate using FluentValidation or DataAnnotations
var validationResults = new List<ValidationResult>();
var validationContext = new ValidationContext(requestBody);
if (!Validator.TryValidateObject(
requestBody,
validationContext,
validationResults,
validateAllProperties: true))
{
var errors = validationResults
.Select(v => new { Field = v.MemberNames.First(), Error = v.ErrorMessage })
.ToList();
return Results.ValidationProblem(errors.ToDictionary(
e => e.Field,
e => new[] { e.Error }));
}
return await next(context);
}
}
// Usage
group.MapPost("/", CreateUser)
.AddEndpointFilter<ValidationFilter<CreateUserRequest>>();
// Global exception handling filter
public class ExceptionHandlingFilter : IEndpointFilter
{
private readonly ILogger<ExceptionHandlingFilter> _logger;
public ExceptionHandlingFilter(ILogger<ExceptionHandlingFilter> logger)
{
_logger = logger;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
try
{
return await next(context);
}
catch (ValidationException ex)
{
_logger.LogWarning(ex, "Validation error");
return Results.BadRequest(new { Error = ex.Message });
}
catch (NotFoundException ex)
{
_logger.LogWarning(ex, "Resource not found");
return Results.NotFound(new { Error = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception");
return Results.Problem("An error occurred processing your request");
}
}
}
Pattern 6: API Versioning
// Install: Asp.Versioning.Http
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
// Version 1 endpoints
var v1 = app.NewVersionedApi("Users")
.MapGroup("/api/v{version:apiVersion}/users")
.HasApiVersion(1.0);
v1.MapGet("/", GetUsersV1);
// Version 2 endpoints
var v2 = app.NewVersionedApi("Users")
.MapGroup("/api/v{version:apiVersion}/users")
.HasApiVersion(2.0);
v2.MapGet("/", GetUsersV2);
Pattern 7: Response Caching
builder.Services.AddOutputCache(options =>
{
options.AddBasePolicy(builder =>
builder.Expire(TimeSpan.FromMinutes(1)));
options.AddPolicy("UserCache", builder =>
builder.Expire(TimeSpan.FromMinutes(5))
.SetVaryByQuery("page", "pageSize"));
});
app.UseOutputCache();
// Apply caching
group.MapGet("/", GetUsers)
.CacheOutput("UserCache");
Pattern 8: Result Pattern (Error Handling)
// Install: ErrorOr, FluentResults, or custom implementation
// Custom Result implementation
public class Result<T>
{
public T? Value { get; }
public bool IsSuccess { get; }
public Error? Error { get; }
private Result(T value)
{
Value = value;
IsSuccess = true;
Error = null;
}
private Result(Error error)
{
Value = default;
IsSuccess = false;
Error = error;
}
public static Result<T> Success(T value) => new(value);
public static Result<T> Failure(Error error) => new(error);
public TResult Match<TResult>(
Func<T, TResult> onSuccess,
Func<Error, TResult> onFailure)
{
return IsSuccess ? onSuccess(Value!) : onFailure(Error!);
}
}
public record Error(string Code, string Description);
// Usage in handlers
public class CreateUserHandler : IRequestHandler<CreateUserCommand, Result<UserDto>>
{
private readonly IUserRepository _repository;
public async Task<Result<UserDto>> Handle(
CreateUserCommand request,
CancellationToken cancellationToken)
{
// Validate
if (await _repository.ExistsByEmailAsync(request.Email, cancellationToken))
{
return Result<UserDto>.Failure(new Error(
"User.DuplicateEmail",
"A user with this email already exists"));
}
// Create user
var user = new User
{
Id = Guid.NewGuid(),
Email = request.Email,
Name = request.Name,
CreatedAt = DateTime.UtcNow
};
await _repository.AddAsync(user, cancellationToken);
var userDto = new UserDto(user.Id, user.Email, user.Name);
return Result<UserDto>.Success(userDto);
}
}
// Endpoint with Result pattern
app.MapPost("/api/users", async (
CreateUserRequest request,
IMediator mediator,
CancellationToken ct) =>
{
var command = new CreateUserCommand(request.Email, request.Name);
var result = await mediator.Send(command, ct);
return result.Match(
onSuccess: user => Results.Created($"/api/users/{user.Id}", user),
onFailure: error => Results.BadRequest(new { error.Code, error.Description }));
});
// Using ErrorOr library (recommended)
// Install: ErrorOr
using ErrorOr;
public class CreateUserHandler : IRequestHandler<CreateUserCommand, ErrorOr<UserDto>>
{
public async Task<ErrorOr<UserDto>> Handle(
CreateUserCommand request,
CancellationToken cancellationToken)
{
if (await _repository.ExistsByEmailAsync(request.Email, cancellationToken))
{
return Error.Conflict(
code: "User.DuplicateEmail",
description: "A user with this email already exists");
}
var user = new User
{
Id = Guid.NewGuid(),
Email = request.Email,
Name = request.Name
};
await _repository.AddAsync(user, cancellationToken);
return new UserDto(user.Id, user.Email, user.Name);
}
}
// Extension for converting ErrorOr to IResult
public static class ErrorOrExtensions
{
public static IResult ToHttpResult<T>(this ErrorOr<T> result)
{
return result.Match(
value => Results.Ok(value),
errors => ToProblemDetails(errors));
}
private static IResult ToProblemDetails(List<Error> errors)
{
var firstError = errors[0];
var statusCode = firstError.Type switch
{
ErrorType.Conflict => StatusCodes.Status409Conflict,
ErrorType.Validation => StatusCodes.Status400BadRequest,
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Unauthorized => StatusCodes.Status401Unauthorized,
_ => StatusCodes.Status500InternalServerError
};
return Results.Problem(
statusCode: statusCode,
title: firstError.Code,
detail: firstError.Description);
}
}
Pattern 9: FluentValidation Integration
// Install: FluentValidation.DependencyInjectionExtensions
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
// Validator
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserCommandValidator()
{
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
.MaximumLength(255);
RuleFor(x => x.Name)
.NotEmpty()
.MinimumLength(2)
.MaximumLength(100);
}
}
// Validation behavior for MediatR
public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
where TResponse : IErrorOr
{
private readonly IValidator<TRequest>? _validator;
public ValidationBehavior(IValidator<TRequest>? validator = null)
{
_validator = validator;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (_validator is null)
{
return await next();
}
var validationResult = await _validator.ValidateAsync(request, cancellationToken);
if (validationResult.IsValid)
{
return await next();
}
var errors = validationResult.Errors
.Select(failure => Error.Validation(
code: failure.PropertyName,
description: failure.ErrorMessage))
.ToList();
return (dynamic)errors;
}
}
// Register behavior
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
// Endpoint filter alternative
public class FluentValidationFilter<T> : IEndpointFilter where T : class
{
private readonly IValidator<T> _validator;
public FluentValidationFilter(IValidator<T> validator)
{
_validator = validator;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var requestBody = context.Arguments.OfType<T>().FirstOrDefault();
if (requestBody is null)
{
return Results.BadRequest("Request body is required");
}
var validationResult = await _validator.ValidateAsync(requestBody);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(
validationResult.ToDictionary());
}
return await next(context);
}
}
Pattern 10: Mapster for Object Mapping
// Install: Mapster, Mapster.DependencyInjection
builder.Services.AddMapster();
// Configure mappings
public class MappingConfig : IRegister
{
public void Register(TypeAdapterConfig config)
{
// Simple mapping
config.NewConfig<User, UserDto>();
// Custom mapping
config.NewConfig<User, UserDetailDto>()
.Map(dest => dest.FullName, src => $"{src.FirstName} {src.LastName}")
.Map(dest => dest.OrderCount, src => src.Orders.Count);
// Ignore properties
config.NewConfig<CreateUserRequest, User>()
.Ignore(dest => dest.Id)
.Ignore(dest => dest.CreatedAt);
// After mapping
config.NewConfig<User, UserDto>()
.AfterMapping((src, dest) =>
{
dest.DisplayName = dest.Name.ToUpper();
});
}
}
// Usage in handlers
public class GetUserByIdHandler : IRequestHandler<GetUserByIdQuery, ErrorOr<UserDetailDto>>
{
private readonly IUserRepository _repository;
private readonly IMapper _mapper;
public GetUserByIdHandler(IUserRepository repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<ErrorOr<UserDetailDto>> Handle(
GetUserByIdQuery request,
CancellationToken cancellationToken)
{
var user = await _repository.GetByIdAsync(request.Id, cancellationToken);
if (user is null)
{
return Error.NotFound(
code: "User.NotFound",
description: $"User with ID {request.Id} was not found");
}
// Map with Mapster
var userDto = _mapper.Map<UserDetailDto>(user);
return userDto;
}
}
// ProjectToType for query optimization
public async Task<PagedResult<UserDto>> GetPagedAsync(
int page,
int pageSize,
CancellationToken ct)
{
var query = _context.Users.AsNoTracking();
var total = await query.CountAsync(ct);
// Project directly to DTO in database query
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ProjectToType<UserDto>()
.ToListAsync(ct);
return new PagedResult<UserDto>
{
Items = items,
Total = total,
Page = page,
PageSize = pageSize
};
}
Pattern 11: EF Core Specification Pattern
// Base specification
public abstract class Specification<T>
{
public Expression<Func<T, bool>>? Criteria { get; private set; }
public List<Expression<Func<T, object>>> Includes { get; } = new();
public List<string> IncludeStrings { get; } = new();
public Expression<Func<T, object>>? OrderBy { get; private set; }
public Expression<Func<T, object>>? OrderByDescending { get; private set; }
public int? Take { get; private set; }
public int? Skip { get; private set; }
public bool AsNoTracking { get; private set; } = true;
protected void AddCriteria(Expression<Func<T, bool>> criteria)
{
Criteria = criteria;
}
protected void AddInclude(Expression<Func<T, object>> includeExpression)
{
Includes.Add(includeExpression);
}
protected void AddInclude(string includeString)
{
IncludeStrings.Add(includeString);
}
protected void ApplyOrderBy(Expression<Func<T, object>> orderByExpression)
{
OrderBy = orderByExpression;
}
protected void ApplyOrderByDescending(Expression<Func<T, object>> orderByDescendingExpression)
{
OrderByDescending = orderByDescendingExpression;
}
protected void ApplyPaging(int skip, int take)
{
Skip = skip;
Take = take;
}
protected void EnableTracking()
{
AsNoTracking = false;
}
}
// Concrete specifications
public class ActiveUsersSpecification : Specification<User>
{
public ActiveUsersSpecification()
{
AddCriteria(u => u.IsActive);
ApplyOrderBy(u => u.Name);
}
}
public class UserByEmailSpecification : Specification<User>
{
public UserByEmailSpecification(string email)
{
AddCriteria(u => u.Email == email);
AddInclude(u => u.Orders);
}
}
public class UserOrdersSpecification : Specification<User>
{
public UserOrdersSpecification(Guid userId, DateTime? from = null)
{
AddCriteria(u => u.Id == userId);
AddInclude(u => u.Orders.Where(o => from == null || o.CreatedAt >= from));
}
}
// Specification evaluator
public static class SpecificationEvaluator
{
public static IQueryable<T> GetQuery<T>(
IQueryable<T> inputQuery,
Specification<T> specification) where T : class
{
var query = inputQuery;
if (specification.Criteria is not null)
{
query = query.Where(specification.Criteria);
}
query = specification.Includes
.Aggregate(query, (current, include) => current.Include(include));
query = specification.IncludeStrings
.Aggregate(query, (current, include) => current.Include(include));
if (specification.OrderBy is not null)
{
query = query.OrderBy(specification.OrderBy);
}
else if (specification.OrderByDescending is not null)
{
query = query.OrderByDescending(specification.OrderByDescending);
}
if (specification.Skip.HasValue)
{
query = query.Skip(specification.Skip.Value);
}
if (specification.Take.HasValue)
{
query = query.Take(specification.Take.Value);
}
if (specification.AsNoTracking)
{
query = query.AsNoTracking();
}
return query;
}
}
// Repository with specifications
public interface IRepository<T> where T : class
{
Task<T?> GetBySpecificationAsync(
Specification<T> specification,
CancellationToken ct = default);
Task<List<T>> ListAsync(
Specification<T> specification,
CancellationToken ct = default);
Task<int> CountAsync(
Specification<T> specification,
CancellationToken ct = default);
}
public class Repository<T> : IRepository<T> where T : class
{
private readonly ApplicationDbContext _context;
public Repository(ApplicationDbContext context)
{
_context = context;
}
public async Task<T?> GetBySpecificationAsync(
Specification<T> specification,
CancellationToken ct = default)
{
var query = SpecificationEvaluator.GetQuery(_context.Set<T>(), specification);
return await query.FirstOrDefaultAsync(ct);
}
public async Task<List<T>> ListAsync(
Specification<T> specification,
CancellationToken ct = default)
{
var query = SpecificationEvaluator.GetQuery(_context.Set<T>(), specification);
return await query.ToListAsync(ct);
}
public async Task<int> CountAsync(
Specification<T> specification,
CancellationToken ct = default)
{
var query = SpecificationEvaluator.GetQuery(_context.Set<T>(), specification);
return await query.CountAsync(ct);
}
}
// Usage
var spec = new ActiveUsersSpecification();
var activeUsers = await _repository.ListAsync(spec, ct);
var emailSpec = new UserByEmailSpecification("test@example.com");
var user = await _repository.GetBySpecificationAsync(emailSpec, ct);
Pattern 12: Event-Driven Domain Models
// Domain event base
public abstract record DomainEvent
{
public Guid Id { get; init; } = Guid.NewGuid();
public DateTime OccurredAt { get; init; } = DateTime.UtcNow;
}
// Specific domain events
public record UserCreatedEvent(Guid UserId, string Email, string Name) : DomainEvent;
public record OrderPlacedEvent(Guid OrderId, Guid UserId, decimal Total) : DomainEvent;
public record OrderCancelledEvent(Guid OrderId, string Reason) : DomainEvent;
// Entity base with domain events
public abstract class Entity
{
private readonly List<DomainEvent> _domainEvents = new();
public IReadOnlyCollection<DomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void RaiseDomainEvent(DomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}
// Domain entity with events
public class Order : Entity
{
public Guid Id { get; private set; }
public Guid UserId { get; private set; }
public OrderStatus Status { get; private set; }
public decimal Total { get; private set; }
public DateTime CreatedAt { get; private set; }
private readonly List<OrderItem> _items = new();
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
public static Order Create(Guid userId, List<OrderItem> items)
{
var order = new Order
{
Id = Guid.NewGuid(),
UserId = userId,
Status = OrderStatus.Pending,
CreatedAt = DateTime.UtcNow
};
order._items.AddRange(items);
order.Total = items.Sum(i => i.Price * i.Quantity);
// Raise domain event
order.RaiseDomainEvent(new OrderPlacedEvent(
order.Id,
order.UserId,
order.Total));
return order;
}
public void Cancel(string reason)
{
if (Status == OrderStatus.Cancelled)
{
throw new InvalidOperationException("Order is already cancelled");
}
Status = OrderStatus.Cancelled;
RaiseDomainEvent(new OrderCancelledEvent(Id, reason));
}
public void MarkAsShipped()
{
if (Status != OrderStatus.Confirmed)
{
throw new InvalidOperationException("Only confirmed orders can be shipped");
}
Status = OrderStatus.Shipped;
}
}
// Domain event handler
public class OrderPlacedEventHandler : INotificationHandler<OrderPlacedEvent>
{
private readonly IEmailService _emailService;
private readonly ILogger<OrderPlacedEventHandler> _logger;
public OrderPlacedEventHandler(
IEmailService emailService,
ILogger<OrderPlacedEventHandler> logger)
{
_emailService = emailService;
_logger = logger;
}
public async Task Handle(OrderPlacedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Order {OrderId} placed for user {UserId}",
notification.OrderId,
notification.UserId);
// Send confirmation email
await _emailService.SendOrderConfirmationAsync(
notification.UserId,
notification.OrderId,
cancellationToken);
}
}
// Save changes with domain event publishing
public class ApplicationDbContext : DbContext
{
private readonly IPublisher _publisher;
public ApplicationDbContext(
DbContextOptions<ApplicationDbContext> options,
IPublisher publisher)
: base(options)
{
_publisher = publisher;
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
// Get domain events before saving
var domainEvents = ChangeTracker.Entries<Entity>()
.Select(e => e.Entity)
.SelectMany(e => e.DomainEvents)
.ToList();
// Save changes
var result = await base.SaveChangesAsync(cancellationToken);
// Publish domain events
foreach (var domainEvent in domainEvents)
{
await _publisher.Publish(domainEvent, cancellationToken);
}
// Clear events
foreach (var entity in ChangeTracker.Entries<Entity>().Select(e => e.Entity))
{
entity.ClearDomainEvents();
}
return result;
}
}
// Register MediatR for domain events
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
});
Testing
// Tests/Integration/UserEndpointsTests.cs
public class UserEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public UserEndpointsTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Replace database with in-memory
services.RemoveAll<DbContextOptions<ApplicationDbContext>>();
services.AddDbContext<ApplicationDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
});
});
_client = _factory.CreateClient();
}
[Fact]
public async Task GetUsers_ReturnsOk()
{
// Act
var response = await _client.GetAsync("/api/users");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var users = await response.Content.ReadFromJsonAsync<PagedResult<UserDto>>();
users.Should().NotBeNull();
}
[Fact]
public async Task CreateUser_WithValidData_ReturnsCreated()
{
// Arrange
var request = new CreateUserRequest("test@example.com", "Test User");
// Act
var response = await _client.PostAsJsonAsync("/api/users", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var user = await response.Content.ReadFromJsonAsync<UserDto>();
user.Should().NotBeNull();
user!.Email.Should().Be(request.Email);
}
[Fact]
public async Task GetUserById_WithInvalidId_ReturnsNotFound()
{
// Act
var response = await _client.GetAsync($"/api/users/{Guid.NewGuid()}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}
// Tests/Unit/CreateUserHandlerTests.cs
public class CreateUserHandlerTests
{
private readonly Mock<IUserRepository> _repository;
private readonly Mock<ILogger<CreateUserHandler>> _logger;
private readonly CreateUserHandler _handler;
public CreateUserHandlerTests()
{
_repository = new Mock<IUserRepository>();
_logger = new Mock<ILogger<CreateUserHandler>>();
_handler = new CreateUserHandler(_repository.Object, _logger.Object);
}
[Fact]
public async Task Handle_WithValidCommand_CreatesUser()
{
// Arrange
var command = new CreateUserCommand("test@example.com", "Test User");
_repository.Setup(r => r.ExistsByEmailAsync(command.Email, default))
.ReturnsAsync(false);
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
result.Should().NotBeNull();
result.Email.Should().Be(command.Email);
_repository.Verify(r => r.AddAsync(
It.IsAny<User>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task Handle_WithDuplicateEmail_ThrowsValidationException()
{
// Arrange
var command = new CreateUserCommand("test@example.com", "Test User");
_repository.Setup(r => r.ExistsByEmailAsync(command.Email, default))
.ReturnsAsync(true);
// Act & Assert
await _handler.Invoking(h => h.Handle(command, CancellationToken.None))
.Should().ThrowAsync<ValidationException>();
}
}
Best Practices
- Use Route Groups: Organize related endpoints with
MapGroup - Leverage Endpoint Filters: Replace middleware for endpoint-specific logic
- CQRS Pattern: Separate read and write operations
- Async All The Way: Use async methods for I/O operations
- Keyed Services: Use .NET 8 keyed services for multiple implementations
- Result Pattern: Use
IResultfor flexible responses - OpenAPI: Document with
WithOpenApi()and attributes - Testing: Use
WebApplicationFactoryfor integration tests
Common Pitfalls
- Blocking Code: Using
.Resultor.Wait()instead ofawait - No Cancellation Tokens: Not passing
CancellationTokento async methods - Fat Endpoints: Business logic in endpoint handlers
- Missing Validation: Not validating requests
- Poor Error Handling: Exposing stack traces in production
- No API Versioning: Breaking changes without versioning
- Ignoring Performance: Not using
AsNoTracking()for read-only queries