
Dotnet Api
- 161 installs
- 228 repo stars
- Updated August 3, 2026
- novotnyllc/dotnet-artisan
Scaffold ASP.NET Core Web API endpoints, DTOs, validation, dependency injection, and persistence layers following dotnet-artisan conventions for production REST or minimal APIs.
About
dotnet-artisan dotnet-api skill guides Claude through ASP.NET Core API design—endpoints, DTO mapping, validation, DI, persistence, and OpenAPI—so .NET backends for SaaS and standalone APIs follow consistent Artisan conventions.
- ASP.NET Core API scaffolding
- DTOs and validation
- Dependency injection wiring
- EF Core and repositories
- OpenAPI and error middleware
Dotnet Api by the numbers
- 161 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #71 of 153 .NET & C# skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/novotnyllc/dotnet-artisan --skill dotnet-apiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 161 |
|---|---|
| repo stars | ★ 228 |
| Last updated | August 3, 2026 |
| Repository | novotnyllc/dotnet-artisan ↗ |
What it does
Scaffold ASP.NET Core Web API endpoints, DTOs, validation, dependency injection, and persistence layers following dotnet-artisan conventions for production REST or minimal APIs.
Files
dotnet-api
Overview
ASP.NET Core APIs, data access, backend services, security, and cloud-native patterns. This consolidated skill spans 32 topic areas. Load the appropriate companion file from references/ based on the routing table below.
Baseline dependency: references/minimal-apis.md defines the core ASP.NET Core Minimal API patterns (route groups, endpoint filters, TypedResults, parameter binding) that apply to most API development tasks. Load it by default when building HTTP endpoints.
Most-shared companion: references/architecture-patterns.md covers vertical slices, request pipelines, error handling, caching, and idempotency patterns used across nearly all ASP.NET Core projects.
Routing Table
| Topic | Keywords | Description | Companion File |
|---|---|---|---|
| Minimal APIs | endpoint, route group, filter, TypedResults | Minimal API route groups, filters, TypedResults, OpenAPI | references/minimal-apis.md |
| Middleware | pipeline ordering, short-circuit, exception | Pipeline ordering, short-circuit, exception handling | references/middleware-patterns.md |
| EF Core patterns | DbContext, migrations, AsNoTracking | DbContext, AsNoTracking, query splitting, migrations | references/efcore-patterns.md |
| EF Core architecture | read/write split, aggregate boundaries, N+1 | Read/write split, aggregate boundaries, N+1 | references/efcore-architecture.md |
| Data access strategy | EF Core vs Dapper vs ADO.NET decision | EF Core vs Dapper vs ADO.NET decision matrix | references/data-access-strategy.md |
| gRPC | proto, code-gen, streaming, auth | Proto definition, code-gen, ASP.NET Core host, streaming | references/grpc.md |
| Real-time | SignalR, SSE, JSON-RPC, gRPC streaming | SignalR hubs, SSE, JSON-RPC 2.0, scaling | references/realtime-communication.md |
| Resilience | Polly v8, retry, circuit breaker, timeout | Polly v8 retry, circuit breaker, timeout, rate limiter | references/resilience.md |
| HTTP client | IHttpClientFactory, typed/named, DelegatingHandler | IHttpClientFactory, typed/named clients, DelegatingHandlers | references/http-client.md |
| API versioning | Asp.Versioning, URL/header/query, sunset | Asp.Versioning.Http/Mvc, URL/header/query, sunset | references/api-versioning.md |
| OpenAPI | MS.AspNetCore.OpenApi, Swashbuckle, NSwag | MS.AspNetCore.OpenApi, Swashbuckle migration, NSwag | references/openapi.md |
| API security | Identity, OAuth/OIDC, JWT, CORS, rate limiting | Identity, OAuth/OIDC, JWT bearer, CORS, rate limiting | references/api-security.md |
| OWASP | injection, auth, XSS, deprecated APIs | OWASP Top 10 hardening for .NET | references/security-owasp.md |
| Secrets | user secrets, env vars, rotation | User secrets, environment variables, rotation | references/secrets-management.md |
| Cryptography | AES-GCM, RSA, ECDSA, hashing, key derivation | AES-GCM, RSA, ECDSA, hashing, PQC key derivation | references/cryptography.md |
| Background services | BackgroundService, IHostedService, lifecycle | BackgroundService, IHostedService, lifecycle | references/background-services.md |
| Aspire | AppHost, service discovery, dashboard | AppHost, service discovery, components, dashboard | references/aspire-patterns.md |
| Semantic Kernel | AI/LLM plugins, prompts, memory, agents | AI/LLM plugins, prompt templates, memory, agents | references/semantic-kernel.md |
| Architecture | vertical slices, layered, pipelines, caching | Vertical slices, layered, pipelines, caching | references/architecture-patterns.md |
| Messaging | Wolverine, Azure Service Bus, RabbitMQ, pub/sub, sagas | Wolverine, Azure Service Bus, RabbitMQ, pub/sub, sagas | references/messaging-patterns.md |
| Service communication | REST vs gRPC vs SignalR decision matrix | REST vs gRPC vs SignalR decision matrix | references/service-communication.md |
| API surface validation | PublicApiAnalyzers, Verify, ApiCompat | PublicApiAnalyzers, Verify snapshots, ApiCompat | references/api-surface-validation.md |
| Library API compat | binary/source compat, type forwarders | Binary/source compat, type forwarders, SemVer | references/library-api-compat.md |
| I/O pipelines | PipeReader/PipeWriter, backpressure, Kestrel | PipeReader/PipeWriter, backpressure, Kestrel | references/io-pipelines.md |
| Agent gotchas | async misuse, NuGet errors, DI mistakes | Common agent mistakes in .NET code | references/agent-gotchas.md |
| File-based apps | .NET 10, directives, csproj migration | .NET 10 file-based C# apps | references/file-based-apps.md |
| API docs | DocFX, OpenAPI-as-docs, versioned docs | DocFX, OpenAPI-as-docs, versioned documentation | references/api-docs.md |
| HybridCache | HybridCache, L1/L2, stampede, tag eviction | HybridCache (.NET 9+), stampede protection, tag-based eviction | references/hybrid-cache.md |
| YARP | reverse proxy, load balancing, API gateway, BFF | YARP reverse proxy, load balancing, health checks, transforms | references/yarp.md |
| Output caching | OutputCache, response caching, compression | Output/response caching, compression, CDN, tag invalidation | references/output-caching.md |
| Identity | ASP.NET Core Identity, login, MFA, scaffolding | Identity setup, scaffolding, external providers, MapIdentityApi | references/identity-setup.md |
| Office documents and PDF | Excel, Word, PowerPoint, PDF, Open XML SDK, spreadsheet, docx, xlsx, PDFsharp, MigraDoc, merge PDF, split PDF, watermark | Open XML SDK, ClosedXML, PDFsharp/MigraDoc for PDF create/read/merge/split/watermark | references/office-documents.md |
Scope
- ASP.NET Core web APIs (minimal and controller-based)
- Data access (EF Core, Dapper, ADO.NET)
- Service communication (gRPC, SignalR, SSE, messaging)
- Security (auth, OWASP, secrets, crypto)
- Cloud-native (Aspire, resilience, background services)
- AI integration (Semantic Kernel)
- Architecture patterns and API surface validation
Out of scope
- C# language features -> [skill:dotnet-csharp]
- UI rendering -> [skill:dotnet-ui]
- Test authoring -> [skill:dotnet-testing]
- CI/CD pipelines -> [skill:dotnet-devops]
- Build tooling -> [skill:dotnet-tooling]
interface:
display_name: "dotnet-api"
short_description: "ASP.NET Core APIs, data access, and services"
default_prompt: "Use $dotnet-advisor to route this backend task, then load $dotnet-api for API architecture and implementation guidance."
policy:
allow_implicit_invocation: true
Agent Gotchas
Common mistakes AI agents make when generating or modifying .NET code, organized by category. Each category provides a brief warning, anti-pattern code, corrected code, and a cross-reference to the canonical skill that owns the deep guidance. This skill does NOT provide full implementation walkthroughs -- it surfaces the mistake and points to the right skill.
Prerequisites
.NET 8.0+ SDK. Familiarity with SDK-style projects and C# language features.
---
Category 1: Async/Await Misuse
Warning: Agents frequently block on async methods using .Result or .Wait(), causing deadlocks in ASP.NET Core and UI contexts. Another common mistake is fire-and-forget calls that silently swallow exceptions.
Anti-Pattern
// WRONG: blocking on async -- deadlock risk in synchronization contexts
public Order GetOrder(int id)
{
var order = _repository.GetOrderAsync(id).Result; // DEADLOCK
return order;
}
// WRONG: fire-and-forget with no error handling
public void ProcessOrder(Order order)
{
_ = _emailService.SendConfirmationAsync(order); // exception silently lost
}Corrected
// CORRECT: async all the way
public async Task<Order> GetOrderAsync(int id, CancellationToken ct = default)
{
var order = await _repository.GetOrderAsync(id, ct);
return order;
}
// CORRECT: background work via IHostedService or explicit error handling
public async Task ProcessOrderAsync(Order order, CancellationToken ct = default)
{
await _emailService.SendConfirmationAsync(order, ct);
}See [skill:dotnet-csharp] for full async/await guidance including ValueTask, ConfigureAwait, and cancellation propagation.
---
Category 2: NuGet Package Errors
Warning: Agents generate incorrect package names, reference pre-release versions without opt-in, or add packages that have been deprecated/replaced. ASP.NET Core shared-framework packages must match the project TFM major version.
Anti-Pattern
<!-- WRONG: package name does not exist (correct: Microsoft.EntityFrameworkCore) -->
<PackageReference Include="EntityFrameworkCore" Version="9.0.0" />
<!-- WRONG: hardcoded version for shared-framework package -- must match TFM -->
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
<!-- This breaks on net8.0 projects -->
<!-- WRONG: agents add Swashbuckle by default; .NET 9+ templates use built-in OpenAPI -->
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.0.0" />
<!-- Swashbuckle is still valid when Swagger UI is needed, but not the default choice -->Corrected
<!-- CORRECT: exact package ID -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<!-- CORRECT: use version variable or central package management to match TFM -->
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<!-- Version managed via Directory.Packages.props matching project TFM -->
<!-- CORRECT: .NET 9+ templates prefer built-in OpenAPI support -->
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
<!-- Swashbuckle remains a valid choice when Swagger UI features are needed -->See [skill:dotnet-tooling] for project file conventions and central package management guidance.
---
Category 3: Deprecated API Usage
Warning: Agents generate code using deprecated and insecure APIs: BinaryFormatter (CVE-prone deserialization), WebClient (replaced by HttpClient), and older cryptography APIs (RNGCryptoServiceProvider, SHA1CryptoServiceProvider).
Anti-Pattern
// WRONG: BinaryFormatter is banned in .NET 8+ (SYSLIB0011)
var formatter = new BinaryFormatter();
formatter.Serialize(stream, data);
// WRONG: WebClient is obsolete -- use HttpClient via IHttpClientFactory
var client = new WebClient();
var html = client.DownloadString("https://example.com");
// WRONG: obsolete crypto API (SYSLIB0023)
using var rng = new RNGCryptoServiceProvider();
rng.GetBytes(buffer);Corrected
// CORRECT: use System.Text.Json for serialization
var json = JsonSerializer.Serialize(data);
await File.WriteAllTextAsync("data.json", json);
// CORRECT: use IHttpClientFactory (registered via DI)
public class MyService(HttpClient httpClient)
{
public async Task<string> GetHtmlAsync(CancellationToken ct = default)
=> await httpClient.GetStringAsync("https://example.com", ct);
}
// CORRECT: modern RandomNumberGenerator (static API)
RandomNumberGenerator.Fill(buffer);See [skill:dotnet-api] for the full deprecated security pattern catalog and OWASP mitigations.
---
Category 4: Project Structure Mistakes
Warning: Agents use wrong SDK types, add PackageReference entries for framework-included libraries, or create broken ProjectReference paths.
Anti-Pattern
<!-- WRONG: using Microsoft.NET.Sdk for a web project -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>
<!-- Missing WebApplication APIs, Kestrel, etc. -->
</Project>
<!-- WRONG: referencing a package already in the shared framework -->
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0" />
<!-- This is included in Microsoft.NET.Sdk.Web; explicit reference causes version conflicts -->
<!-- WRONG: relative path that doesn't match actual project location -->
<ProjectReference Include="..\..\Core\MyApp.Core.csproj" />
<!-- Actual location is ../MyApp.Core/MyApp.Core.csproj -->Corrected
<!-- CORRECT: use the Web SDK for ASP.NET Core projects -->
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>
</Project>
<!-- CORRECT: don't add explicit PackageReference for shared-framework packages -->
<!-- Microsoft.Extensions.Logging is implicitly available via Sdk.Web -->
<!-- CORRECT: verify the actual project path before adding a reference -->
<ProjectReference Include="..\MyApp.Core\MyApp.Core.csproj" />See [skill:dotnet-tooling] for SDK types, project organization, and project reference conventions.
---
Category 5: Nullable Reference Type Annotation Errors
Warning: Agents misuse the null-forgiving operator (!) to silence warnings instead of fixing nullability, or forget to enable the nullable context.
Anti-Pattern
// WRONG: null-forgiving operator hides a real null risk
public string GetUserName(int id)
{
var user = _db.Users.Find(id);
return user!.Name; // NullReferenceException if user not found
}
// WRONG: nullable not enabled, so annotations are meaningless
// Missing <Nullable>enable</Nullable> in .csproj
public string? GetOptionalValue() => null; // no compiler warnings without nullable contextCorrected
// CORRECT: handle null explicitly
public string GetUserName(int id)
{
var user = _db.Users.Find(id);
if (user is null)
{
throw new InvalidOperationException($"User {id} not found.");
}
return user.Name;
}<!-- CORRECT: enable nullable context in .csproj -->
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>See [skill:dotnet-csharp] for full NRT usage patterns and annotation strategies.
---
Category 6: Source Generator Misconfiguration
Warning: Agents forget to mark classes as partial when source generators need to augment them, or use incorrect output types that prevent generator output from compiling.
Anti-Pattern
// WRONG: missing partial keyword -- source generator cannot augment this class
[JsonSerializable(typeof(WeatherForecast))]
internal class WeatherJsonContext : JsonSerializerContext
{
}
// WRONG: generator expects a class but agent declared a struct
[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Processing {Item}")]
public static partial struct LogMessages // struct is invalid for LoggerMessage
{
}Corrected
// CORRECT: partial class allows source generator to emit companion code
[JsonSerializable(typeof(WeatherForecast))]
internal partial class WeatherJsonContext : JsonSerializerContext
{
}
// CORRECT: LoggerMessage requires partial method in a partial class
public static partial class Log
{
[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Processing {Item}")]
public static partial void ProcessingItem(ILogger logger, string item);
}See [skill:dotnet-csharp] for source generator configuration, diagnostics, and debugging.
---
Category 7: Trimming/AOT Warning Suppression
Warning: Agents suppress trimming and AOT warnings with #pragma or [UnconditionalSuppressMessage] instead of fixing the underlying reflection/dynamic usage. Suppression hides runtime failures in published apps.
Anti-Pattern
// WRONG: suppressing trim warning instead of fixing it
#pragma warning disable IL2026
var type = Type.GetType(typeName); // reflection not trim-safe
var instance = Activator.CreateInstance(type!);
#pragma warning restore IL2026
// WRONG: app-level suppression in .csproj hides all trim warnings
// <NoWarn>IL2026;IL2046;IL3050</NoWarn>Corrected
// CORRECT: use compile-time type resolution or [DynamicallyAccessedMembers]
public T CreateInstance<T>() where T : new()
{
return new T(); // no reflection, trim-safe
}
// For unavoidable reflection, annotate correctly:
public object CreateInstance(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type)
{
return Activator.CreateInstance(type)
?? throw new InvalidOperationException($"Cannot create {type.Name}");
}<!-- CORRECT: enable trim/AOT analyzers to catch issues early -->
<!-- For apps: -->
<PublishTrimmed>true</PublishTrimmed>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<!-- For libraries: -->
<IsTrimmable>true</IsTrimmable>
<!-- IsTrimmable auto-enables trim analyzer for libraries -->See [skill:dotnet-tooling] for MSBuild property guidance on trimming and AOT configuration.
---
Category 8: Test Organization Anti-Patterns
Warning: Agents put test classes in production projects, use wrong test SDK configurations, or mix test framework attributes incorrectly.
Anti-Pattern
// WRONG: test class in the production project (not in a separate test project)
// File: src/MyApp.Api/OrderServiceTests.cs
namespace MyApp.Api;
public class OrderServiceTests
{
[Fact] // xUnit attribute in production code -- ships test dependencies to users
public void CalculateTotal_ReturnsCorrectSum() { }
}<!-- WRONG: test project missing Microsoft.NET.Test.Sdk and runner -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="3.2.2" />
<!-- Missing Microsoft.NET.Test.Sdk and runner -- dotnet test will find zero tests -->
</ItemGroup>
</Project>Corrected
<!-- CORRECT: test project in tests/ directory with proper configuration -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MyApp.Api\MyApp.Api.csproj" />
</ItemGroup>
</Project>See [skill:dotnet-testing] for test organization, naming conventions, and test type decision guidance.
---
Category 9: DI Registration Errors
Warning: Agents forget to register services, use wrong lifetimes (singleton capturing scoped), or create captive dependencies that cause memory leaks and concurrency bugs.
Anti-Pattern
// WRONG: scoped service injected into singleton -- captive dependency
builder.Services.AddSingleton<OrderProcessor>(); // singleton
builder.Services.AddScoped<IOrderRepository, OrderRepository>(); // scoped
public class OrderProcessor(IOrderRepository repo) // repo is captured as singleton!
{
public async Task ProcessAsync(int orderId, CancellationToken ct)
{
var order = await repo.GetByIdAsync(orderId, ct); // same DbContext forever
}
}
// WRONG: missing registration causes runtime exception
// builder.Services.AddScoped<IOrderRepository, OrderRepository>(); // forgot this line
// InvalidOperationException: Unable to resolve service for type 'IOrderRepository'Corrected
// CORRECT: lifetimes must not capture shorter-lived dependencies
builder.Services.AddScoped<OrderProcessor>(); // scoped, matches repository lifetime
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
// Or if OrderProcessor must be singleton, inject IServiceScopeFactory:
builder.Services.AddSingleton<OrderProcessor>();
public class OrderProcessor(IServiceScopeFactory scopeFactory)
{
public async Task ProcessAsync(int orderId, CancellationToken ct)
{
await using var scope = scopeFactory.CreateAsyncScope();
var repo = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
var order = await repo.GetByIdAsync(orderId, ct);
}
}See [skill:dotnet-csharp] for lifetime rules, registration patterns, and service scope management.
---
Slopwatch Anti-Patterns
These are patterns that indicate an agent is hiding problems rather than fixing them. Every code review should check for these. See [skill:dotnet-testing] for the automated quality gate that detects these patterns.
1. Disabled or Skipped Tests
// RED FLAG: skipping tests to make the build pass
[Fact(Skip = "Flaky, will fix later")] // test never gets fixed
public void CriticalBusinessLogic_WorksCorrectly() { }
// RED FLAG: commenting out failing tests
// [Fact]
// public void CalculateTotal_HandlesNegative() { ... }
// RED FLAG: conditional compilation to hide tests
#if false
[Fact]
public void ImportantEdgeCase() { }
#endifFix: Investigate and fix the underlying issue. If a test is genuinely flaky due to timing, use [Retry] (xUnit v3) or fix the non-determinism. Never disable tests to achieve a green build.
2. Warning Suppressions
// RED FLAG: blanket warning suppression
#pragma warning disable CS8600, CS8602, CS8604 // suppress all nullability warnings
var result = GetData();
result.Process();
#pragma warning restore CS8600, CS8602, CS8604
// RED FLAG: project-level suppression hiding real issues
// <NoWarn>CS8618;CS8625;IL2026</NoWarn>Fix: Address the underlying nullability or trim issues. Add proper null checks, use nullable annotations correctly, or apply [DynamicallyAccessedMembers] for trim warnings.
3. Empty Catch Blocks
// RED FLAG: swallowing exceptions silently
try
{
await _service.ProcessAsync(data, ct);
}
catch (Exception) { } // failure is invisible
// RED FLAG: catch-and-ignore with misleading comment
catch (Exception ex)
{
// TODO: add logging
}Fix: At minimum, log the exception. Prefer catching specific exception types and handling them appropriately.
4. Silenced Analyzers Without Justification
// RED FLAG: suppressing analyzer with no explanation
[SuppressMessage("Design", "CA1062")]
public void Process(string input) { }
// RED FLAG: disabling analyzer rules in .editorconfig globally
// dotnet_diagnostic.CA1062.severity = noneFix: Fix the code to satisfy the analyzer rule, or provide a documented justification in the suppression attribute: [SuppressMessage("Design", "CA1062", Justification = "Input validated by middleware")].
5. Removed Assertions from Tests
// RED FLAG: test with no assertions -- always passes
[Fact]
public async Task CreateOrder_Succeeds()
{
var service = new OrderService();
await service.CreateOrderAsync(new Order());
// no Assert -- this test proves nothing
}Fix: Every test must have at least one assertion that validates the expected behavior. If the test is for side effects, assert on the side effect (database state, published events, log output).
---
Cross-References
- [skill:dotnet-csharp] -- async/await deep patterns,
ValueTask, cancellation - [skill:dotnet-csharp] -- DI lifetime rules, registration patterns, scope management
- [skill:dotnet-csharp] -- NRT annotations, nullable context, flow analysis
- [skill:dotnet-csharp] -- generator configuration, partial class requirements, diagnostics
- [skill:dotnet-testing] -- test type decisions, organization, naming conventions
- [skill:dotnet-api] -- OWASP mitigations, deprecated security API catalog
References
API Docs
API documentation generation for .NET projects: DocFX setup for API reference from assemblies (docfx.json configuration, metadata extraction, template customization, cross-referencing), OpenAPI spec as living API documentation (Scalar and Swagger UI embedding, versioned OpenAPI documents), documentation-code synchronization (CI validation with -warnaserror:CS1591, broken link detection, automated doc builds on PR), API changelog patterns (breaking change documentation, migration guides, deprecated API tracking), and versioned API documentation (version selectors, multi-version maintenance, URL patterns).
Version assumptions: DocFX v2.x (community-maintained). OpenAPI 3.x via Microsoft.AspNetCore.OpenApi (.NET 9+ built-in). Scalar UI for modern OpenAPI visualization. .NET 8.0+ baseline for code examples.
DocFX Setup for .NET API Reference
DocFX generates API reference documentation directly from .NET assemblies and XML documentation comments. It is the only documentation tool with native docfx metadata extraction from .NET projects.
Installation
# Install DocFX as a .NET global tool
dotnet tool install -g docfx
# Or as a local tool (recommended for team consistency)
dotnet new tool-manifest
dotnet tool install docfxConfiguration (docfx.json)
{
"metadata": [
{
"src": [
{
"files": ["src/**/*.csproj"],
"exclude": ["**/bin/**", "**/obj/**"],
"src": ".."
}
],
"dest": "api",
"properties": {
"TargetFramework": "net8.0"
},
"disableGitFeatures": false,
"disableDefaultFilter": false
}
],
"build": {
"content": [
{
"files": ["api/**.yml", "api/index.md"]
},
{
"files": [
"articles/**.md",
"articles/**/toc.yml",
"toc.yml",
"*.md"
]
}
],
"resource": [
{
"files": ["images/**"]
}
],
"dest": "_site",
"globalMetadataFiles": [],
"fileMetadataFiles": [],
"template": ["default", "modern"],
"postProcessors": ["ExtractSearchIndex"],
"markdownEngineName": "markdig",
"noLangKeyword": false,
"keepFileLink": false,
"cleanupCacheHistory": false,
"disableGitFeatures": false,
"globalMetadata": {
"_appTitle": "My.Library API Reference",
"_appFooter": "Copyright 2024 My Company",
"_enableSearch": true,
"_enableNewTab": true
}
}
}Metadata Extraction
The metadata section controls how DocFX extracts API information from .NET projects:
# Generate API metadata YAML files from projects
docfx metadata docfx.json
# This creates YAML files in the api/ directory:
# api/MyLibrary.WidgetService.yml
# api/MyLibrary.Widget.yml
# api/toc.ymlKey metadata configuration options:
| Property | Purpose | Default |
|---|---|---|
src.files | Project files to extract from | Required |
dest | Output directory for YAML | api |
properties.TargetFramework | TFM to build against | Project default |
disableGitFeatures | Skip git blame info | false |
filter | Path to API filter YAML | None (all public APIs) |
API Filtering
Exclude internal types from the generated documentation:
# filterConfig.yml
apiRules:
- exclude:
uidRegex: ^MyLibrary\.Internal\.
type: Namespace
- exclude:
hasAttribute:
uid: System.ComponentModel.EditorBrowsableAttribute
ctorArguments:
- System.ComponentModel.EditorBrowsableState.NeverReference the filter in docfx.json:
{
"metadata": [
{
"filter": "filterConfig.yml"
}
]
}Template Customization
DocFX supports template overrides for custom branding:
docs/
templates/
custom/
styles/
main.css # Custom CSS overrides
partials/
head.tmpl.partial # Custom head section (analytics, fonts)
footer.tmpl.partialReference custom templates in docfx.json:
{
"build": {
"template": ["default", "modern", "templates/custom"]
}
}Cross-Referencing Between Pages
DocFX supports uid-based cross-references between API pages and conceptual articles:
<!-- In a conceptual article -->
See the @MyLibrary.WidgetService.CreateWidgetAsync(System.String) method for details.
For the full API, see <xref:MyLibrary.WidgetService>.# In an API YAML override file (api/MyLibrary.WidgetService.yml)
# Add links to conceptual articles
references:
- uid: MyLibrary.WidgetService
seealso:
- linkId: ../articles/getting-started.md
commentId: getting-started---
OpenAPI Spec as Documentation
Generated OpenAPI specifications serve as living API documentation that stays in sync with the code. This section covers using OpenAPI output as documentation; for OpenAPI generation and configuration, see [skill:dotnet-api].
Scalar UI Embedding
Scalar provides a modern, interactive API documentation viewer:
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi(); // Serves OpenAPI JSON at /openapi/v1.json
app.MapScalarApiReference(options =>
{
options.WithTitle("My API Documentation")
.WithTheme(ScalarTheme.Purple)
.WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient);
});
}
app.Run();Scalar renders the OpenAPI spec as an interactive documentation page with:
- Endpoint grouping by tags
- Request/response examples
- Authentication configuration
- "Try it" functionality for testing endpoints
Swagger UI Embedding
For projects using Swashbuckle or requiring the classic Swagger UI:
if (app.Environment.IsDevelopment())
{
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/openapi/v1.json", "My API v1");
options.RoutePrefix = "api-docs";
options.DocumentTitle = "My API Documentation";
options.DefaultModelsExpandDepth(-1); // Hide schemas by default
});
}Versioned OpenAPI Documents
Serve multiple OpenAPI documents for different API versions:
builder.Services.AddOpenApi("v1", options =>
{
options.AddDocumentTransformer((document, context, ct) =>
{
document.Info.Version = "1.0";
document.Info.Title = "My API";
return Task.CompletedTask;
});
});
builder.Services.AddOpenApi("v2", options =>
{
options.AddDocumentTransformer((document, context, ct) =>
{
document.Info.Version = "2.0";
document.Info.Title = "My API";
return Task.CompletedTask;
});
});
// Serves /openapi/v1.json and /openapi/v2.json
app.MapOpenApi();Exporting OpenAPI for Static Documentation
Export the OpenAPI spec at build time for use in static documentation sites:
# Generate OpenAPI spec from the running application
dotnet run -- --urls "http://localhost:5099" &
APP_PID=$!
sleep 3
curl -s http://localhost:5099/openapi/v1.json > docs/openapi/v1.json
kill $APP_PIDAlternatively, use the Microsoft.Extensions.ApiDescription.Server package to generate at build time:
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="8.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PropertyGroup>
<OpenApiGenerateDocuments>true</OpenApiGenerateDocuments>
<OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/../docs/openapi</OpenApiDocumentsDirectory>
</PropertyGroup>For OpenAPI generation setup and Swashbuckle migration details, see [skill:dotnet-api].
---
Doc Site Generation from XML Comments
XML Docs to DocFX (Static HTML)
The primary pipeline for library API reference documentation:
Source Code (.cs files)
|
v
XML Doc Comments (/// <summary>...)
|
v
Build with GenerateDocumentationFile=true
|
v
XML Doc File (MyLibrary.xml)
|
v
docfx metadata (extracts API structure)
|
v
YAML Files (api/*.yml)
|
v
docfx build (generates HTML)
|
v
Static HTML Site (_site/)For XML documentation comment authoring best practices, see [skill:dotnet-tooling].
XML Docs to Starlight (via Markdown Extraction)
For projects using Starlight instead of DocFX, extract API documentation as Markdown:
1. Generate the XML doc file with <GenerateDocumentationFile>true</GenerateDocumentationFile> 2. Use a conversion tool to transform XML docs to Markdown pages:
xmldoc2md(community tool): converts XML doc files to Markdown- Custom script: parse the XML file and generate Markdown pages for each type
# Using xmldoc2md
dotnet tool install -g XMLDoc2Markdown
xmldoc2md MyLibrary.dll docs/src/content/docs/reference/
# Output: one Markdown file per type in the reference/ directory3. Include in Starlight build:
docs/src/content/docs/
reference/
MyLibrary.WidgetService.md # Auto-generated from XML docs
MyLibrary.Widget.md
MyLibrary.WidgetStatus.mdConfigure the sidebar to auto-generate from the reference directory:
// astro.config.mjs
sidebar: [
{
label: 'API Reference',
autogenerate: { directory: 'reference' },
},
],---
Keeping Docs in Sync with Code
CI Validation of Doc Completeness
Enforce XML documentation completeness in CI by treating CS1591 as an error:
<!-- Directory.Build.props -->
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<!-- For public library projects only -->
<PropertyGroup Condition="'$(IsPublicLibrary)' == 'true'">
<WarningsAsErrors>$(WarningsAsErrors);CS1591</WarningsAsErrors>
</PropertyGroup># CI command: build with warnings-as-errors for doc completeness
dotnet build -warnaserror:CS1591This fails the build if any public member is missing XML documentation. Use the IsPublicLibrary condition (or per-project configuration) to apply only to published NuGet packages, not test projects or internal tools.
Broken Link Detection
Validate documentation links in CI:
# Build DocFX and check for broken cross-references
docfx build docfx.json --warningsAsErrors
# DocFX reports broken xref links as warnings -- the flag promotes them to errorsFor Starlight or Docusaurus sites, use a link checker after building:
# Build the doc site
npm run build
# Check for broken links in the built output
npx broken-link-checker-local ./_site --recursiveAutomated Doc Builds on PR
Validate documentation builds on every pull request without deploying. For the deployment workflow configuration, see [skill:dotnet-devops]. The validation step typically runs as part of the CI workflow:
# In CI: verify docs build without errors
dotnet build -warnaserror:CS1591 # XML doc completeness
docfx metadata docfx.json # API metadata extraction
docfx build docfx.json --warningsAsErrors # Full doc site buildThis catches documentation regressions (missing docs, broken cross-references) before they reach the main branch.
---
API Changelog Patterns
Breaking Change Documentation
Document breaking changes with a structured format that consumers can quickly scan:
## Breaking Changes in v3.0
### Removed APIs
| API | Replacement | Migration |
|-----|-------------|-----------|
| `WidgetService.Create(string)` | `WidgetService.CreateAsync(string, CancellationToken)` | Add `await` and `CancellationToken` parameter |
| `Widget.Name` setter | `WidgetService.RenameAsync(Guid, string)` | Use service method instead of direct property mutation |
| `IWidgetRepository` (interface) | `IWidgetRepository<T>` (generic) | Update implementations to use generic interface |
### Changed Behavior
- `WidgetService.CreateAsync` now validates name uniqueness within a category.
Previously, duplicate names were silently allowed.
- `Widget.Status` defaults to `Draft` instead of `Active`.
Existing code that assumes newly created widgets are active must call `widget.Activate()`.
### New Required Dependencies
- `Microsoft.Extensions.Caching.Memory` is now a required dependency for `WidgetService`.
Register with `builder.Services.AddMemoryCache()`.Migration Guides Between Major Versions
Structure migration guides by the action required:
# Migrating from v2.x to v3.0
## Step 1: Update Package References
<!-- Before --> <PackageReference Include="My.Library" Version="2.*" />
<!-- After --> <PackageReference Include="My.Library" Version="3.0.0" />
## Step 2: Fix Compilation Errors
### Async API Changes
All synchronous methods have been removed. Replace synchronous calls with async equivalents:
// Before (v2.x) var widget = service.Create("name");
// After (v3.0) var widget = await service.CreateAsync("name", cancellationToken);
### Generic Repository Interface
// Before (v2.x) public class MyRepo : IWidgetRepository { }
// After (v3.0) public class MyRepo : IWidgetRepository<Widget> { }
## Step 3: Update Behavioral Assumptions
- Check all code paths that assume `Widget.Status == Active` after creation
- Add `builder.Services.AddMemoryCache()` to DI registrationDeprecated API Tracking
Use the [Obsolete] attribute with message pointing to the replacement. Document deprecation timelines:
/// <summary>
/// Creates a widget synchronously.
/// </summary>
/// <remarks>
/// This method will be removed in v4.0. Use
/// <see cref="CreateAsync(string, CancellationToken)"/> instead.
/// </remarks>
[Obsolete("Use CreateAsync instead. This method will be removed in v4.0.", error: false)]
public Widget Create(string name)
{
}Track deprecated APIs in a dedicated document:
# Deprecated APIs
| API | Deprecated In | Removed In | Replacement |
|-----|---------------|------------|-------------|
| `WidgetService.Create(string)` | v2.5 | v4.0 (planned) | `CreateAsync(string, CancellationToken)` |
| `Widget.Name` setter | v3.0 | v4.0 (planned) | `WidgetService.RenameAsync(Guid, string)` |
| `WidgetOptions.EnableCache` | v3.1 | v5.0 (planned) | `WidgetOptions.CachePolicy` |For changelog format conventions and SemVer versioning strategy, see [skill:dotnet-devops].
---
Versioned API Documentation
Version Selectors in Doc Sites
DocFX versioned docs:
DocFX supports version-specific metadata extraction by targeting different project versions:
{
"metadata": [
{
"src": [{ "files": ["src/**/*.csproj"], "src": ".." }],
"dest": "api/v2",
"properties": { "TargetFramework": "net8.0" },
"globalNamespaceId": "v2"
}
]
}Maintain separate branches or tags for each major version, and build documentation from each:
# Build docs for v2.x (current branch)
docfx build docfx.json
# Build docs for v1.x (from tag)
git checkout v1.x
docfx build docfx.json --output _site/v1
git checkout mainStarlight versioned docs:
Use directory-based versioning or the @lorenzo_lewis/starlight-utils plugin. See [skill:dotnet-tooling] for Starlight versioning setup.
Docusaurus versioned docs:
Docusaurus has built-in versioning with npx docusaurus docs:version. See [skill:dotnet-tooling] for Docusaurus versioning setup.
Maintaining Docs for Multiple Active Versions
When supporting multiple active major versions simultaneously:
1. Branch-per-major-version strategy: Maintain docs/v1, docs/v2 directories on the main branch, or separate v1.x, v2.x branches 2. Shared conceptual docs: Keep version-independent guides (architecture, concepts) in a shared location, version-specific API reference in separate directories 3. Version banner: Add a notification banner on older version docs pointing to the latest version
URL Patterns
Consistent URL patterns for versioned API docs:
https://docs.mylib.dev/ # Latest stable version
https://docs.mylib.dev/v2/ # Specific version
https://docs.mylib.dev/v2/api/WidgetService # Specific type in specific version
https://docs.mylib.dev/latest/ # Alias for latest stable
https://docs.mylib.dev/next/ # Pre-release / unreleased docsConfigure redirects so unversioned URLs point to the latest stable version. This ensures existing links remain valid when a new version is published.
---
Agent Gotchas
1. Do not generate OpenAPI spec configuration -- OpenAPI generation setup (builder.Services.AddOpenApi(), document transformers, Swashbuckle migration) belongs to [skill:dotnet-api]. This skill covers using the generated OpenAPI output as documentation.
2. Do not write XML doc comment syntax guidance -- XML tag syntax, conventions, <inheritdoc>, and GenerateDocumentationFile belong to [skill:dotnet-tooling]. This skill covers the pipeline from XML docs to generated documentation sites.
3. Do not generate CI deployment YAML -- doc site deployment workflows (GitHub Pages actions, DocFX deploy) belong to [skill:dotnet-devops]. This skill covers doc build validation and local generation.
4. `docfx metadata` requires a buildable project -- the project must compile successfully for DocFX to extract API metadata. Always run dotnet build before docfx metadata in CI pipelines.
5. DocFX is community-maintained since November 2022 -- Microsoft transferred the repository. It remains actively maintained and widely used. For new projects evaluating alternatives, see [skill:dotnet-tooling].
6. DocFX `modern` template requires v2.75+ -- earlier versions use the default template which does not include Mermaid support or modern styling. Check the installed version with docfx --version.
7. `-warnaserror:CS1591` should apply only to public library projects -- applying it to test projects, console apps, or internal tools creates unnecessary documentation burden. Use MSBuild conditions to target only published packages.
8. API filtering with `filterConfig.yml` uses UID regex, not namespace strings -- the pattern ^MyLibrary\.Internal\. matches UIDs that start with that prefix. Test filter patterns with docfx metadata --log verbose to verify correct filtering.
9. Breaking change documentation must include migration code examples -- a table listing removed APIs without showing the replacement code is insufficient. Always include before/after code snippets.
10. Versioned doc URLs must redirect unversioned paths to latest stable -- do not break existing links when publishing a new version. Configure server-side redirects or a client-side redirect page at the root URL.
11. OpenAPI UI (Scalar, Swagger UI) should only be exposed in development -- wrap MapScalarApiReference and UseSwaggerUI in if (app.Environment.IsDevelopment()) guards. Production exposure of interactive API docs is a security consideration.
API Security
API-level authentication, authorization, and security patterns for ASP.NET Core. This skill owns API auth implementation: ASP.NET Core Identity configuration, OAuth 2.0/OIDC integration, JWT bearer token handling, passkey (WebAuthn) authentication, CORS policies, Content Security Policy headers, and rate limiting.
ASP.NET Core Identity
ASP.NET Core Identity provides user management, password hashing, role-based authorization, and two-factor authentication out of the box. It is the recommended starting point for applications that manage their own user accounts.
builder.Services.AddIdentityApiEndpoints<ApplicationUser>(options =>
{
// Password requirements
options.Password.RequiredLength = 12;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequireUppercase = true;
options.Password.RequireLowercase = true;
options.Password.RequireDigit = true;
// Lockout
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.AllowedForNewUsers = true;
// User
options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
var app = builder.Build();
app.MapIdentityApi<ApplicationUser>(); // Maps /register, /login, /refresh, /manage endpointsIdentity API Endpoints (.NET 8+)
MapIdentityApi<TUser>() provides pre-built token-based authentication endpoints for SPAs and mobile clients without Razor UI:
| Endpoint | Method | Description |
|---|---|---|
/register | POST | Create a new user account |
/login | POST | Authenticate and receive tokens |
/refresh | POST | Refresh an expired access token |
/confirmEmail | GET | Confirm email address |
/manage/info | GET/POST | Get/update user profile |
/manage/2fa | POST | Configure two-factor authentication |
---
OAuth 2.0 / OpenID Connect
For applications that delegate authentication to an external identity provider (Entra ID, Auth0, Okta, Keycloak), configure OIDC middleware.
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = builder.Configuration["Oidc:Authority"];
options.ClientId = builder.Configuration["Oidc:ClientId"];
options.ClientSecret = builder.Configuration["Oidc:ClientSecret"];
options.ResponseType = OpenIdConnectResponseType.Code; // Authorization Code Flow
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.MapInboundClaims = false; // Preserve original claim types
options.TokenValidationParameters.NameClaimType = "name";
options.TokenValidationParameters.RoleClaimType = "roles";
});Gotcha: MapInboundClaims = false prevents the Microsoft OIDC handler from remapping standard JWT claims (e.g., sub to http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier). Set this to false to preserve the original claim types from the identity provider.
---
JWT Bearer Token Authentication
For API-only scenarios where the client sends a JWT in the Authorization header:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Jwt:Authority"];
options.Audience = builder.Configuration["Jwt:Audience"];
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ClockSkew = TimeSpan.FromMinutes(1) // Default is 5 min; tighten for security
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// Protect endpoints
app.MapGet("/api/profile", (ClaimsPrincipal user) =>
TypedResults.Ok(new { Name = user.Identity?.Name }))
.RequireAuthorization();Policy-Based Authorization
builder.Services.AddAuthorizationBuilder()
.AddPolicy("AdminOnly", policy =>
policy.RequireRole("Admin"))
.AddPolicy("PremiumUser", policy =>
policy.RequireClaim("subscription", "premium"))
.SetFallbackPolicy(new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build());---
Passkeys / WebAuthn (.NET 10)
.NET 10 introduces built-in passkey (WebAuthn/FIDO2) support for passwordless authentication. Passkeys use public-key cryptography and are phishing-resistant.
// .NET 10: Add passkey support to Identity
builder.Services.AddIdentityApiEndpoints<ApplicationUser>(options =>
{
options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders()
.AddPasskeys(); // Enable WebAuthn passkey authentication
var app = builder.Build();
app.MapIdentityApi<ApplicationUser>();
// Passkey registration and authentication endpoints are added automaticallyPasskey Registration Flow
1. Client calls /passkey/register/options to get a PublicKeyCredentialCreationOptions challenge 2. Client creates a credential using the Web Authentication API (navigator.credentials.create) 3. Client sends the attestation response to /passkey/register 4. Server validates and stores the credential
Passkey Authentication Flow
1. Client calls /passkey/login/options to get a PublicKeyCredentialRequestOptions challenge 2. Client signs the challenge using navigator.credentials.get 3. Client sends the assertion response to /passkey/login 4. Server validates the assertion and issues a session/token
Key benefits: No passwords to phish, no credentials stored server-side (only public keys), built-in resistance to replay attacks.
---
CORS Policies
Cross-Origin Resource Sharing (CORS) controls which origins can call your API. Always use explicit, named policies -- never use AllowAnyOrigin() in production.
builder.Services.AddCors(options =>
{
options.AddPolicy("Production", policy =>
{
policy.WithOrigins(
"https://app.example.com",
"https://admin.example.com")
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Content-Type", "Authorization")
.SetPreflightMaxAge(TimeSpan.FromMinutes(10)); // Cache preflight
});
options.AddPolicy("Development", policy =>
{
policy.WithOrigins("https://localhost:5173") // Vite dev server
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});
var app = builder.Build();
app.UseCors(app.Environment.IsDevelopment() ? "Development" : "Production");Common CORS Pitfalls
- `AllowAnyOrigin()` + `AllowCredentials()` is rejected at runtime by ASP.NET Core. But
SetIsOriginAllowed(_ => true)+AllowCredentials()silently allows all origins -- never use this pattern. - Preflight caching: Without
SetPreflightMaxAge, browsers send an OPTIONS request before every cross-origin request. Set a reasonable cache duration (10-60 minutes) to reduce latency. - Wildcard headers with credentials:
AllowAnyHeader()combined withAllowCredentials()works in ASP.NET Core but may behave unexpectedly in some browsers. Prefer explicit header lists. - CORS middleware order:
UseCors()must be called afterUseRouting()and beforeUseAuthorization().
---
Content Security Policy (CSP)
Content Security Policy headers prevent XSS, clickjacking, and other injection attacks by controlling which resources the browser can load.
app.Use(async (context, next) =>
{
// API-focused CSP -- restrict all content sources
context.Response.Headers.Append(
"Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'");
// Additional security headers
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
context.Response.Headers.Append("X-Frame-Options", "DENY");
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
context.Response.Headers.Append("Permissions-Policy",
"camera=(), microphone=(), geolocation=()");
await next();
});For APIs serving HTML responses (Razor Pages, Blazor Server), use a more permissive CSP with nonces:
app.Use(async (context, next) =>
{
var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
context.Items["CspNonce"] = nonce;
context.Response.Headers.Append(
"Content-Security-Policy",
$"default-src 'self'; script-src 'self' 'nonce-{nonce}'; style-src 'self' 'nonce-{nonce}'");
await next();
});---
Rate Limiting
ASP.NET Core includes built-in rate limiting middleware (Microsoft.AspNetCore.RateLimiting, .NET 7+). Four algorithms are available: fixed window, sliding window, token bucket, and concurrency limiter.
Fixed Window
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("fixed", limiterOptions =>
{
limiterOptions.PermitLimit = 100;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.QueueLimit = 0; // Reject immediately when limit reached
});
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
var app = builder.Build();
app.UseRateLimiter();
app.MapGet("/api/products", GetProducts)
.RequireRateLimiting("fixed");Sliding Window
builder.Services.AddRateLimiter(options =>
{
options.AddSlidingWindowLimiter("sliding", limiterOptions =>
{
limiterOptions.PermitLimit = 100;
limiterOptions.Window = TimeSpan.FromMinutes(1);
limiterOptions.SegmentsPerWindow = 6; // 10-second segments
limiterOptions.QueueLimit = 0;
});
});Token Bucket
builder.Services.AddRateLimiter(options =>
{
options.AddTokenBucketLimiter("token", limiterOptions =>
{
limiterOptions.TokenLimit = 100;
limiterOptions.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
limiterOptions.TokensPerPeriod = 10;
limiterOptions.QueueLimit = 0;
});
});Concurrency Limiter
builder.Services.AddRateLimiter(options =>
{
options.AddConcurrencyLimiter("concurrent", limiterOptions =>
{
limiterOptions.PermitLimit = 10; // Max 10 concurrent requests
limiterOptions.QueueLimit = 5;
limiterOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
});Per-User Rate Limiting
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("per-user", httpContext =>
{
var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? httpContext.Connection.RemoteIpAddress?.ToString()
?? "anonymous";
return RateLimitPartition.GetFixedWindowLimiter(userId,
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 60,
Window = TimeSpan.FromMinutes(1)
});
});
});Gotcha: UseRateLimiter() must be called after UseRouting() and before UseAuthorization() and endpoint mapping to apply correctly.
---
Agent Gotchas
1. Do not use `AllowAnyOrigin()` in production CORS policies -- always specify explicit origins. See [skill:dotnet-api] for CORS security implications. 2. Do not forget `MapInboundClaims = false` when using external OIDC providers -- without it, claim types are remapped to long XML namespace URIs, breaking role and name lookups. 3. Do not hardcode JWT signing keys in source code or `appsettings.json` -- use user secrets for development and environment variables or managed identity for production. See [skill:dotnet-api]. 4. Do not set `ClockSkew` to `TimeSpan.Zero` -- small clock differences between token issuer and validator will cause spurious 401 errors. Use 1-2 minutes. 5. Do not forget middleware order -- UseAuthentication() must come before UseAuthorization(), and UseCors() must come before UseAuthorization(). 6. Do not use `AllowAnyMethod()` and `AllowAnyHeader()` together in production -- explicitly list allowed methods and headers to follow the principle of least privilege. 7. Do not skip rate limiting on authentication endpoints -- /login and /register are common brute-force targets. Apply rate limiting to prevent credential stuffing. 8. Do not use exception-driven rejection in auth paths -- use defensive parsing (TryFromBase64String, length validation) on attacker-controlled input instead.
---
Prerequisites
- .NET 8.0+ (LTS baseline for Identity API endpoints, JWT bearer, CORS, rate limiting)
- .NET 10.0 for passkey/WebAuthn support
Microsoft.AspNetCore.Authentication.JwtBearerfor JWT bearer authenticationMicrosoft.AspNetCore.Authentication.OpenIdConnectfor OIDC integrationMicrosoft.AspNetCore.RateLimiting(included in shared framework .NET 7+)
---
References
API Surface Validation
Tools and workflows for validating and tracking the public API surface of .NET libraries. Covers three complementary approaches: PublicApiAnalyzers for text-file tracking of shipped/unshipped APIs with Roslyn diagnostics, the Verify snapshot pattern for reflection-based API surface snapshot testing, and ApiCompat CI enforcement for gating pull requests on API surface changes.
Version assumptions: .NET 8.0+ baseline. PublicApiAnalyzers 3.3+ (ships with Microsoft.CodeAnalysis.Analyzers or standalone Microsoft.CodeAnalysis.PublicApiAnalyzers). ApiCompat tooling included in .NET 8+ SDK.
PublicApiAnalyzers
PublicApiAnalyzers tracks every public API member in text files committed to source control. The analyzer enforces that new APIs go through an explicit "unshipped" phase before being marked "shipped," preventing accidental public API exposure and undocumented surface area changes.
Setup
Install the analyzer package:
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="3.3.*" PrivateAssets="all" />
</ItemGroup>Create the two tracking files at the project root (adjacent to the .csproj):
MyLib/
MyLib.csproj
PublicAPI.Shipped.txt # APIs shipped in released versions
PublicAPI.Unshipped.txt # APIs added since last releaseBoth files must exist, even if empty. Each must contain a header comment:
#nullable enableThe #nullable enable header tells the analyzer to track nullable annotations in API signatures. Without it, nullable context differences are ignored.
Diagnostic Rules
| Rule | Severity | Meaning |
|---|---|---|
| RS0016 | Warning | Public API member not declared in API tracking files |
| RS0017 | Warning | Public API member removed but still in tracking files |
| RS0024 | Warning | Public API member has wrong nullable annotation |
| RS0025 | Warning | Public API symbol marked shipped but has changed signature |
| RS0026 | Warning | New public API added without PublicAPI.Unshipped.txt entry |
| RS0036 | Warning | API file missing #nullable enable header |
| RS0037 | Warning | Public API declared but does not exist in source |
RS0016 is the most common diagnostic. When you add a new public or protected member, RS0016 fires until you add the member's signature to PublicAPI.Unshipped.txt. Use the code fix (lightbulb) in the IDE to automatically add the entry.
RS0017 fires when you remove or rename a public member but the old signature still exists in the tracking files. Remove the stale line from the appropriate file.
File Format
Each line in the tracking files represents one public API symbol using its documentation comment ID format:
#nullable enable
MyLib.Widget
MyLib.Widget.Widget() -> void
MyLib.Widget.Name.get -> string!
MyLib.Widget.Name.set -> void
MyLib.Widget.Calculate(int count) -> decimal
MyLib.Widget.CalculateAsync(int count, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<decimal>!
MyLib.IWidgetFactory
MyLib.IWidgetFactory.Create(string! name) -> MyLib.Widget!
MyLib.WidgetOptions
MyLib.WidgetOptions.WidgetOptions() -> void
MyLib.WidgetOptions.MaxRetries.get -> int
MyLib.WidgetOptions.MaxRetries.set -> voidKey formatting rules:
- The
!suffix denotes a non-nullable reference type in nullable-enabled context - The
?suffix denotes a nullable reference type or nullable value type - Constructors use the type name (e.g.,
Widget.Widget() -> void) - Properties expand to
.getand.setentries - Default parameter values are included in the signature
Shipped/Unshipped Lifecycle
The workflow across release cycles:
During development (between releases):
1. Add new public API member to source code 2. RS0016 fires -- member not tracked 3. Use code fix or manually add to PublicAPI.Unshipped.txt 4. RS0016 clears
At release time:
1. Move all entries from PublicAPI.Unshipped.txt to PublicAPI.Shipped.txt 2. Clear PublicAPI.Unshipped.txt back to just the #nullable enable header 3. Commit both files as part of the release PR 4. Tag the release
When removing a previously shipped API (major version):
1. Remove the member from source code 2. Remove the entry from PublicAPI.Shipped.txt 3. RS0017 clears (if it fired) 4. Document the removal in release notes
When removing an unshipped API (before release):
1. Remove the member from source code 2. Remove the entry from PublicAPI.Unshipped.txt 3. No SemVer impact -- the API was never released
Multi-TFM Projects
For multi-targeted projects, PublicApiAnalyzers supports per-TFM tracking files when the API surface differs across targets:
MyLib/
MyLib.csproj
PublicAPI.Shipped.txt # Shared across all TFMs
PublicAPI.Unshipped.txt # Shared across all TFMs
PublicAPI.Shipped.net8.0.txt # net8.0-specific APIs
PublicAPI.Unshipped.net8.0.txt # net8.0-specific APIs
PublicAPI.Shipped.net10.0.txt # net10.0-specific APIs
PublicAPI.Unshipped.net10.0.txt # net10.0-specific APIsThe shared files contain APIs common to all TFMs. The TFM-specific files contain APIs that only exist on that target. The analyzer merges them at build time.
To enable per-TFM files, add to the .csproj:
<PropertyGroup>
<RoslynPublicApiPerTfm>true</RoslynPublicApiPerTfm>
</PropertyGroup>See [skill:dotnet-tooling] for multi-TFM packaging mechanics.
Integrating with CI
PublicApiAnalyzers runs as part of the standard build. To enforce it in CI, ensure warnings are treated as errors for the RS-series rules:
<!-- In Directory.Build.props or the library .csproj -->
<PropertyGroup>
<WarningsAsErrors>$(WarningsAsErrors);RS0016;RS0017;RS0036;RS0037</WarningsAsErrors>
</PropertyGroup>This gates CI builds on any undeclared public API changes. Developers must explicitly update the tracking files before the build passes.
---
Verify API Surface Snapshot Pattern
Use the Verify library to snapshot-test the entire public API surface of an assembly. This approach uses reflection to enumerate all public types and members, producing a human-readable snapshot that is committed to source control and compared on every test run. Any change to the public API surface causes a test failure until the snapshot is explicitly approved.
This pattern complements PublicApiAnalyzers -- the analyzer catches changes at build time within the project, while the Verify snapshot catches changes from the perspective of a compiled assembly consumer.
For Verify fundamentals (setup, scrubbing, converters, diff tool integration, CI configuration), see [skill:dotnet-testing].
Extracting the Public API Surface
Create a helper method that reflects over an assembly to produce a stable, sorted representation of all public types and their members:
using System.Reflection;
using System.Text;
public static class PublicApiExtractor
{
public static string GetPublicApi(Assembly assembly)
{
var sb = new StringBuilder();
var publicTypes = assembly
.GetTypes()
.Where(t => t.IsPublic || t.IsNestedPublic)
.OrderBy(t => t.FullName, StringComparer.Ordinal);
foreach (var type in publicTypes)
{
AppendType(sb, type);
}
return sb.ToString();
}
private static void AppendType(StringBuilder sb, Type type)
{
var kind = type switch
{
{ IsEnum: true } => "enum",
{ IsValueType: true } => "struct",
{ IsInterface: true } => "interface",
{ IsAbstract: true, IsSealed: true } => "static class",
{ IsAbstract: true } => "abstract class",
{ IsSealed: true } => "sealed class",
_ => "class"
};
sb.AppendLine($"{kind} {type.FullName}");
var members = type
.GetMembers(BindingFlags.Public | BindingFlags.Instance
| BindingFlags.Static | BindingFlags.DeclaredOnly)
.OrderBy(m => m.MemberType)
.ThenBy(m => m.Name, StringComparer.Ordinal)
.ThenBy(m => m.ToString(), StringComparer.Ordinal);
foreach (var member in members)
{
sb.AppendLine($" {FormatMember(member)}");
}
sb.AppendLine();
}
private static string FormatMember(MemberInfo member) =>
member switch
{
ConstructorInfo c => $".ctor({FormatParameters(c.GetParameters())})",
MethodInfo m when !m.IsSpecialName =>
$"{m.ReturnType.Name} {m.Name}({FormatParameters(m.GetParameters())})",
PropertyInfo p => $"{p.PropertyType.Name} {p.Name} {{ {GetAccessors(p)} }}",
FieldInfo f => $"{f.FieldType.Name} {f.Name}",
EventInfo e => $"event {e.EventHandlerType?.Name} {e.Name}",
_ => member.ToString() ?? string.Empty
};
private static string FormatParameters(ParameterInfo[] parameters) =>
string.Join(", ", parameters.Select(p => $"{p.ParameterType.Name} {p.Name}"));
private static string GetAccessors(PropertyInfo prop)
{
var parts = new List<string>();
if (prop.GetMethod?.IsPublic == true) parts.Add("get;");
if (prop.SetMethod?.IsPublic == true) parts.Add("set;");
return string.Join(" ", parts);
}
}Writing the Snapshot Test
[UsesVerify]
public class PublicApiSurfaceTests
{
[Fact]
public Task PublicApi_ShouldMatchApprovedSurface()
{
var assembly = typeof(Widget).Assembly;
var publicApi = PublicApiExtractor.GetPublicApi(assembly);
return Verify(publicApi);
}
}On first run, this creates a .verified.txt file containing the full public API listing. Subsequent runs compare the current API surface against the approved snapshot. Any addition, removal, or modification of public members causes a test failure with a clear diff.
Reviewing API Surface Changes
When the snapshot test fails:
1. Verify generates a .received.txt file showing the new API surface 2. Diff the .received.txt against .verified.txt to review changes 3. If the changes are intentional, accept the new snapshot with verify accept 4. If the changes are accidental, revert the code changes
This creates a code-review checkpoint where every public API change must be explicitly approved by someone reviewing the snapshot diff in the pull request.
Combining with PublicApiAnalyzers
The two approaches serve different purposes:
| Concern | PublicApiAnalyzers | Verify Snapshot |
|---|---|---|
| Detection timing | Build time (in-IDE) | Test time (post-compile) |
| Granularity | Per-member signatures | Assembly-wide surface |
| Nullable annotations | Tracked via #nullable enable | Requires explicit reflection |
| Approval workflow | Edit text files (shipped/unshipped) | Accept snapshot diffs |
| Multi-TFM | Per-TFM files | Per-TFM test targets |
| CI gating | Warnings-as-errors | Test failures |
Use both for maximum coverage: PublicApiAnalyzers catches changes during development, while Verify snapshots provide an end-to-end assembly-level validation in the test suite.
---
ApiCompat CI Enforcement
ApiCompat compares two assemblies (or a baseline NuGet package against the current build) and reports API differences. When integrated into CI, it gates pull requests on API surface changes -- any breaking change produces a build error that the author must explicitly acknowledge.
For EnablePackageValidation basics and suppression file mechanics, see [skill:dotnet-devops] and [skill:dotnet-tooling].
Package Validation in CI
The simplest enforcement uses EnablePackageValidation during dotnet pack:
<PropertyGroup>
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>1.2.0</PackageValidationBaselineVersion>
</PropertyGroup>In a CI pipeline, dotnet pack runs package validation automatically:
# GitHub Actions -- gate PRs on API compatibility
name: API Compatibility Check
on:
pull_request:
paths:
- 'src/**'
- '*.props'
- '*.targets'
jobs:
api-compat:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Pack with API validation
run: dotnet pack --configuration Release --no-build
# EnablePackageValidation runs during pack and fails
# the build if breaking changes are detectedStandalone ApiCompat Tool for Assembly Comparison
When you need to compare assemblies without packing (e.g., comparing a feature branch build against the main branch build), use the standalone ApiCompat tool:
# GitHub Actions -- compare assemblies directly
name: API Diff Check
on:
pull_request:
jobs:
api-diff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Install ApiCompat tool
run: dotnet tool install --global Microsoft.DotNet.ApiCompat.Tool
- name: Build current branch
run: dotnet build src/MyLib/MyLib.csproj -c Release -o artifacts/current
- name: Build baseline (main branch)
run: |
git stash
git checkout origin/main -- src/MyLib/
dotnet build src/MyLib/MyLib.csproj -c Release -o artifacts/baseline
git checkout - -- src/MyLib/
git stash pop || true
- name: Compare APIs
run: |
apicompat --left-assembly artifacts/baseline/MyLib.dll \
--right-assembly artifacts/current/MyLib.dllPR Labeling for API Changes
Combine ApiCompat with PR labeling to surface API changes to reviewers:
- name: Check for API changes
id: api-check
continue-on-error: true
run: |
apicompat --left-assembly artifacts/baseline/MyLib.dll \
--right-assembly artifacts/current/MyLib.dll 2>&1 | tee api-diff.txt
echo "has_changes=$([[ -s api-diff.txt ]] && echo true || echo false)" >> "$GITHUB_OUTPUT"
- name: Label PR with API changes
if: steps.api-check.outputs.has_changes == 'true'
run: gh pr edit "${{ github.event.pull_request.number }}" --add-label "api-change"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}Handling Intentional Breaking Changes
When a breaking change is intentional (new major version), generate a suppression file:
dotnet pack /p:GenerateCompatibilitySuppressionFile=trueThis creates CompatibilitySuppressions.xml in the project directory. Reference it explicitly if stored elsewhere:
<ItemGroup>
<ApiCompatSuppressionFile Include="CompatibilitySuppressions.xml" />
</ItemGroup>Note: ApiCompatSuppressionFile is an ItemGroup item, not a PropertyGroup property. Using PropertyGroup syntax silently does nothing.
The suppression file documents the specific breaking changes that are accepted:
<?xml version="1.0" encoding="utf-8"?>
<Suppressions xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:MyLib.Widget.Calculate</Target>
<Left>lib/net8.0/MyLib.dll</Left>
<Right>lib/net8.0/MyLib.dll</Right>
</Suppression>
</Suppressions>Commit suppression files to source control. Reviewers can inspect the file to verify that breaking changes are documented and intentional.
Enforcing PublicApiAnalyzers Files in CI
Combine PublicApiAnalyzers warnings-as-errors with a CI step that verifies tracking files are not stale:
- name: Build with API tracking enforcement
run: dotnet build -c Release /p:TreatWarningsAsErrors=true /warnaserror:RS0016,RS0017,RS0036,RS0037
- name: Verify PublicAPI files are committed
run: |
if git diff --name-only | grep -q 'PublicAPI'; then
echo "::error::PublicAPI tracking files have uncommitted changes"
git diff -- '**/PublicAPI.*.txt'
exit 1
fiMulti-Library Monorepo Enforcement
For repositories with multiple libraries, apply API validation at the solution level:
<!-- Directory.Build.props -- applied to all library projects -->
<Project>
<PropertyGroup Condition="'$(IsPackable)' == 'true'">
<EnablePackageValidation>true</EnablePackageValidation>
<WarningsAsErrors>$(WarningsAsErrors);RS0016;RS0017;RS0036;RS0037</WarningsAsErrors>
</PropertyGroup>
<ItemGroup Condition="'$(IsPackable)' == 'true'">
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers"
Version="3.3.*" PrivateAssets="all" />
</ItemGroup>
</Project>This ensures every packable project in the repository has both PublicApiAnalyzers and package validation enabled without duplicating configuration.
---
Agent Gotchas
1. Do not forget to create both `PublicAPI.Shipped.txt` and `PublicAPI.Unshipped.txt` -- PublicApiAnalyzers requires both files to exist, even if empty. Missing files cause RS0037 warnings on every public member. 2. Do not omit the `#nullable enable` header from PublicAPI tracking files -- without it (RS0036), the analyzer ignores nullable annotation differences, missing real API surface changes in nullable-enabled libraries. 3. Do not put `ApiCompatSuppressionFile` in a PropertyGroup -- it is an ItemGroup item (<ApiCompatSuppressionFile Include="..." />). PropertyGroup syntax is silently ignored, and suppression will not work. 4. Do not move entries from `PublicAPI.Unshipped.txt` to `PublicAPI.Shipped.txt` mid-development -- move entries only at release time. Premature shipping makes it impossible to cleanly revert unreleased API additions. 5. Do not use the Verify API surface snapshot as the sole validation mechanism -- it runs at test time, after compilation. Use PublicApiAnalyzers for immediate build-time feedback and ApiCompat for baseline comparison; add Verify snapshots as an additional safety net. 6. Do not hardcode TFM-specific paths in CI ApiCompat workflows -- use MSBuild output path variables or parameterize the TFM to avoid breakage when TFMs are added or changed. 7. Do not suppress RS0016 globally with `<NoWarn>` -- this silently disables all public API tracking. Instead, add the missing API entries to the tracking files. If an API is intentionally internal but must be public (e.g., for InternalsVisibleTo alternatives), use [EditorBrowsable(EditorBrowsableState.Never)] and add it to the tracking files. 8. Do not generate the suppression file with `GenerateCompatibilitySuppressionFile=true` and forget to review it -- the file may suppress more changes than intended. Always review the generated XML before committing.
---
Prerequisites
- .NET 8.0+ SDK
Microsoft.CodeAnalysis.PublicApiAnalyzersNuGet package (for RS0016/RS0017 diagnostics)EnablePackageValidationMSBuild property (for baseline API comparison duringdotnet pack)Microsoft.DotNet.ApiCompat.Tool(optional, for standalone assembly comparison outside ofdotnet pack)- Verify test library and test framework integration package (for API surface snapshot testing) -- see [skill:dotnet-testing] for setup
- Understanding of binary vs source compatibility rules -- see [skill:dotnet-api]
---
References
API Versioning
API versioning strategies for ASP.NET Core using the Asp.Versioning library family. URL segment versioning (/api/v1/) is the preferred approach for simplicity and discoverability. This skill covers URL, header, and query string versioning with configuration for both Minimal APIs and MVC controllers, sunset policy enforcement, and migration from legacy packages.
Package Landscape
| Package | Target | Status |
|---|---|---|
Asp.Versioning.Http | Minimal APIs | Current |
Asp.Versioning.Mvc.ApiExplorer | MVC controllers + API Explorer | Current |
Asp.Versioning.Mvc | MVC controllers (no API Explorer) | Current |
Microsoft.AspNetCore.Mvc.Versioning | MVC controllers | Legacy -- migrate to Asp.Versioning.Mvc |
Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer | MVC + API Explorer | Legacy -- migrate to Asp.Versioning.Mvc.ApiExplorer |
Install for Minimal APIs:
<PackageReference Include="Asp.Versioning.Http" Version="8.*" />Install for MVC controllers:
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.*" />---
URL Segment Versioning (Preferred)
URL segment versioning embeds the version in the path (/api/v1/products). It is the simplest strategy, works with all HTTP clients, is cacheable, and clearly visible in logs and documentation.
Minimal APIs
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true; // Adds api-supported-versions header
options.ApiVersionReader = new UrlSegmentApiVersionReader();
});
var app = builder.Build();
var versionSet = app.NewApiVersionSet()
.HasApiVersion(new ApiVersion(1, 0))
.HasApiVersion(new ApiVersion(2, 0))
.ReportApiVersions()
.Build();
var v1 = app.MapGroup("/api/v{version:apiVersion}/products")
.WithApiVersionSet(versionSet)
.MapToApiVersion(new ApiVersion(1, 0));
var v2 = app.MapGroup("/api/v{version:apiVersion}/products")
.WithApiVersionSet(versionSet)
.MapToApiVersion(new ApiVersion(2, 0));
// V1: returns basic product info
v1.MapGet("/", async (AppDbContext db) =>
TypedResults.Ok(await db.Products
.Select(p => new ProductV1Dto(p.Id, p.Name, p.Price))
.ToListAsync()));
// V2: returns extended product info with category
v2.MapGet("/", async (AppDbContext db) =>
TypedResults.Ok(await db.Products
.Select(p => new ProductV2Dto(p.Id, p.Name, p.Price, p.Category, p.CreatedAt))
.ToListAsync()));MVC Controllers
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
})
.AddMvc()
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV"; // e.g., v1, v2
options.SubstituteApiVersionInUrl = true;
});
// V1 controller
[ApiController]
[Route("api/v{version:apiVersion}/products")]
[ApiVersion("1.0")]
public sealed class ProductsController(AppDbContext db) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll() =>
Ok(await db.Products
.Select(p => new ProductV1Dto(p.Id, p.Name, p.Price))
.ToListAsync());
}
// V2 controller -- use explicit route, not [controller] token
[ApiController]
[Route("api/v{version:apiVersion}/products")]
[ApiVersion("2.0")]
public sealed class ProductsV2Controller(AppDbContext db) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAll() =>
Ok(await db.Products
.Select(p => new ProductV2Dto(p.Id, p.Name, p.Price, p.Category, p.CreatedAt))
.ToListAsync());
}---
Header Versioning
Header versioning reads the API version from a custom request header. Keeps URLs clean but is less discoverable and harder to test from a browser.
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new HeaderApiVersionReader("X-Api-Version");
});Client request:
GET /api/products HTTP/1.1
Host: api.example.com
X-Api-Version: 2.0---
Query String Versioning
Query string versioning uses a query parameter (default: api-version). Simple to use but pollutes URLs and may conflict with caching strategies.
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new QueryStringApiVersionReader("api-version");
});Client request:
GET /api/products?api-version=2.0 HTTP/1.1
Host: api.example.com---
Combining Version Readers
Multiple readers can be combined. The first reader that resolves a version wins. This is useful during migration from one strategy to another:
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("X-Api-Version"),
new QueryStringApiVersionReader("api-version"));---
Sunset Policies
Sunset policies communicate to consumers that an API version is deprecated and will be removed. The Sunset HTTP response header follows RFC 8594.
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(2, 0);
options.ReportApiVersions = true;
options.Policies.Sunset(1.0)
.Effective(new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero))
.Link("https://docs.example.com/api/migration-v1-to-v2")
.Title("V1 to V2 Migration Guide")
.Type("text/html");
});Response headers for a v1 request:
api-supported-versions: 1.0, 2.0
api-deprecated-versions: 1.0
Sunset: Sun, 01 Jun 2026 00:00:00 GMT
Link: <https://docs.example.com/api/migration-v1-to-v2>; rel="sunset"; title="V1 to V2 Migration Guide"; type="text/html"Deprecating a Version
Mark a version as deprecated using the version set (Minimal APIs) or attribute (MVC):
// Minimal APIs
var versionSet = app.NewApiVersionSet()
.HasApiVersion(new ApiVersion(1, 0))
.HasDeprecatedApiVersion(new ApiVersion(1, 0))
.HasApiVersion(new ApiVersion(2, 0))
.ReportApiVersions()
.Build();
// MVC controllers
[ApiVersion("1.0", Deprecated = true)]
[ApiVersion("2.0")]
public sealed class ProductsController : ControllerBase { }---
Migration from Legacy Packages
Projects using Microsoft.AspNetCore.Mvc.Versioning should migrate to Asp.Versioning.Mvc (or Asp.Versioning.Http for Minimal APIs). The API surface is largely compatible with namespace changes:
| Legacy namespace | Current namespace |
|---|---|
Microsoft.AspNetCore.Mvc.Versioning | Asp.Versioning |
Microsoft.AspNetCore.Mvc.ApiExplorer | Asp.Versioning.ApiExplorer |
Key migration steps: 1. Replace NuGet package references 2. Update using directives from Microsoft.AspNetCore.Mvc.Versioning to Asp.Versioning 3. Update service registration from services.AddApiVersioning() (legacy extension) to the current extension from Asp.Versioning 4. Review any custom IApiVersionReader implementations for breaking changes
See the migration guide for detailed steps.
---
Version Strategy Decision Guide
| Strategy | Pros | Cons | Best for |
|---|---|---|---|
URL segment (/api/v1/) | Simple, visible, cacheable, works everywhere | URL changes per version | Public APIs, most projects (preferred) |
Header (X-Api-Version: 1.0) | Clean URLs, no path changes | Less discoverable, harder to test | Internal APIs with controlled clients |
Query string (?api-version=1.0) | Easy to add, no path changes | Pollutes URL, cache key issues | Quick prototyping, legacy compatibility |
Recommendation: Start with URL segment versioning for all new projects. Add header or query string readers only when migrating from an existing strategy or when specific client constraints require it.
---
Agent Gotchas
1. Do not use the legacy `Microsoft.AspNetCore.Mvc.Versioning` package for new projects -- use Asp.Versioning.Http (Minimal APIs) or Asp.Versioning.Mvc (MVC controllers). 2. Do not hardcode version numbers in package references -- use version ranges (e.g., 8.*) so the package version matches the latest compatible release. 3. Do not forget `ReportApiVersions = true` -- without it, clients cannot discover available versions from response headers. 4. Do not mix `MapToApiVersion` and route group prefixes inconsistently -- each route group should target exactly one API version. 5. Do not deprecate a version without a sunset policy -- always provide a sunset date and migration link so consumers can plan. 6. Do not use `AssumeDefaultVersionWhenUnspecified = true` for public APIs -- it hides versioning requirements from consumers. Require explicit version selection instead.
---
Prerequisites
- .NET 8.0+ (LTS baseline)
Asp.Versioning.Httpfor Minimal APIsAsp.Versioning.Mvc.ApiExplorerfor MVC controllers with API Explorer integration
---
References
Architecture Patterns
Modern architecture patterns for .NET applications. Covers practical approaches to organizing minimal APIs at scale, vertical slice architecture, request pipeline composition, validation strategies, caching, error handling, and idempotency/outbox patterns.
Agent Gotchas
1. Idempotency must handle three states and finalize unconditionally -- Distinguish no-record (claim), in-progress (reject 409), and completed (replay). Do NOT gate finalization on specific IResult subtypes -- non-value results like Results.NoContent() would be stuck permanently in-progress. 2. Cache invalidation must be explicit -- ALWAYS invalidate (evict by tag or key) after write operations. Forgetting invalidation causes stale reads. 3. HybridCache stampede protection only works with `GetOrCreateAsync` -- Do NOT use separate get-then-set; use the factory overload so the library serializes concurrent requests for the same key. 4. Outbox messages must be written in the same transaction as domain data -- A crash between separate writes loses the event. ALWAYS wrap both in BeginTransactionAsync. 5. Endpoint filter order matters -- Filters added first run outermost. Validation must run before idempotency, otherwise invalid requests get cached. 6. Do NOT share `DbContext` across concurrent requests -- DbContext is not thread-safe. Each request must resolve its own scoped instance from DI.
---
Knowledge Sources
Grounded in publicly available content from Jimmy Bogard (vertical slice architecture, domain events) and Nick Chapsas (result types, modern .NET patterns). This skill applies publicly documented guidance and does not represent or speak for the named sources. MediatR is commercial for commercial use; patterns here use built-in .NET mechanisms.
References
- ASP.NET Core Best Practices
- HybridCache library
- Endpoint filters in minimal APIs
- Vertical Slice Architecture (Jimmy Bogard)
---
Architecture Patterns -- Detailed Examples
Extended code examples for vertical slices, minimal API organization, request pipeline, error handling, validation, caching, idempotency, and outbox patterns.
---
Vertical Slice Architecture
Organize code by feature (vertical slice) rather than by technical layer (controllers, services, repositories). Each slice owns its endpoint, handler, validation, and data access.
Directory Structure
Features/
Orders/
CreateOrder/
CreateOrderEndpoint.cs
CreateOrderHandler.cs
CreateOrderRequest.cs
CreateOrderValidator.cs
GetOrder/
GetOrderEndpoint.cs
GetOrderHandler.cs
ListOrders/
ListOrdersEndpoint.cs
ListOrdersHandler.cs
Products/
GetProduct/
...Benefits: Low coupling (feature changes don't ripple), easy navigation, independent testability, team scalability.
Each slice contains: Request/Response DTOs (contract), Validator (input rules), Handler (business logic), Endpoint (HTTP mapping).
public sealed record CreateOrderRequest(
string CustomerId, List<OrderLineRequest> Lines);
public sealed record OrderLineRequest(string ProductId, int Quantity);
public sealed record CreateOrderResponse(
string OrderId, decimal Total, DateTimeOffset CreatedAt);---
Minimal API Organization at Scale
Route Group Pattern
Use MapGroup to organize related endpoints and apply shared filters:
// Program.cs -- register feature groups
app.MapGroup("/api/orders").WithTags("Orders").MapOrderEndpoints();
app.MapGroup("/api/products").WithTags("Products").MapProductEndpoints();
// Features/Orders/OrderEndpoints.cs
public static class OrderEndpoints
{
public static RouteGroupBuilder MapOrderEndpoints(this RouteGroupBuilder group)
{
group.MapPost("/", CreateOrderEndpoint.Handle)
.WithName("CreateOrder")
.Produces<CreateOrderResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem();
group.MapGet("/{id}", GetOrderEndpoint.Handle)
.WithName("GetOrder")
.Produces<OrderResponse>()
.ProducesProblem(StatusCodes.Status404NotFound);
return group;
}
}Endpoint Classes
Keep each endpoint in its own static class with a single Handle method:
public static class CreateOrderEndpoint
{
public static async Task<IResult> Handle(
CreateOrderRequest request,
IValidator<CreateOrderRequest> validator,
IOrderService orderService,
CancellationToken ct)
{
var validation = await validator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
return Results.ValidationProblem(validation.ToDictionary());
}
var order = await orderService.CreateAsync(request, ct);
return Results.Created($"/api/orders/{order.OrderId}", order);
}
}---
Request Pipeline Composition
Endpoint Filters (Middleware for Endpoints)
Use endpoint filters for cross-cutting concerns scoped to specific routes:
// Validation filter applied to a route group
public sealed class ValidationFilter<TRequest> : IEndpointFilter
where TRequest : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var request = context.Arguments.OfType<TRequest>().FirstOrDefault();
if (request is null)
{
return Results.BadRequest();
}
var validator = context.HttpContext.RequestServices
.GetService<IValidator<TRequest>>();
if (validator is not null)
{
var result = await validator.ValidateAsync(request);
if (!result.IsValid)
{
return Results.ValidationProblem(result.ToDictionary());
}
}
return await next(context);
}
}
// Usage
group.MapPost("/", CreateOrderEndpoint.Handle)
.AddEndpointFilter<ValidationFilter<CreateOrderRequest>>();Pipeline Order
The standard middleware pipeline order matters:
app.UseExceptionHandler(); // 1. Global error handling
app.UseStatusCodePages(); // 2. Status code formatting
app.UseRateLimiter(); // 3. Rate limiting
app.UseAuthentication(); // 4. Authentication
app.UseAuthorization(); // 5. Authorization
// Endpoint routing happens here---
Error Handling
Problem Details (RFC 9457)
Use the built-in Problem Details support for consistent error responses:
// Program.cs
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Extensions["traceId"] =
context.HttpContext.TraceIdentifier;
};
});
app.UseExceptionHandler();
app.UseStatusCodePages();Result Pattern for Business Logic
Return a result type from handlers instead of throwing exceptions for expected business failures:
public abstract record Result<T>
{
public sealed record Success(T Value) : Result<T>;
public sealed record NotFound(string Message) : Result<T>;
public sealed record ValidationFailed(IDictionary<string, string[]> Errors) : Result<T>;
public sealed record Conflict(string Message) : Result<T>;
}
// In the handler
public async Task<Result<Order>> CreateAsync(
CreateOrderRequest request,
CancellationToken ct)
{
var customer = await _db.Customers.FindAsync([request.CustomerId], ct);
if (customer is null)
{
return new Result<Order>.NotFound($"Customer {request.CustomerId} not found");
}
// ... create order
return new Result<Order>.Success(order);
}
// In the endpoint -- map result to HTTP response
return result switch
{
Result<Order>.Success s => Results.Created($"/api/orders/{s.Value.Id}", s.Value),
Result<Order>.NotFound n => Results.Problem(n.Message, statusCode: 404),
Result<Order>.ValidationFailed v => Results.ValidationProblem(v.Errors),
Result<Order>.Conflict c => Results.Problem(c.Message, statusCode: 409),
_ => Results.Problem("Unexpected error", statusCode: 500)
};---
Validation Strategy
Choose validation based on complexity. For .NET 10+, prefer the built-in AddValidation() source-generator pipeline (see [skill:dotnet-csharp]). For detailed framework guidance, see [skill:dotnet-csharp].
Data Annotations (simple): Use [Required], [MaxLength], [Range] on record properties. In minimal APIs, validate via MiniValidation (MiniValidator.TryValidate) or the .NET 10+ built-in pipeline.
FluentValidation (complex): Use for cross-property rules, conditional logic, or database-dependent checks:
builder.Services.AddValidatorsFromAssemblyContaining<Program>(ServiceLifetime.Scoped);
public sealed class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerId).NotEmpty().MaximumLength(50);
RuleFor(x => x.Lines).NotEmpty()
.WithMessage("Order must have at least one line item");
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(l => l.ProductId).NotEmpty();
line.RuleFor(l => l.Quantity).GreaterThan(0);
});
}
}---
Caching Strategy
Choose the right caching level:
- Output Caching -- HTTP response caching via
AddOutputCache(). Use.CacheOutput("PolicyName")on endpoints andEvictByTagAsyncto invalidate after writes. Best for read-heavy GET endpoints. - Distributed Caching -- Application-level caching via
IDistributedCache(e.g.,AddStackExchangeRedisCache()). Manual get/set with serialization. Use when sharing cached data across app instances. - HybridCache (.NET 9+) -- Preferred for new projects. Combines L1 (in-memory) + L2 (distributed) with built-in stampede protection.
HybridCache (Primary Pattern)
builder.Services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10),
LocalCacheExpiration = TimeSpan.FromMinutes(2)
};
});
// Stampede-safe, two-tier -- always use GetOrCreateAsync (not separate get/set)
public sealed class ProductService(HybridCache cache, AppDbContext db)
{
public async Task<Product?> GetByIdAsync(
string id, CancellationToken ct = default)
{
return await cache.GetOrCreateAsync(
$"product:{id}",
async cancel => await db.Products.FindAsync([id], cancel),
cancellationToken: ct);
}
}Output Caching Example
builder.Services.AddOutputCache(options =>
{
options.AddBasePolicy(p => p.NoCache());
options.AddPolicy("ProductList", p =>
p.Expire(TimeSpan.FromMinutes(5)).Tag("products"));
});
app.UseOutputCache();
group.MapGet("/", ListProductsEndpoint.Handle)
.CacheOutput("ProductList");
// Always invalidate after writes
app.MapPost("/api/products", async (IOutputCacheStore cache, /* ... */) =>
{
// ... create product
await cache.EvictByTagAsync("products", ct);
return Results.Created(/* ... */);
});---
Idempotency and Outbox Pattern
Idempotency Keys
Prevent duplicate processing of retried requests. A robust idempotency implementation must:
1. Scope keys by route + user/tenant to prevent cross-endpoint collisions 2. Atomically claim the key before executing, so concurrent duplicates are rejected 3. Store a concrete response envelope (not an IResult reference) for safe replay
Database-Backed Idempotency (Recommended)
Use a database row with a unique constraint for atomic claim-then-execute:
public sealed class IdempotencyRecord
{
public required string Key { get; init; }
public required string RequestRoute { get; init; }
public required string? UserId { get; init; }
public int StatusCode { get; set; }
public string? ResponseBody { get; set; }
public string? ContentType { get; set; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public bool IsCompleted { get; set; }
}
public sealed class IdempotencyFilter(AppDbContext db) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var httpContext = context.HttpContext;
if (!httpContext.Request.Headers.TryGetValue(
"Idempotency-Key", out var keyValues))
return await next(context);
var clientKey = keyValues.ToString();
if (string.IsNullOrWhiteSpace(clientKey) || clientKey.Length > 256)
return Results.Problem("Invalid Idempotency-Key", statusCode: 400);
// Scope key: route + user + client key
var route = $"{httpContext.Request.Method}:{httpContext.Request.Path}";
var userId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous";
var scopedKey = $"{route}:{userId}:{clientKey}";
var existing = await db.IdempotencyRecords
.FirstOrDefaultAsync(r => r.Key == scopedKey);
// Completed: replay cached response
if (existing is { IsCompleted: true })
{
return existing.ResponseBody is not null
? Results.Text(existing.ResponseBody,
existing.ContentType ?? "application/json",
statusCode: existing.StatusCode)
: Results.StatusCode(existing.StatusCode);
}
// In-progress: reject duplicate
if (existing is { IsCompleted: false })
return Results.Problem("Duplicate request in progress", statusCode: 409);
// Claim: unique constraint prevents concurrent duplicates
var record = new IdempotencyRecord
{
Key = scopedKey, RequestRoute = route,
UserId = userId, IsCompleted = false
};
db.IdempotencyRecords.Add(record);
try { await db.SaveChangesAsync(); }
catch (DbUpdateException)
{
return Results.Problem("Duplicate request in progress", statusCode: 409);
}
var result = await next(context);
// Finalize unconditionally -- handles value and non-value results
record.StatusCode = result is IStatusCodeHttpResult sc
? sc.StatusCode ?? 200 : 200;
record.ResponseBody = result is IValueHttpResult vr
? JsonSerializer.Serialize(vr.Value) : null;
record.ContentType = record.ResponseBody is not null
? "application/json" : null;
record.IsCompleted = true;
await db.SaveChangesAsync();
return result;
}
}Key design choices:
- Three states: no record (claim), in-progress (reject 409), completed (replay)
- Unique constraint on
Keyprovides atomic claim without distributed locks - Scoped key (
route:userId:clientKey) prevents cross-endpoint and cross-tenant collisions - Response envelope stores serialized body + status code (not
IResultreferences) - Consider a cleanup job for abandoned in-progress records (process crash scenarios)
Transactional Outbox Pattern
Guarantee at-least-once delivery of domain events alongside database writes:
// 1. Store outbox messages in the same transaction as the domain write
public sealed class OutboxMessage
{
public Guid Id { get; init; } = Guid.NewGuid();
public required string EventType { get; init; }
public required string Payload { get; init; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset? ProcessedAt { get; set; }
}
// 2. In the handler -- same DbContext transaction
public async Task<Order> CreateOrderAsync(
CreateOrderRequest request,
CancellationToken ct)
{
await using var transaction = await _db.Database
.BeginTransactionAsync(ct);
var order = new Order { /* ... */ };
_db.Orders.Add(order);
_db.OutboxMessages.Add(new OutboxMessage
{
EventType = "OrderCreated",
Payload = JsonSerializer.Serialize(
new OrderCreatedEvent(order.Id, order.Total))
});
await _db.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
return order;
}
// 3. Background processor publishes outbox messages
// See [skill:dotnet-api] (`references/background-services.md`) for the
// Channels-based processor that polls and publishes these messages.The outbox pattern ensures that if the database write succeeds, the event is guaranteed to be published (eventually), even if the message broker is temporarily unavailable.
---
Key Principles
- Prefer composition -- endpoint filters, middleware, and pipeline composition over base classes
- Keep slices independent -- DRY applies to knowledge duplication, not code similarity across features
- Validate early, fail fast -- validate at the boundary before entering business logic
- Use Problem Details everywhere -- consistent error format via RFC 9457
- Make writes idempotent -- use idempotency keys for retryable operations
- See [skill:dotnet-csharp] for SOLID anti-patterns and compliance guidance
---
Aspire Patterns
.NET Aspire orchestration patterns for building cloud-ready distributed applications. Covers AppHost configuration, service discovery, the component model for integrating backing services (databases, caches, message brokers), the Aspire dashboard for local observability, distributed health checks, and when to choose Aspire vs manual container orchestration.
Aspire Overview
.NET Aspire is an opinionated stack for building observable, production-ready distributed applications. It provides:
- Orchestration -- define your distributed topology in C# (the AppHost)
- Components -- pre-configured NuGet packages for common backing services
- Service Defaults -- shared configuration for OpenTelemetry, health checks, resilience
- Dashboard -- local development UI for traces, logs, metrics, and resource status
Aspire is not a deployment target. It orchestrates the local development and testing experience. For production, it generates manifests consumed by deployment tools (Azure Developer CLI, Kubernetes, etc.).
When to Use Aspire
| Scenario | Recommendation |
|---|---|
| Multiple .NET services + backing infrastructure | Aspire AppHost -- simplifies local dev and service wiring |
| Single API with a database | Optional -- Aspire adds overhead for simple topologies |
| Non-.NET services only (Node, Python) | Aspire can reference container images, but the tooling benefit is reduced |
| Need Kubernetes/Compose for local dev already | Evaluate migration cost; Aspire replaces docker-compose for dev scenarios |
| Team needs consistent observability defaults | Aspire ServiceDefaults standardize OTel across all projects |
---
AppHost Configuration
The AppHost is a .NET project (Aspire.Hosting.AppHost SDK) that defines the distributed application topology. It references other projects and backing services, wiring them together with service discovery.
AppHost Project Setup
<Project Sdk="Microsoft.NET.Sdk">
<!-- Aspire SDK version is independent of .NET TFM; 9.x works on net8.0+ -->
<Sdk Name="Aspire.AppHost.Sdk" Version="9.1.*" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsAspireHost>true</IsAspireHost>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.1.*" />
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="9.1.*" />
<PackageReference Include="Aspire.Hosting.Redis" Version="9.1.*" />
<PackageReference Include="Aspire.Hosting.RabbitMQ" Version="9.1.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyApi\MyApi.csproj" />
<ProjectReference Include="..\MyWorker\MyWorker.csproj" />
</ItemGroup>
</Project>Defining the Topology
var builder = DistributedApplication.CreateBuilder(args);
// Backing services -- Aspire manages containers automatically
var postgres = builder.AddPostgres("pg")
.WithPgAdmin() // Adds pgAdmin UI container
.AddDatabase("ordersdb");
var redis = builder.AddRedis("cache")
.WithRedisCommander(); // Adds Redis Commander UI
var rabbitmq = builder.AddRabbitMQ("messaging")
.WithManagementPlugin(); // Adds RabbitMQ management UI
// Application projects -- wired with service discovery
var api = builder.AddProject<Projects.MyApi>("api")
.WithReference(postgres)
.WithReference(redis)
.WithReference(rabbitmq)
.WithExternalHttpEndpoints(); // Marks endpoints as public in deployment manifests
builder.AddProject<Projects.MyWorker>("worker")
.WithReference(postgres)
.WithReference(rabbitmq)
.WaitFor(api); // Start worker after API is healthy
builder.Build().Run();Resource Lifecycle
WaitFor controls startup ordering. Resources wait until dependencies report healthy before starting:
// Worker waits for both the database and API to be ready
builder.AddProject<Projects.MyWorker>("worker")
.WithReference(postgres)
.WaitFor(postgres) // Wait for database container health check
.WaitFor(api); // Wait for API health endpointWithout WaitFor, resources start in parallel. Use it only when startup order matters (e.g., a worker that requires the database schema to exist).
---
Service Discovery
Aspire automatically configures service discovery so projects can resolve each other by resource name rather than hardcoded URLs.
How It Works
1. The AppHost injects endpoint information as environment variables and configuration 2. The Aspire.ServiceDefaults project configures Microsoft.Extensions.ServiceDiscovery 3. Application code resolves services by name via HttpClient or connection strings
Consuming Discovered Services
// In MyApi/Program.cs
var builder = WebApplication.CreateBuilder(args);
// AddServiceDefaults registers service discovery, OpenTelemetry, health checks
builder.AddServiceDefaults();
// HttpClient resolves "worker" via service discovery
builder.Services.AddHttpClient("worker-client", client =>
{
client.BaseAddress = new Uri("https+http://worker");
});The https+http:// scheme prefix tells the service discovery provider to try HTTPS first, falling back to HTTP. This is the recommended pattern for inter-service communication in Aspire.
Connection Strings
For backing services (databases, caches), Aspire injects connection strings via the standard ConnectionStrings configuration section:
// AppHost: .WithReference(postgres) on the API project
// injects ConnectionStrings__ordersdb automatically
// In MyApi/Program.cs
builder.AddNpgsqlDbContext<OrdersDbContext>("ordersdb");
// Resolves ConnectionStrings:ordersdb from configuration---
Component Model
Aspire components are NuGet packages that provide pre-configured client integrations for backing services. They handle connection management, health checks, telemetry, and resilience.
Hosting Packages vs Client Packages
| Package Type | Installed In | Purpose |
|---|---|---|
Aspire.Hosting.* | AppHost project | Define and configure the resource (container, connection) |
Aspire.* (client) | Service projects | Consume the resource with health checks and telemetry |
<!-- AppHost project -->
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="9.1.*" />
<!-- API project -->
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.1.*" />Common Components
| Component | Hosting Package | Client Package |
|---|---|---|
| PostgreSQL (EF Core) | Aspire.Hosting.PostgreSQL | Aspire.Npgsql.EntityFrameworkCore.PostgreSQL |
| PostgreSQL (Npgsql) | Aspire.Hosting.PostgreSQL | Aspire.Npgsql |
| Redis (caching) | Aspire.Hosting.Redis | Aspire.StackExchange.Redis |
| Redis (output cache) | Aspire.Hosting.Redis | Aspire.StackExchange.Redis.OutputCaching |
| RabbitMQ | Aspire.Hosting.RabbitMQ | Aspire.RabbitMQ.Client |
| Azure Service Bus | Aspire.Hosting.Azure.ServiceBus | Aspire.Azure.Messaging.ServiceBus |
| SQL Server (EF Core) | Aspire.Hosting.SqlServer | Aspire.Microsoft.EntityFrameworkCore.SqlServer |
| MongoDB | Aspire.Hosting.MongoDB | Aspire.MongoDB.Driver |
Client Registration
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
// Each Add* method registers the client, health check, and telemetry
builder.AddNpgsqlDbContext<OrdersDbContext>("ordersdb");
builder.AddRedisClient("cache");
builder.AddRabbitMQClient("messaging");Component Add* methods: 1. Register the client/DbContext in DI 2. Add a health check for the resource 3. Configure OpenTelemetry instrumentation for the client 4. Apply default resilience settings (retries, timeouts)
---
Service Defaults
The ServiceDefaults project is a shared library referenced by all service projects. It standardizes cross-cutting concerns.
What ServiceDefaults Configures
public static class Extensions
{
public static IHostApplicationBuilder AddServiceDefaults(
this IHostApplicationBuilder builder)
{
// Service discovery
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
// Resilience for HttpClient
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddStandardResilienceHandler();
http.AddServiceDiscovery();
});
return builder;
}
public static IHostApplicationBuilder ConfigureOpenTelemetry(
this IHostApplicationBuilder builder)
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation())
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddGrpcClientInstrumentation()
.AddHttpClientInstrumentation());
builder.AddOpenTelemetryExporters();
return builder;
}
public static IHostApplicationBuilder AddDefaultHealthChecks(
this IHostApplicationBuilder builder)
{
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy());
return builder;
}
public static WebApplication MapDefaultEndpoints(
this WebApplication app)
{
app.MapHealthChecks("/health");
app.MapHealthChecks("/alive", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
return app;
}
}Using ServiceDefaults
Every service project references the ServiceDefaults project and calls the extension methods:
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
// ... service-specific registrations
var app = builder.Build();
app.MapDefaultEndpoints();
// ... middleware and endpoints
app.Run();---
Dashboard
The Aspire dashboard provides a local observability UI that starts automatically with the AppHost. It displays:
- Resources -- status of all projects, containers, and executables
- Console logs -- aggregated stdout/stderr from all resources
- Structured logs -- OpenTelemetry log records with structured properties
- Traces -- distributed traces across all services
- Metrics -- real-time metric charts
Accessing the Dashboard
When you run the AppHost (dotnet run --project MyApp.AppHost), the dashboard URL is printed to the console:
info: Aspire.Hosting.DistributedApplication[0]
Login to the dashboard at https://localhost:17043/login?t=<token>Dashboard in Non-Aspire Projects
The dashboard is available as a standalone container for projects not using the full Aspire stack:
docker run --rm -it -p 18888:18888 -p 4317:18889 \
-d --name aspire-dashboard \
mcr.microsoft.com/dotnet/aspire-dashboard:9.1Configure your app to export OTLP telemetry to http://localhost:4317 and view it at http://localhost:18888.
---
Health Checks and Distributed Tracing
Component Health Checks
Each Aspire component automatically registers health checks. The AppHost uses these to determine resource readiness:
// In AppHost -- WaitFor uses health checks to gate startup
builder.AddProject<Projects.MyApi>("api")
.WithReference(postgres)
.WaitFor(postgres); // Waits for Npgsql health check to passCustom Health Checks
Add application-specific health checks alongside Aspire defaults:
builder.Services.AddHealthChecks()
.AddCheck<OrderProcessingHealthCheck>(
"order-processing",
tags: ["ready"]);See [skill:dotnet-devops] for detailed health check patterns (liveness vs readiness, custom checks, health check publishing).
Distributed Tracing Integration
Aspire configures OpenTelemetry tracing through ServiceDefaults. Traces propagate automatically across HTTP boundaries. For custom spans:
private static readonly ActivitySource s_activitySource = new("MyApp.Orders");
public async Task<Order> ProcessOrderAsync(CreateOrderRequest request, CancellationToken ct)
{
using var activity = s_activitySource.StartActivity("ProcessOrder");
activity?.SetTag("order.customer_id", request.CustomerId);
// Calls to other Aspire services carry trace context automatically
var inventory = await _httpClient.GetFromJsonAsync<InventoryResponse>(
$"https+http://inventory-api/api/stock/{request.ProductId}", ct);
// ... process order
return order;
}See [skill:dotnet-devops] for comprehensive distributed tracing guidance (custom ActivitySource, trace context propagation, span events).
---
Container Resources
Adding Container Images
For services not available as Aspire components, add arbitrary container images:
var seq = builder.AddContainer("seq", "datalust/seq")
.WithHttpEndpoint(port: 5341, targetPort: 80)
.WithEnvironment("ACCEPT_EULA", "Y");
// Reference the container from a project
builder.AddProject<Projects.MyApi>("api")
.WithReference(seq);Persistent Volumes
By default, Aspire containers use ephemeral storage. Add volumes for data persistence across restarts:
var postgres = builder.AddPostgres("pg")
.WithDataVolume("pg-data") // Named volume for data persistence
.AddDatabase("ordersdb");External Resources
Reference existing infrastructure not managed by Aspire:
// Connection string from configuration (not an Aspire-managed container)
var existingDb = builder.AddConnectionString("legacydb");
builder.AddProject<Projects.MyApi>("api")
.WithReference(existingDb);---
Aspire vs Manual Container Orchestration
| Concern | Aspire | Docker Compose / Manual |
|---|---|---|
| Configuration language | C# (strongly typed) | YAML |
| Service discovery | Automatic (env var injection) | Manual DNS/env config |
| Health checks | Automatic per component | Manual HEALTHCHECK per service |
| Observability | Pre-configured OTel + dashboard | Manual OTel collector setup |
| IDE integration | Hot reload, F5 debugging | Attach debugger manually |
| Production deployment | Generates manifests (AZD, K8s) | Write manifests directly |
| Non-.NET services | Container references (less integrated) | Equal support for all languages |
| Learning curve | .NET-specific abstractions | Industry-standard tooling |
Choose Aspire when your stack is primarily .NET and you want standardized observability, service discovery, and a simplified local dev experience. Choose manual orchestration when you need fine-grained control, polyglot services, or your team is already proficient with Compose/Kubernetes.
---
Key Principles
- AppHost is dev-time only -- it orchestrates local development, not production deployment
- Use components over raw connection strings -- components add health checks, telemetry, and resilience automatically
- ServiceDefaults is non-negotiable -- every Aspire service project must reference it for consistent observability
- WaitFor for ordered startup -- use it for real dependencies (schema migrations, seed data), not for every resource
- Do not duplicate OTel config -- Aspire ServiceDefaults configure OpenTelemetry; manual configuration causes double-collection
---
Agent Gotchas
1. Do not manually configure OpenTelemetry in Aspire service projects -- ServiceDefaults already registers OTel providers. Adding manual .AddOpenTelemetry() calls causes duplicate trace/metric collection and inflated telemetry costs. 2. Do not hardcode connection strings in Aspire service projects -- use builder.AddNpgsqlDbContext<T>("name") or builder.Configuration.GetConnectionString("name"). Aspire injects connection strings via environment variables; hardcoded values bypass service discovery. 3. Do not use `WaitFor` on every resource -- it serializes startup and increases launch time. Use it only when a service genuinely cannot start without the dependency (e.g., database migration on startup). 4. *Do not reference `Aspire.Hosting. packages from service projects** -- hosting packages belong in the AppHost only. Service projects use client packages (Aspire.Npgsql, Aspire.StackExchange.Redis, etc.). 5. **Do not confuse the AppHost with a production host** -- the AppHost runs locally (or in CI) to orchestrate resources. Production deployment uses generated manifests or infrastructure-as-code. 6. **Do not omit AddServiceDefaults()` in new service projects** -- without it, the project lacks service discovery, health checks, and telemetry, breaking Aspire integration silently.
---
Prerequisites
- .NET 10 SDK (or .NET 8/9 with Aspire workload)
- Docker Desktop or Podman (for container resources)
- Aspire workload:
dotnet workload install aspire
---