ASP.NET Core Guide
Applies to: ASP.NET Core 8.x (LTS), C# 12, Minimal APIs, MVC, Web APIs
Core Principles
- •Clean Architecture: Separate API, Core (domain), Infrastructure, and Contracts layers
- •Dependency Injection: Built-in DI container for all service registrations
- •Minimal APIs First: Prefer Minimal APIs for new endpoints; use controllers for complex scenarios
- •Async Everywhere: All I/O-bound operations must be async with CancellationToken
- •Records for DTOs: Immutable data transfer objects using C# records
Guardrails
Version & Dependencies
- •Target
net8.0(LTS) with<Nullable>enable</Nullable>and<ImplicitUsings>enable</ImplicitUsings> - •Enable
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>inDirectory.Build.props - •Use Central Package Management (
Directory.Packages.props) for version consistency - •Include StyleCop.Analyzers with
<AnalysisLevel>latest-recommended</AnalysisLevel>
Code Style
- •File-scoped namespaces:
namespace MyApp.Core.Entities; - •Primary constructors for DI:
public class UserService(IUserRepository repo, IMapper mapper) - •Use
requiredkeyword for mandatory properties on entities - •Records for all request/response DTOs
- •Avoid
async void-- always returnTaskorTask<T>
Error Handling
- •Use a global exception middleware (not per-controller try/catch)
- •Define domain exception hierarchy:
DomainException->NotFoundException,ConflictException,ValidationException - •Map domain exceptions to HTTP status codes in middleware
- •Log unhandled exceptions at Error level; log expected exceptions at Warning
- •Never expose stack traces in production responses
Security
- •Never hardcode connection strings or secrets (use
appsettings.json+ environment overrides) - •Always validate JWT
Issuer,Audience,Lifetime, andIssuerSigningKey - •Use
[Authorize]attribute on all endpoints that require authentication - •Role-based authorization:
[Authorize(Roles = "Admin")] - •Use HTTPS in production; enforce with
UseHttpsRedirection()
Project Structure
MyApp/ ├── MyApp.Api/ # Web API project │ ├── Controllers/ # API controllers (MVC pattern) │ ├── Endpoints/ # Minimal API endpoints (alternative) │ ├── Middleware/ # Custom middleware │ ├── Filters/ # Action filters │ ├── Validators/ # FluentValidation validators │ ├── Mappings/ # Mapster/AutoMapper configurations │ ├── Extensions/ # Service collection extensions │ ├── Program.cs # Entry point and DI configuration │ ├── appsettings.json # Configuration │ └── appsettings.Development.json ├── MyApp.Core/ # Domain/business logic (no dependencies) │ ├── Entities/ # Domain entities │ ├── Interfaces/ # Repository and service interfaces │ ├── Services/ # Business logic implementations │ └── Exceptions/ # Domain exception types ├── MyApp.Infrastructure/ # Data access, external services │ ├── Data/ # DbContext and EF configurations │ │ └── Configurations/ # IEntityTypeConfiguration<T> │ └── Repositories/ # Repository implementations ├── MyApp.Contracts/ # DTOs, API contracts (shared) │ ├── Requests/ # Input DTOs │ └── Responses/ # Output DTOs ├── tests/ │ ├── MyApp.UnitTests/ # xUnit + Moq + FluentAssertions │ └── MyApp.IntegrationTests/ # WebApplicationFactory + Testcontainers ├── MyApp.sln ├── Directory.Build.props # Shared build settings ├── Directory.Packages.props # Central package management └── docker-compose.yml
Layer rules:
- •
Corehas zero external dependencies (no EF Core, no ASP.NET references) - •
InfrastructurereferencesCoreonly - •
ApireferencesCore,Infrastructure, andContracts - •
Contractshas no project references (shareable with clients)
Minimal APIs
Endpoint Group Pattern
public static class UserEndpoints
{
public static IEndpointRouteBuilder MapUserEndpoints(
this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/users")
.WithTags("Users")
.WithOpenApi();
group.MapGet("/", GetAll)
.RequireAuthorization()
.Produces<PagedResponse<UserResponse>>();
group.MapGet("/{id:long}", GetById)
.RequireAuthorization()
.Produces<UserResponse>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/", Create)
.Produces<UserResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);
return routes;
}
private static async Task<IResult> GetById(
long id, IUserService service, CancellationToken ct)
{
var user = await service.GetByIdAsync(id, ct);
return Results.Ok(user);
}
private static async Task<IResult> Create(
CreateUserRequest request,
IUserService service,
IValidator<CreateUserRequest> validator,
CancellationToken ct)
{
var validation = await validator.ValidateAsync(request, ct);
if (!validation.IsValid)
return Results.BadRequest(validation.Errors);
var user = await service.CreateAsync(request, ct);
return Results.Created($"/api/users/{user.Id}", user);
}
}
Register in Program.cs: app.MapUserEndpoints();
Controllers
Standard REST Controller
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
private readonly IValidator<CreateUserRequest> _validator;
public UsersController(
IUserService userService,
IValidator<CreateUserRequest> validator)
{
_userService = userService;
_validator = validator;
}
[HttpGet("{id:long}")]
[Authorize]
[ProducesResponseType(typeof(UserResponse), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<UserResponse>> GetById(
long id, CancellationToken ct)
{
var user = await _userService.GetByIdAsync(id, ct);
return Ok(user);
}
[HttpPost]
[ProducesResponseType(typeof(UserResponse), 201)]
[ProducesResponseType(400)]
public async Task<ActionResult<UserResponse>> Create(
[FromBody] CreateUserRequest request, CancellationToken ct)
{
var validation = await _validator.ValidateAsync(request, ct);
if (!validation.IsValid)
return BadRequest(validation.Errors);
var user = await _userService.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = user.Id }, user);
}
}
Guidelines:
- •Always use
[ApiController]for automatic model binding and validation - •Use
CancellationTokenon every async action - •Annotate with
[ProducesResponseType]for OpenAPI documentation - •Use route constraints:
{id:long},{slug:alpha},{page:int:min(1)}
Entity Framework Core
DbContext
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
public DbSet<User> Users => Set<User>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(
typeof(AppDbContext).Assembly);
}
public override Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
foreach (var entry in ChangeTracker.Entries<User>())
{
if (entry.State == EntityState.Modified)
entry.Entity.UpdatedAt = DateTime.UtcNow;
}
return base.SaveChangesAsync(cancellationToken);
}
}
Entity Configuration
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ToTable("users");
builder.HasKey(u => u.Id);
builder.Property(u => u.Email).HasMaxLength(255).IsRequired();
builder.HasIndex(u => u.Email).IsUnique();
builder.Property(u => u.Role).HasConversion<string>().HasMaxLength(50);
builder.Property(u => u.Active).HasDefaultValue(true);
}
}
EF Core rules:
- •Use
IEntityTypeConfiguration<T>for all configurations (not inline inOnModelCreating) - •Use
AsNoTracking()for read-only queries - •Always include
CancellationTokenin async EF methods - •Use snake_case for database column names via configuration
Middleware
Exception Handling Middleware
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionMiddleware> _logger;
public ExceptionMiddleware(
RequestDelegate next, ILogger<ExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex);
}
}
private async Task HandleExceptionAsync(
HttpContext context, Exception exception)
{
var (statusCode, response) = exception switch
{
NotFoundException ex => (404, new { ex.Message }),
ConflictException ex => (409, new { ex.Message }),
ValidationException ex => (400, new { ex.Message, ex.Errors }),
_ => (500, (object)new { Message = "An unexpected error occurred" })
};
if (statusCode == 500)
_logger.LogError(exception, "Unhandled exception");
context.Response.ContentType = "application/json";
context.Response.StatusCode = statusCode;
await context.Response.WriteAsJsonAsync(response);
}
}
Register: app.UseMiddleware<ExceptionMiddleware>(); (first in pipeline)
Dependency Injection & Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((ctx, cfg) =>
cfg.ReadFrom.Configuration(ctx.Configuration));
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
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:Secret"]!))
};
});
builder.Services.AddAuthorization();
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
var app = builder.Build();
app.UseMiddleware<ExceptionMiddleware>();
app.UseSerilogRequestLogging();
app.UseAuthentication();
app.UseAuthorization();
app.MapUserEndpoints();
app.MapHealthChecks("/health");
app.Run();
public partial class Program { } // For integration test access
DI lifetimes:
- •
Scoped: repositories, services, DbContext (per-request) - •
Singleton: configuration objects, mapping configs, HttpClient factories - •
Transient: lightweight stateless services
Validation (FluentValidation)
public class CreateUserRequestValidator
: AbstractValidator<CreateUserRequest>
{
public CreateUserRequestValidator()
{
RuleFor(x => x.Email)
.NotEmpty().EmailAddress().MaximumLength(255);
RuleFor(x => x.Password)
.NotEmpty().MinimumLength(8)
.Matches("[A-Z]").WithMessage("Must contain uppercase")
.Matches("[0-9]").WithMessage("Must contain digit")
.Matches("[^a-zA-Z0-9]").WithMessage("Must contain special char");
RuleFor(x => x.FirstName).NotEmpty().MaximumLength(100);
}
}
Register: builder.Services.AddValidatorsFromAssemblyContaining<Program>();
Testing
Unit Test Pattern (xUnit + Moq + FluentAssertions)
public class UserServiceTests
{
private readonly Mock<IUserRepository> _repoMock = new();
private readonly Mock<IMapper> _mapperMock = new();
private readonly Mock<ILogger<UserService>> _loggerMock = new();
private readonly UserService _sut;
public UserServiceTests()
{
_sut = new UserService(
_repoMock.Object, _mapperMock.Object, _loggerMock.Object);
}
[Fact]
public async Task GetByIdAsync_WhenNotFound_ThrowsNotFoundException()
{
_repoMock.Setup(r => r.GetByIdAsync(999, It.IsAny<CancellationToken>()))
.ReturnsAsync((User?)null);
var act = () => _sut.GetByIdAsync(999);
await act.Should().ThrowAsync<NotFoundException>();
}
}
Integration Tests (WebApplicationFactory + Testcontainers)
public class UsersControllerTests : IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
.WithImage("postgres:15-alpine").Build();
private WebApplicationFactory<Program> _factory = null!;
private HttpClient _client = null!;
public async Task InitializeAsync()
{
await _postgres.StartAsync();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(b => b.ConfigureServices(services =>
services.AddDbContext<AppDbContext>(opt =>
opt.UseNpgsql(_postgres.GetConnectionString()))));
_client = _factory.CreateClient();
}
public async Task DisposeAsync()
{
await _factory.DisposeAsync();
await _postgres.DisposeAsync();
}
[Fact]
public async Task Create_ValidRequest_ReturnsCreated()
{
var request = new CreateUserRequest("test@example.com", "Password123!", "John", "Doe");
var response = await _client.PostAsJsonAsync("/api/users", request);
response.StatusCode.Should().Be(HttpStatusCode.Created);
}
}
Commands
# Restore and build dotnet restore dotnet build # Run with hot reload dotnet watch run --project MyApp.Api # Run tests dotnet test dotnet test --collect:"XPlat Code Coverage" # EF Core migrations dotnet tool install --global dotnet-ef dotnet ef migrations add MigrationName -p MyApp.Infrastructure -s MyApp.Api dotnet ef database update -p MyApp.Infrastructure -s MyApp.Api # Format and lint dotnet format # Publish and containerize dotnet publish -c Release -o ./publish docker build -t myapp:latest .
Best Practices
DO: Central Package Management | CancellationToken everywhere | Records for DTOs |
FluentValidation | Clean Architecture layers | Health checks (/health) | Serilog structured logging | Testcontainers for integration tests
DON'T: Expose entities in API responses | Synchronous DB calls | Catch-and-swallow exceptions |
Hardcode secrets | Skip API validation | Magic strings (use nameof())
Advanced Topics
For detailed patterns and examples, see:
- •references/patterns.md -- EF Core advanced patterns, Identity/Security, SignalR, Blazor integration, testing strategies, deployment