aspnet-core
SKILL.md
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)InfrastructurereferencesCoreonlyApireferencesCore,Infrastructure, andContractsContractshas 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 factoriesTransient: 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
External References
Weekly Installs
5
Repository
ar4mirez/samuelGitHub Stars
3
First Seen
14 days ago
Security Audits
Installed on
gemini-cli5
opencode5
codebuddy5
github-copilot5
codex5
kimi-cli5