| name | aspnet-conventions |
| description | ASP.NET Core web framework conventions: Minimal API vs MVC controllers, Program.cs composition, DI lifetimes (Scoped/Singleton/Transient), Options pattern with IOptions<T>, middleware ordering (HTTPS redirect → routing → authentication → authorization), model binding and validation (FluentValidation / DataAnnotations), ProblemDetails error handling, structured logging with ILogger<T>, configuration layering (appsettings.json + environment variables + User Secrets), and health checks. Works alongside csharp-foundation:csharp-conventions and aspnet-core-plugin:efcore-patterns.
Use this skill to:
- Compose Program.cs correctly — register services, configure middleware in the right order, map endpoints.
- Apply the Options pattern to avoid passing raw IConfiguration into services.
- Write Minimal API endpoint groups with typed results and authorization.
- Handle cross-cutting errors uniformly with ProblemDetails.
- Configure structured logging and health checks for production readiness.
Do NOT use this skill for:
- EF Core entity configuration and migrations — see aspnet-core-plugin:efcore-patterns.
- C# language idioms — see csharp-foundation:csharp-conventions.
- Testing — see csharp-foundation:dotnet-testing.
|
ASP.NET Core Conventions
Program.cs — canonical composition order
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddEnvironmentVariables(prefix: "MYAPP_");
builder.Services.AddControllers();
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Jwt"));
builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp"));
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddSingleton<IEmailTemplateCache, EmailTemplateCache>();
builder.Services.AddTransient<IPasswordHasher, Argon2PasswordHasher>();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o => builder.Configuration.Bind("Jwt", o));
builder.Services.AddAuthorization(o =>
{
o.AddPolicy("ProfileOwner", p => p.AddRequirements(new ProfileOwnerRequirement()));
});
builder.Services.AddSingleton<IAuthorizationHandler, ProfileOwnerHandler>();
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string not configured.")));
builder.Services.AddDataProtection()
.SetApplicationName("MyApp")
.PersistKeysToFileSystem(new DirectoryInfo("/var/keys"))
.ProtectKeysWithCertificate();
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapUserEndpoints();
app.MapHealthChecks("/health");
app.Run();
Never call UseAuthentication() / UseAuthorization() before UseRouting() — authentication middleware needs the routing context to resolve endpoint metadata.
Minimal API — endpoint groups
Organize endpoints in extension methods per feature:
public static class UserEndpoints
{
public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/users")
.WithTags("Users")
.RequireAuthorization();
group.MapGet("/{id:int}", GetUserAsync)
.WithName("GetUser")
.Produces<UserDto>()
.ProducesProblem(404);
group.MapPost("/", CreateUserAsync)
.AllowAnonymous()
.Accepts<CreateUserCommand>("application/json")
.Produces<UserDto>(201)
.ProducesValidationProblem();
group.MapPut("/{id:int}", UpdateUserAsync)
.RequireAuthorization("ProfileOwner");
group.MapDelete("/{id:int}", DeleteUserAsync)
.RequireAuthorization("Admin");
return app;
}
private static async Task<Results<Ok<UserDto>, NotFound>> GetUserAsync(
int id, IUserService service, CancellationToken ct)
{
var user = await service.GetAsync(id, ct);
return user is null ? TypedResults.NotFound() : TypedResults.Ok(user);
}
private static async Task<Results<Created<UserDto>, ValidationProblem>> CreateUserAsync(
CreateUserCommand cmd, IValidator<CreateUserCommand> validator,
IUserService service, CancellationToken ct)
{
var result = await validator.ValidateAsync(cmd, ct);
if (!result.IsValid) return TypedResults.ValidationProblem(result.ToDictionary());
var user = await service.CreateAsync(cmd, ct);
return TypedResults.Created($"/users/{user.Id}", user);
}
}
Use TypedResults.* (not Results.*) for strongly typed return types — it enables better Swagger documentation and compile-time safety.
Options pattern — never inject IConfiguration directly
public sealed class JwtOptions
{
public required string Issuer { get; init; }
public required string Audience { get; init; }
public required string Secret { get; init; }
}
public sealed class JwtTokenService(IOptions<JwtOptions> options)
{
private readonly JwtOptions _opts = options.Value;
public string GenerateToken(User user)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_opts.Secret));
}
}
Use IOptionsSnapshot<T> (scoped, reloads per request) for options that change at runtime. Use IOptionsMonitor<T> for singleton services that need live reloads.
Validation — FluentValidation
public sealed class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserCommandValidator()
{
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
.MaximumLength(256);
RuleFor(x => x.Password)
.NotEmpty()
.MinimumLength(8)
.Matches(@"[A-Z]").WithMessage("Password must contain at least one uppercase letter.")
.Matches(@"[0-9]").WithMessage("Password must contain at least one digit.");
RuleFor(x => x.DisplayName)
.NotEmpty()
.MaximumLength(100);
}
}
Register all validators via services.AddValidatorsFromAssembly(typeof(Program).Assembly). Call await validator.ValidateAsync(cmd, ct) explicitly in Minimal API handlers; MVC controllers with [ApiController] call ModelState validation automatically when using DataAnnotations.
Error handling — ProblemDetails (RFC 9457)
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
var problem = exception switch
{
KeyNotFoundException e => new ProblemDetails
{ Status = 404, Title = "Not Found", Detail = e.Message },
UnauthorizedAccessException e => new ProblemDetails
{ Status = 403, Title = "Forbidden", Detail = e.Message },
_ => new ProblemDetails
{ Status = 500, Title = "Internal Server Error" }
};
context.Response.StatusCode = problem.Status ?? 500;
await context.Response.WriteAsJsonAsync(problem);
});
});
TypedResults.NotFound()
TypedResults.Forbid()
TypedResults.Problem(detail: "...", statusCode: 422)
TypedResults.ValidationProblem(errors)
Never return raw exception messages — they leak implementation details. Use ProblemDetails with a sanitized Detail and a correlation ID in the Extensions dictionary.
Structured logging
public sealed class UserService(ILogger<UserService> logger, IUserRepository repo)
{
public async Task<User> CreateAsync(CreateUserCommand cmd, CancellationToken ct)
{
logger.LogInformation("Creating user {Email}", cmd.Email);
var user = User.Create(cmd.Email, cmd.Password);
await repo.SaveAsync(user, ct);
logger.LogInformation("User {UserId} created successfully", user.Id);
return user;
}
}
- Never
string.Format / $"..." in log messages — use structured logging placeholders {PropertyName}.
- Never log sensitive data (passwords, tokens, PII).
- Prefer
LogInformation / LogWarning / LogError over the generic Log(LogLevel, ...) overload.
- Use
Serilog or Microsoft.Extensions.Logging — configure sinks in appsettings.json, not in code.
Authentication — JWT Bearer
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"]
?? throw new InvalidOperationException("Jwt:Secret not configured."))),
ClockSkew = TimeSpan.FromMinutes(5),
};
});
- Never disable
ValidateLifetime or ValidateIssuerSigningKey in production.
- Store the signing secret in environment variables (
JWT__SECRET) or a secrets manager — never in appsettings.json.
Configuration layering
ASP.NET Core reads configuration in this order (later sources override earlier ones):
appsettings.json
appsettings.{Environment}.json (e.g., appsettings.Production.json)
- Environment variables (use
__ for nesting: Jwt__Secret)
- User Secrets (development only —
dotnet user-secrets set "Jwt:Secret" "...")
- Command-line arguments
Never commit secrets — appsettings.Development.json is for non-sensitive dev overrides only. Use User Secrets locally, environment variables in CI, and Key Vault / Secrets Manager in production.
Health checks
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>("database")
.AddUrlGroup(new Uri("https://api.external.com/health"), "external-api");
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
});
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false,
});
Expose /health/live (Kubernetes liveness probe) and /health/ready (readiness probe) as separate endpoints. Protect the full /health endpoint with authorization in production.