
Dotnet Csharp
- 325 installs
- 228 repo stars
- Updated August 3, 2026
- novotnyllc/dotnet-artisan
dotnet-csharp is a Claude Code skill that implements C# and .NET APIs, services, and libraries with idiomatic patterns, dependency injection, async/await, records, and framework conventions for developers who need produc
About
dotnet-csharp is a skill from the novotnyllc/dotnet-artisan pack that steers AI agents toward idiomatic C# and .NET implementation. It covers API endpoints, service layers, library design, dependency injection registration, async/await usage, records, and framework-specific conventions so generated code matches real .NET project structure. Developers reach for dotnet-csharp when scaffolding ASP.NET Core APIs, refactoring services to modern C# patterns, or extending a .NET solution where consistency with Microsoft ecosystem norms matters more than generic language output.
- Idiomatic C# and modern language features
- Dependency injection and hosting
- Minimal APIs and ASP.NET Core patterns
- Async and cancellation best practices
- Project structure and package conventions
Dotnet Csharp by the numbers
- 325 all-time installs (skills.sh)
- +14 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #61 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-csharpAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 325 |
|---|---|
| repo stars | ★ 228 |
| Last updated | August 3, 2026 |
| Repository | novotnyllc/dotnet-artisan ↗ |
How do you write idiomatic C# APIs with DI and async?
Implement C# and .NET APIs, services, and libraries with idiomatic patterns, DI, async/await, records, and framework conventions from the dotnet-artisan skill pack.
Who is it for?
Developers building or extending ASP.NET Core APIs, microservices, or .NET libraries who want agent output that follows Microsoft ecosystem patterns instead of generic C# snippets.
Skip if: Developers working in non-.NET stacks, frontend-only Blazor UI tasks, or teams that only need one-off scripts without service architecture.
When should I use this skill?
User asks to implement, refactor, or review C# APIs, .NET services, dependency injection, async patterns, records, or backend library code.
What you get
ASP.NET Core API endpoints, service classes, library modules, and DI registration code following .NET conventions
- API endpoint code
- Service classes
- Library modules with DI setup
Files
dotnet-csharp
Overview
C# language patterns, coding standards, and .NET runtime features for idiomatic, performant code. This consolidated skill spans 25 topic areas. Load the appropriate companion file from references/ based on the routing table below.
Always-Load Baseline
These references define correctness and quality standards that apply to all C# code — load them by default whenever producing or reviewing code, regardless of what the user asked for:
references/coding-standards.md— naming conventions, file layout, style rulesreferences/async-patterns.md— async/await correctness, ConfigureAwait, cancellation propagation (nearly all .NET code uses async)references/solid-principles.md— SOLID, DRY, single responsibility, dependency inversion, anti-pattern detectionreferences/code-smells.md— common mistakes the agent should avoid without being told (async void, DI lifetime misuse, swallowed exceptions)references/dotnet-releases.md— .NET 10/11 and C# 14/15 features, version matrix, TFM-specific code generation rules (compensates for training data cutoff)
On-Demand References
Load these when the topic matches (see Routing Table keywords):
Routing Table
| Topic | Keywords | Description | Companion File |
|---|---|---|---|
| Coding standards | naming, file layout, style rules | Baseline C# conventions (naming, layout, style rules) | references/coding-standards.md |
| Async/await | async, Task, ConfigureAwait, cancellation | async/await, Task patterns, ConfigureAwait, cancellation | references/async-patterns.md |
| Dependency injection | DI, services, scopes, keyed, lifetimes | MS DI, keyed services, scopes, decoration, lifetimes | references/dependency-injection.md |
| Configuration | Options pattern, user secrets, feature flags | Options pattern, user secrets, feature flags, IOptions\<T\> | references/configuration.md |
| Source generators | IIncrementalGenerator, GeneratedRegex, LoggerMessage | IIncrementalGenerator, GeneratedRegex, LoggerMessage, STJ | references/source-generators.md |
| Nullable reference types | annotations, migration, agent mistakes | Annotation strategies, migration, agent mistakes | references/nullable-reference-types.md |
| Serialization | System.Text.Json, Protobuf, MessagePack, AOT | System.Text.Json source generators, Protobuf, MessagePack | references/serialization.md |
| Channels | Channel\<T\>, bounded/unbounded, backpressure | Channel\<T\>, bounded/unbounded, backpressure, drain | references/channels.md |
| LINQ optimization | IQueryable vs IEnumerable, compiled queries | IQueryable vs IEnumerable, compiled queries, allocations | references/linq-optimization.md |
| Domain modeling | aggregates, value objects, domain events | Aggregates, value objects, domain events, repositories | references/domain-modeling.md |
| SOLID principles | SRP, DRY, anti-patterns, compliance checks | SOLID and DRY principles, C# anti-patterns, fixes | references/solid-principles.md |
| Concurrency | lock, SemaphoreSlim, Interlocked, concurrent collections | lock, SemaphoreSlim, Interlocked, concurrent collections | references/concurrency-patterns.md |
| Roslyn analyzers | DiagnosticAnalyzer, CodeFixProvider, multi-version | DiagnosticAnalyzer, CodeFixProvider, CodeRefactoring | references/roslyn-analyzers.md |
| Editorconfig | IDE/CA severity, AnalysisLevel, globalconfig | IDE/CA severity, AnalysisLevel, globalconfig, enforcement | references/editorconfig.md |
| File I/O | FileStream, RandomAccess, FileSystemWatcher, paths | FileStream, RandomAccess, FileSystemWatcher, MemoryMappedFile | references/file-io.md |
| Native interop | P/Invoke, LibraryImport, ComWrappers, marshalling | P/Invoke, LibraryImport, ComWrappers, marshalling, cross-platform | references/native-interop.md |
| Input validation | .NET 10 AddValidation, FluentValidation | .NET 10 AddValidation, FluentValidation, ProblemDetails | references/input-validation.md |
| Validation patterns | DataAnnotations, IValidatableObject, IValidateOptions | DataAnnotations, IValidatableObject, IValidateOptions\<T\> | references/validation-patterns.md |
| Modern patterns | records, pattern matching, primary constructors | Records, pattern matching, primary constructors, C# 12-15 | references/modern-patterns.md |
| API design | naming, parameter ordering, return types, extensions | Naming, parameter ordering, return types, error patterns | references/api-design.md |
| Type design/perf | struct vs class, sealed, Span/Memory, collections | struct vs class, sealed, Span/Memory, collections | references/type-design-performance.md |
| Code smells | anti-patterns, async misuse, DI mistakes, fixes | Anti-patterns, async misuse, DI mistakes, fixes | references/code-smells.md |
| .NET releases | .NET 10, .NET 11, C# 14, C# 15, TFM, version, union, extension blocks, field keyword | Version matrix, new features, TFM-specific code generation | references/dotnet-releases.md |
| Globalization | CultureInfo, StringComparison, TimeZoneInfo, Rune, encoding | Culture-aware coding, string comparison, time zones, character processing | references/globalization.md |
| WASM interop | JSImport, JSExport, standalone WASM, wasm-experimental, browser | JSImport/JSExport, standalone .NET WASM, browser APIs, WASM AOT | references/wasm-interop.md |
Scope
- C# language features (C# 8-15)
- .NET runtime patterns (async, DI, config, serialization, channels, LINQ)
- Code quality (analyzers, editorconfig, code smells, SOLID)
- Type design and domain modeling
- File I/O and native interop
- Globalization (string comparison, CultureInfo, time zones, character processing, encoding)
- Input validation at the model level (DataAnnotations, IValidatableObject, FluentValidation, Options validation)
Out of scope
- ASP.NET Core / web API patterns (request-level validation, endpoint filters) -> [skill:dotnet-api]
- UI framework patterns -> [skill:dotnet-ui]
- Testing patterns -> [skill:dotnet-testing]
- Build/MSBuild/project setup -> [skill:dotnet-tooling]
- Performance profiling tools -> [skill:dotnet-tooling]
interface:
display_name: "dotnet-csharp"
short_description: "C# patterns, standards, and runtime guidance"
default_prompt: "Use $dotnet-advisor to route this .NET task, then apply $dotnet-csharp standards before implementation."
policy:
allow_implicit_invocation: true
API Design
Design-time principles for creating public .NET APIs that are intuitive, consistent, and forward-compatible. Covers naming conventions for API surface, parameter ordering, return type selection, error reporting strategies, extension points, and wire compatibility for serialized types. This skill addresses the design decisions that make APIs compatible and usable in the first place, before enforcement tooling gets involved.
Version assumptions: .NET 8.0+ baseline. Examples use modern C# features (primary constructors, collection expressions) where appropriate.
Naming Conventions for API Surface
Type Naming
Follow the .NET Framework Design Guidelines naming patterns for public API types:
| Type Kind | Suffix Pattern | Example |
|---|---|---|
| Base class | Base suffix only for abstract base types | ValidatorBase |
| Interface | I prefix | IWidgetFactory |
| Exception | Exception suffix | WidgetNotFoundException |
| Attribute | Attribute suffix | RequiredPermissionAttribute |
| Event args | EventArgs suffix | WidgetCreatedEventArgs |
| Options/config | Options suffix | WidgetServiceOptions |
| Builder | Builder suffix | WidgetBuilder |
Method Naming
| Pattern | Convention | Example |
|---|---|---|
| Synchronous | Verb or verb phrase | Calculate(), GetWidget() |
| Asynchronous | Async suffix | CalculateAsync(), GetWidgetAsync() |
| Boolean query | Is/Has/Can prefix | IsValid(), HasPermission() |
| Try pattern | Try prefix, out parameter | TryGetWidget(int id, out Widget widget) |
| Factory | Create prefix | CreateWidget(), CreateWidgetAsync() |
| Conversion | To/From prefix | ToDto(), FromEntity() |
Avoid Abbreviations in Public API
Spell out words in public APIs even if internal code uses abbreviations. Public APIs are consumed by developers who may not share the team's domain shorthand:
// WRONG -- abbreviations in public surface
public IReadOnlyList<TxnResult> GetRecentTxns(int cnt);
// CORRECT -- spelled out for clarity
public IReadOnlyList<TransactionResult> GetRecentTransactions(int count);---
Parameter Ordering
Consistent parameter ordering reduces cognitive load and enables fluent usage patterns across an API surface.
Standard Order
1. Target/subject -- the primary entity being operated on 2. Required parameters -- essential inputs without defaults 3. Optional parameters -- inputs with sensible defaults 4. Cancellation token -- always last (convention enforced by CA1068)
// Consistent ordering across the API surface
public Task<Widget> GetWidgetAsync(
int widgetId, // 1. Target
WidgetOptions options, // 2. Required
bool includeHistory = false, // 3. Optional
CancellationToken cancellationToken = default); // 4. Always last
public Task<Widget> UpdateWidgetAsync(
int widgetId, // 1. Target
WidgetUpdateRequest request, // 2. Required
bool validateFirst = true, // 3. Optional
CancellationToken cancellationToken = default); // 4. Always lastOverload Progression
Design overloads as a progression from simple to detailed. Each overload should delegate to the next more specific one:
// Simple -- sensible defaults
public Task<Widget> GetWidgetAsync(int widgetId,
CancellationToken cancellationToken = default)
=> GetWidgetAsync(widgetId, WidgetOptions.Default, cancellationToken);
// Detailed -- full control
public Task<Widget> GetWidgetAsync(int widgetId,
WidgetOptions options,
CancellationToken cancellationToken = default);---
Return Type Selection
When to Return What
| Scenario | Return Type | Rationale |
|---|---|---|
| Single entity, always exists | Widget | Throw if not found |
| Single entity, may not exist | Widget? | Nullable reference type communicates optionality |
| Collection, possibly empty | IReadOnlyList<Widget> | Immutable, indexable, communicates no mutation |
| Streaming results | IAsyncEnumerable<Widget> | Avoids buffering entire result set |
| Operation result with detail | Result<Widget> / discriminated union | Rich error info without exceptions |
| Void with async | Task | Never async void except event handlers |
| Frequently synchronous completion | ValueTask<Widget> | Avoids Task allocation on cache hits |
Prefer IReadOnlyList Over IEnumerable for Materialized Collections
// WRONG -- caller does not know if result is materialized or lazy
public IEnumerable<Widget> GetWidgets();
// CORRECT -- signals materialized, indexable collection
public IReadOnlyList<Widget> GetWidgets();
// CORRECT -- signals streaming/lazy evaluation explicitly
public IAsyncEnumerable<Widget> GetWidgetsStreamAsync(
CancellationToken cancellationToken = default);The Try Pattern
Use the Try pattern for operations that have a common, non-exceptional failure mode:
// Parsing, lookup, validation -- failure is expected, not exceptional
public bool TryGetWidget(int widgetId, [NotNullWhen(true)] out Widget? widget);
// Async Try pattern -- return nullable instead of out parameter
public Task<Widget?> TryGetWidgetAsync(int widgetId,
CancellationToken cancellationToken = default);---
Error Reporting Strategies
Exception Hierarchy
Design exception types that enable callers to catch at the right granularity:
// Base exception for the library -- callers can catch all library errors
public class WidgetServiceException : Exception
{
public WidgetServiceException(string message) : base(message) { }
public WidgetServiceException(string message, Exception inner) : base(message, inner) { }
}
// Specific exceptions derive from the base
public class WidgetNotFoundException : WidgetServiceException
{
public int WidgetId { get; }
public WidgetNotFoundException(int widgetId)
: base($"Widget {widgetId} not found.") => WidgetId = widgetId;
}
public class WidgetValidationException : WidgetServiceException
{
public IReadOnlyList<string> Errors { get; }
public WidgetValidationException(IReadOnlyList<string> errors)
: base("Widget validation failed.") => Errors = errors;
}When to Use Exceptions vs Return Values
| Approach | When to Use |
|---|---|
| Throw exception | Unexpected failures, programming errors, infrastructure failures |
Return null / default | "Not found" is a normal, expected outcome (query patterns) |
Try pattern (bool + out) | Parsing or validation where failure is common and synchronous |
| Result object | Multiple failure modes that callers need to distinguish without try/catch |
Argument Validation
Validate public API entry points immediately and throw the standard .NET exceptions:
public Widget CreateWidget(string name, decimal price)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(price);
// Proceed with creation
return new Widget(name, price);
}Use ArgumentException.ThrowIfNullOrWhiteSpace (.NET 8+) and ArgumentOutOfRangeException.ThrowIfNegativeOrZero (.NET 8+) instead of manual null checks with throw new ArgumentNullException(...). These throw helpers are optimized by the JIT (no delegate allocation, better inlining).
---
Extension Points
Designing for Extensibility Without Inheritance
Prefer composition and interfaces over class inheritance for extension points:
// GOOD -- interface-based extension point
public interface IWidgetValidator
{
ValueTask<bool> ValidateAsync(Widget widget, CancellationToken ct = default);
}
// GOOD -- delegate-based extension for simple hooks
public class WidgetServiceOptions
{
public Func<Widget, CancellationToken, ValueTask>? OnWidgetCreated { get; set; }
}
// GOOD -- builder pattern for complex configuration
public sealed class WidgetServiceBuilder
{
private readonly List<IWidgetValidator> _validators = [];
public WidgetServiceBuilder AddValidator(IWidgetValidator validator)
{
_validators.Add(validator);
return this;
}
public WidgetServiceBuilder AddValidator(
Func<Widget, CancellationToken, ValueTask<bool>> validator)
{
_validators.Add(new DelegateValidator(validator));
return this;
}
public WidgetService Build() => new(_validators);
}Extension Method Guidelines
| Guideline | Rationale |
|---|---|
| Place extensions in the same namespace as the type they extend | Discoverable without extra using statements |
Never put extensions in System or System.Linq | Namespace pollution affects all consumers |
| Prefer instance methods over extensions when you own the type | Extensions are a last resort for types you do not own |
Keep the extension's this parameter as the most specific usable type | IEnumerable<T> not object; avoids polluting IntelliSense |
---
Wire Compatibility for Serialized Types
Types that are serialized (JSON, Protobuf, MessagePack) or persisted form an implicit contract. Changing their shape breaks existing clients or stored data.
Safe Changes (Wire Compatible)
| Change | Why Safe |
|---|---|
| Add optional property with default | Old payloads deserialize with default; old clients ignore new field |
| Add new enum member at the end | Existing serialized values map to existing members |
Rename property with [JsonPropertyName] annotation | Wire name stays the same |
Breaking Changes (Wire Incompatible)
| Change | Impact |
|---|---|
| Remove or rename property (without annotation) | Old payloads lose data; old clients send unrecognized fields |
| Change property type | Deserialization failure or silent data loss |
| Reorder enum members (for integer-serialized enums) | Existing stored integers map to wrong members |
| Change from class to struct or vice versa | Serializer behavior changes (null handling, default values) |
Defensive Serialization Design
// Version-tolerant DTO with explicit wire names
public sealed class WidgetDto
{
[JsonPropertyName("id")]
public int Id { get; init; }
[JsonPropertyName("name")]
public required string Name { get; init; }
// V2 addition -- optional with default, old payloads work fine
[JsonPropertyName("category")]
public string? Category { get; init; }
// V3 addition -- use JsonIgnoreCondition to exclude defaults from wire
[JsonPropertyName("priority")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int Priority { get; init; }
}Enum Serialization Strategy
// GOOD -- string serialization is rename-safe and human-readable
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum WidgetStatus
{
Draft,
Active,
Archived
}
// RISKY -- integer serialization breaks when members are reordered or inserted
// Only use when wire format size is critical and members are append-only
public enum WidgetPriority
{
Low = 0,
Medium = 1,
High = 2
// New members MUST go at the end with explicit values
}---
API Design Checklist
Before shipping a new public API, verify each concern:
1. Naming -- follows .NET naming conventions, no abbreviations, consistent with rest of API surface 2. Parameters -- ordered (target, required, optional, CancellationToken), no more than ~5 parameters (use options object for complex APIs) 3. Return types -- appropriate for the scenario (nullable for optional, IReadOnlyList for collections, Task/ValueTask for async) 4. Error handling -- clear exception types, argument validation at entry points, Try pattern where failure is expected 5. Extension points -- interfaces or delegates, not virtual methods on concrete classes 6. Wire safety -- serialized types use explicit property names, additive-only evolution, enum strategy documented 7. Compatibility -- changes reviewed against [skill:dotnet-api] rules before release
---
Agent Gotchas
1. Do not use abbreviations in public API names -- spell out words even when internal code uses shorthand. Public APIs are consumed by developers outside the team who do not share the domain vocabulary. 2. Do not place CancellationToken before optional parameters -- CA1068 enforces CancellationToken as the last parameter. Placing it earlier breaks the standard ordering convention and triggers analyzer warnings. 3. Do not return mutable collections from public APIs -- return IReadOnlyList<T> or IReadOnlyCollection<T> instead of List<T> or IList<T>. Mutable return types allow callers to corrupt internal state. 4. Do not change serialized property names without `[JsonPropertyName]` annotations -- renaming a C# property without preserving the wire name breaks all existing serialized data and API clients. 5. Do not add required parameters to existing public methods -- this is a source-breaking change. Add a new overload or use optional parameters with defaults instead. 6. Do not use `async void` in API surface -- return Task or ValueTask. The only valid async void is framework event handlers. See references/async-patterns.md. 7. Do not design exception hierarchies without a base library exception -- callers need a single catch point for all library errors. Always provide a base exception type that specific exceptions derive from. 8. Do not put extension methods in the `System` namespace -- namespace pollution affects every file in every consumer project. Use the library's own namespace or a dedicated .Extensions sub-namespace.
---
Prerequisites
- .NET 8.0+ SDK
- Familiarity with C# naming conventions (see
references/coding-standards.md) - Understanding of binary/source compatibility concepts (see [skill:dotnet-api])
- System.Text.Json for wire compatibility examples
---
References
Async Patterns
Async/await best practices for .NET applications. Covers correct task usage, cancellation propagation, and the most common mistakes AI agents make when generating async code.
Cross-references: references/concurrency-patterns.md for thread synchronization primitives (lock, SemaphoreSlim), references/channels.md for Channel<T> producer/consumer patterns, references/dependency-injection.md for IHostedService/BackgroundService registration, references/coding-standards.md for Async suffix naming, references/modern-patterns.md for language-level features.
---
Core Rules
Always Async All the Way
Every method in the async call chain must be async and awaited. Mixing sync and async causes deadlocks or thread pool starvation.
// Correct: async all the way
public async Task<Order> GetOrderAsync(int id, CancellationToken ct = default)
{
var order = await _repo.GetByIdAsync(id, ct);
return order;
}
// WRONG: blocking on async -- causes deadlocks in ASP.NET and UI contexts
public Order GetOrder(int id)
{
return _repo.GetByIdAsync(id).Result; // DEADLOCK RISK
}Prefer Task and ValueTask
Return Task or Task<T> by default. Use ValueTask<T> when the method frequently completes synchronously (cache hits, buffered I/O) to avoid Task allocation.
// ValueTask: frequently synchronous completion
public ValueTask<User?> GetCachedUserAsync(int id, CancellationToken ct = default)
{
if (_cache.TryGetValue(id, out var user))
{
return ValueTask.FromResult<User?>(user);
}
return LoadUserAsync(id, ct);
}
private async ValueTask<User?> LoadUserAsync(int id, CancellationToken ct)
{
var user = await _repo.GetByIdAsync(id, ct);
if (user is not null)
{
_cache[id] = user;
}
return user;
}ValueTask rules:
- Never
awaitaValueTaskmore than once - Never use
.Resultor.GetAwaiter().GetResult()on an incompleteValueTask - If you need to await multiple times or pass it around, convert with
.AsTask()
---
Agent Gotchas
These are the most common async mistakes AI agents make when generating C# code.
1. Blocking on Async (.Result, .Wait(), .GetAwaiter().GetResult())
// WRONG -- all of these can deadlock
var result = GetDataAsync().Result;
GetDataAsync().Wait();
var result = GetDataAsync().GetAwaiter().GetResult();
// CORRECT
var result = await GetDataAsync();The only safe place for .GetAwaiter().GetResult() is in Main() pre-C# 7.1 or in rare infrastructure code where async is impossible (static constructors, Dispose()).
2. async void
async void methods cannot be awaited, and unhandled exceptions in them crash the process.
// WRONG -- fire-and-forget, unobserved exceptions
async void ProcessOrder(Order order)
{
await _repo.SaveAsync(order);
}
// CORRECT
async Task ProcessOrderAsync(Order order)
{
await _repo.SaveAsync(order);
}The only valid use of async void is event handlers (WinForms, WPF, Blazor @onclick), where the framework requires a void return type.
3. Missing ConfigureAwait
In library code, use ConfigureAwait(false) to avoid capturing the synchronization context. In application code (ASP.NET Core, console apps), it is not needed because there is no synchronization context.
// Library code
public async Task<byte[]> ReadFileAsync(string path, CancellationToken ct = default)
{
var bytes = await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false);
return bytes;
}
// Application code (ASP.NET Core) -- ConfigureAwait not needed
public async Task<IActionResult> GetOrder(int id, CancellationToken ct)
{
var order = await _service.GetOrderAsync(id, ct);
return Ok(order);
}4. Fire-and-Forget Without Error Handling
// WRONG -- exception is silently swallowed
_ = SendEmailAsync(order);
// CORRECT -- use IHostedService or a background channel
await _backgroundQueue.EnqueueAsync(ct => SendEmailAsync(order, ct));If fire-and-forget is truly necessary, at minimum log the exception:
_ = Task.Run(async () =>
{
try
{
await SendEmailAsync(order);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send email for order {OrderId}", order.Id);
}
});5. Forgetting CancellationToken
Always accept and forward CancellationToken. Never silently drop it.
// WRONG -- token not forwarded
public async Task<List<Order>> GetAllAsync(CancellationToken ct = default)
{
return await _dbContext.Orders.ToListAsync(); // missing ct!
}
// CORRECT
public async Task<List<Order>> GetAllAsync(CancellationToken ct = default)
{
return await _dbContext.Orders.ToListAsync(ct);
}---
Cancellation Patterns
Creating Linked Tokens
Combine external cancellation with a timeout:
public async Task<Result> ProcessWithTimeoutAsync(CancellationToken ct = default)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(30));
return await DoWorkAsync(cts.Token);
}Responding to Cancellation
public async Task ProcessBatchAsync(IEnumerable<Item> items, CancellationToken ct = default)
{
foreach (var item in items)
{
ct.ThrowIfCancellationRequested();
await ProcessItemAsync(item, ct);
}
}---
Parallel Async
Task.WhenAll for Independent Operations
public async Task<Dashboard> LoadDashboardAsync(int userId, CancellationToken ct = default)
{
var ordersTask = _orderService.GetRecentAsync(userId, ct);
var profileTask = _profileService.GetAsync(userId, ct);
var statsTask = _statsService.GetAsync(userId, ct);
await Task.WhenAll(ordersTask, profileTask, statsTask);
return new Dashboard(await ordersTask, await profileTask, await statsTask);
}Parallel.ForEachAsync (.NET 6+) for Bounded Parallelism
await Parallel.ForEachAsync(items, new ParallelOptions
{
MaxDegreeOfParallelism = 4,
CancellationToken = ct
}, async (item, token) =>
{
await ProcessItemAsync(item, token);
});---
IAsyncEnumerable<T> Streaming
Use IAsyncEnumerable<T> for streaming results instead of buffering entire collections:
public async IAsyncEnumerable<Order> GetOrdersStreamAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var order in _dbContext.Orders.AsAsyncEnumerable().WithCancellation(ct))
{
yield return order;
}
}---
Background Work
For background processing, use BackgroundService (or IHostedService) instead of Task.Run or fire-and-forget patterns. See references/dependency-injection.md for registration patterns.
public sealed class OrderProcessorWorker(
IServiceScopeFactory scopeFactory,
ILogger<OrderProcessorWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = scopeFactory.CreateScope();
var processor = scope.ServiceProvider.GetRequiredService<IOrderProcessor>();
await processor.ProcessPendingAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
}---
Testing Async Code
[Fact]
public async Task GetOrderAsync_WhenFound_ReturnsOrder()
{
// Arrange
var repo = Substitute.For<IOrderRepository>();
repo.GetByIdAsync(42, Arg.Any<CancellationToken>())
.Returns(new Order { Id = 42 });
var service = new OrderService(repo);
// Act
var result = await service.GetOrderAsync(42);
// Assert
Assert.NotNull(result);
Assert.Equal(42, result.Id);
}
[Fact]
public async Task ProcessAsync_WhenCancelled_ThrowsOperationCanceled()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(
() => _service.ProcessAsync(cts.Token));
}---
Knowledge Sources
Async patterns in this reference are grounded in publicly available content from:
- Stephen Cleary's "Concurrency in C#" and Blog -- Definitive async best practices for .NET. Key guidance applied: "async all the way" (never block on async), "there is no thread" (async I/O does not consume a thread while waiting), correct CancellationToken propagation, async disposal via IAsyncDisposable, and BackgroundService patterns for long-running work. Source: https://blog.stephencleary.com/
- David Fowler's Async Guidance -- Practical async anti-patterns and diagnostic scenarios for ASP.NET Core. Source: https://github.com/davidfowl/AspNetCoreDiagnosticScenarios/blob/master/AsyncGuidance.md
- Stephen Toub's ConfigureAwait FAQ -- Canonical reference for ConfigureAwait behavior across application types. Source: https://devblogs.microsoft.com/dotnet/configureawait-faq/
Note: This reference applies publicly documented guidance. It does not represent or speak for the named sources.
References
Channels
Deep guide to System.Threading.Channels for high-performance, thread-safe producer/consumer communication in .NET. Covers channel creation, backpressure strategies, IAsyncEnumerable integration, and graceful shutdown patterns.
Cross-references: references/async-patterns.md for async patterns used in channel consumers, references/dependency-injection.md for integrating channels with hosted services.
---
Channel<T> Fundamentals
A Channel<T> is a thread-safe data structure with separate ChannelWriter<T> and ChannelReader<T> endpoints. Writers produce items, readers consume them -- the channel handles all synchronization.
// Create a channel and separate the endpoints
Channel<WorkItem> channel = Channel.CreateUnbounded<WorkItem>();
ChannelWriter<WorkItem> writer = channel.Writer;
ChannelReader<WorkItem> reader = channel.Reader;Bounded vs Unbounded
| Aspect | Bounded | Unbounded |
|---|---|---|
| Creation | Channel.CreateBounded<T>(capacity) | Channel.CreateUnbounded<T>() |
| Back-pressure | Yes -- FullMode controls behavior when full | No -- grows without limit |
| Memory safety | Capped at capacity items | Can exhaust memory under load |
| Use when | Production workloads, untrusted producer rates | Guaranteed-low-volume, prototyping |
// Bounded -- preferred for production
var bounded = Channel.CreateBounded<WorkItem>(new BoundedChannelOptions(capacity: 1000)
{
FullMode = BoundedChannelFullMode.Wait
});
// Unbounded -- use only when you control the producer rate
var unbounded = Channel.CreateUnbounded<WorkItem>();---
BoundedChannelFullMode
Controls what happens when a bounded channel is full and a producer attempts to write.
| Mode | Behavior | Use case |
|---|---|---|
Wait | WriteAsync blocks until space is available | Default. Reliable delivery with back-pressure |
DropOldest | Drops the oldest item in the channel to make room | Telemetry, metrics -- latest data matters most |
DropNewest | Drops the newest buffered item to make room (accepts the write) | Sliding window -- keep oldest + newest, discard middle |
DropWrite | Rejects the incoming write and TryWrite returns false | Rate limiting -- discard excess incoming work with overflow detection |
// DropOldest -- telemetry pipeline where stale readings are expendable
var telemetryChannel = Channel.CreateBounded<SensorReading>(new BoundedChannelOptions(500)
{
FullMode = BoundedChannelFullMode.DropOldest
});
// DropWrite -- non-blocking enqueue with overflow awareness
var logChannel = Channel.CreateBounded<LogEntry>(new BoundedChannelOptions(10_000)
{
FullMode = BoundedChannelFullMode.DropWrite
});
if (!logChannel.Writer.TryWrite(entry))
{
// Channel full -- item was dropped; track overflow metric
overflowCounter.Add(1);
}Drop Detection with DropWrite + TryWrite
Use DropWrite mode to detect when the channel rejects a new item because it is full. TryWrite returns false when the item is dropped:
var channel = Channel.CreateBounded<WorkItem>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.DropWrite
});
if (!channel.Writer.TryWrite(item))
{
logger.LogWarning("Channel full, item dropped: {Id}", item.Id);
droppedItemsCounter.Add(1);
}Note: With DropOldest or DropNewest, the channel silently evicts an existing item and accepts the write, so TryWrite returns true even when a drop occurs. Producer-side drop detection is only reliable with DropWrite.
---
Producer Patterns
Single Producer
// Write with back-pressure (bounded channels)
await writer.WriteAsync(item, cancellationToken);
// Non-blocking write attempt (returns false if channel is full or completed)
if (!writer.TryWrite(item))
{
// Handle overflow -- log, retry, or discard
}Multiple Producers
Multiple producers can call WriteAsync or TryWrite concurrently without external locking. The channel is internally thread-safe.
// Multiple API endpoints enqueueing work into a shared channel
app.MapPost("/api/orders/{id}/process", async (
string id,
ChannelWriter<OrderCommand> writer,
CancellationToken ct) =>
{
await writer.WriteAsync(new OrderCommand(id, "process"), ct);
return Results.Accepted();
});
app.MapPost("/api/orders/{id}/cancel", async (
string id,
ChannelWriter<OrderCommand> writer,
CancellationToken ct) =>
{
await writer.WriteAsync(new OrderCommand(id, "cancel"), ct);
return Results.Accepted();
});Signaling Completion
Call Complete() or TryComplete() when no more items will be produced. This lets consumers detect the end of the stream.
// Signal completion -- no more items will be written
writer.Complete();
// TryComplete is idempotent -- safe to call multiple times
writer.TryComplete();
// Signal completion with an error
writer.TryComplete(new InvalidOperationException("Source failed"));---
Consumer Patterns
Single Consumer -- ReadAsync Loop
The classic pattern: wait for an item, process it, repeat.
while (await reader.WaitToReadAsync(cancellationToken))
{
while (reader.TryRead(out var item))
{
await ProcessAsync(item, cancellationToken);
}
}This two-loop pattern is preferred over ReadAsync alone because it drains all available items before awaiting again, reducing async state machine overhead.
Single Consumer -- ReadAsync (Simpler)
For simpler cases where per-item overhead is acceptable:
try
{
while (true)
{
var item = await reader.ReadAsync(cancellationToken);
await ProcessAsync(item, cancellationToken);
}
}
catch (ChannelClosedException)
{
// Writer called Complete() -- no more items
}Multiple Consumers (Fan-Out)
Scale processing by running multiple consumer tasks. The channel ensures each item is read by exactly one consumer.
public sealed class ScaledChannelProcessor(
ChannelReader<WorkItem> reader,
IServiceScopeFactory scopeFactory,
ILogger<ScaledChannelProcessor> logger) : BackgroundService
{
private const int WorkerCount = 3;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var workers = Enumerable.Range(0, WorkerCount)
.Select(i => ConsumeAsync(i, stoppingToken));
await Task.WhenAll(workers);
}
private async Task ConsumeAsync(int workerId, CancellationToken ct)
{
logger.LogDebug("Consumer {WorkerId} started", workerId);
while (await reader.WaitToReadAsync(ct))
{
while (reader.TryRead(out var item))
{
try
{
using var scope = scopeFactory.CreateScope();
var handler = scope.ServiceProvider
.GetRequiredService<IWorkItemHandler>();
await handler.HandleAsync(item, ct);
}
catch (Exception ex)
{
logger.LogError(ex,
"Consumer {WorkerId}: error processing {ItemId}",
workerId, item.Id);
}
}
}
logger.LogDebug("Consumer {WorkerId} stopped", workerId);
}
}---
IAsyncEnumerable Integration
ChannelReader<T>.ReadAllAsync() returns an IAsyncEnumerable<T>, enabling await foreach consumption and integration with LINQ async operators.
Basic await foreach
await foreach (var item in reader.ReadAllAsync(cancellationToken))
{
await ProcessAsync(item, cancellationToken);
}
// Loop exits when writer calls Complete() and all items are consumedReadAllAsync is the simplest consumption pattern. It handles WaitToReadAsync/TryRead internally and completes when the channel is closed.
Streaming from an API Endpoint
Channels combine naturally with ASP.NET Core streaming responses. Return the IAsyncEnumerable<T> directly -- minimal APIs will stream items as JSON array elements:
app.MapGet("/api/events/stream", (
ChannelReader<ServerEvent> reader,
CancellationToken ct) => reader.ReadAllAsync(ct));LINQ Async Operators
With the System.Linq.Async NuGet package, channel streams compose with familiar LINQ operators:
// NuGet: System.Linq.Async
await foreach (var batch in reader.ReadAllAsync(ct)
.Where(item => item.Priority >= Priority.High)
.Buffer(50) // Collect into batches of 50
.WithCancellation(ct))
{
await BulkProcessAsync(batch, ct);
}Producing an IAsyncEnumerable from a Channel
async IAsyncEnumerable<PriceUpdate> StreamPricesAsync(
string symbol,
[EnumeratorCancellation] CancellationToken ct = default)
{
var channel = Channel.CreateUnbounded<PriceUpdate>();
// Start producer in background
_ = Task.Run(async () =>
{
try
{
await foreach (var tick in marketFeed.SubscribeAsync(symbol, ct))
{
await channel.Writer.WriteAsync(tick, ct);
}
channel.Writer.TryComplete();
}
catch (Exception ex)
{
// Propagate error to reader -- ReadAllAsync will throw
channel.Writer.TryComplete(ex);
}
}, ct);
await foreach (var update in channel.Reader.ReadAllAsync(ct))
{
yield return update;
}
}---
Performance
SingleReader / SingleWriter Flags
Setting SingleReader = true or SingleWriter = true on channel options enables lock-free optimizations. The channel trusts these hints -- violating them (multiple concurrent readers when SingleReader = true) causes data corruption.
// Optimal for single-producer, single-consumer pipeline
var channel = Channel.CreateBounded<T>(new BoundedChannelOptions(1000)
{
SingleReader = true, // One consumer task
SingleWriter = true, // One producer task
FullMode = BoundedChannelFullMode.Wait
});WaitToReadAsync + TryRead Pattern
The most efficient consumer pattern. WaitToReadAsync suspends until data is available, then TryRead drains all buffered items synchronously -- avoiding per-item async state machine overhead.
while (await reader.WaitToReadAsync(ct))
{
// Drain all currently buffered items synchronously
while (reader.TryRead(out var item))
{
Process(item);
}
}TryWrite Fast Path
TryWrite is synchronous and allocation-free when the channel has space. Prefer it over WriteAsync in hot paths where you can handle the false return.
// Hot path -- avoid async overhead when channel has space
if (!writer.TryWrite(item))
{
// Slow path -- wait for space (or handle overflow)
await writer.WriteAsync(item, ct);
}Bounded Channel Memory Behavior
Bounded channels pre-allocate an internal array of capacity slots. Items are stored by reference (for reference types), so the channel holds references until consumed. For memory-sensitive workloads:
- Choose capacity based on expected item size multiplied by count
- Items are eligible for GC as soon as
TryRead/ReadAsyncreturns them - Drop modes (
DropOldest,DropNewest) keep memory stable but lose data
---
Cancellation and Graceful Shutdown
Basic Cancellation
Pass a CancellationToken to all async channel operations. When cancelled, operations throw OperationCanceledException.
try
{
await foreach (var item in reader.ReadAllAsync(stoppingToken))
{
await ProcessAsync(item, stoppingToken);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Expected during shutdown
}Drain Pattern
Complete the writer to signal no more items will arrive, then drain remaining items before stopping. This prevents data loss during shutdown.
public sealed class DrainableProcessor(
Channel<WorkItem> channel,
IServiceScopeFactory scopeFactory,
ILogger<DrainableProcessor> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var reader = channel.Reader;
try
{
while (await reader.WaitToReadAsync(stoppingToken))
{
while (reader.TryRead(out var item))
{
using var scope = scopeFactory.CreateScope();
var handler = scope.ServiceProvider
.GetRequiredService<IWorkItemHandler>();
await handler.HandleAsync(item, stoppingToken);
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Shutdown requested -- fall through to drain
}
// Signal producers to stop -- any concurrent WriteAsync will throw ChannelClosedException
channel.Writer.TryComplete();
// Drain remaining items with a deadline
logger.LogInformation("Draining remaining work items");
using var drainCts = new CancellationTokenSource(TimeSpan.FromSeconds(25));
while (reader.TryRead(out var remaining))
{
try
{
using var scope = scopeFactory.CreateScope();
var handler = scope.ServiceProvider
.GetRequiredService<IWorkItemHandler>();
await handler.HandleAsync(remaining, drainCts.Token);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Error during drain");
}
}
logger.LogInformation("Drain complete");
}
}Host Shutdown Timeout
The default host shutdown timeout is 30 seconds. If your drain needs more time, configure it:
builder.Services.Configure<HostOptions>(options =>
{
options.ShutdownTimeout = TimeSpan.FromSeconds(60);
});---
Agent Gotchas
1. Do not use unbounded channels in production without rate control -- they can exhaust memory under sustained producer pressure. Always prefer bounded channels with explicit capacity. 2. Do not violate SingleReader/SingleWriter promises -- these flags enable lock-free optimizations. Multiple concurrent readers with SingleReader = true causes data corruption, not exceptions. 3. Do not forget to call `Complete()` on the writer -- without completion, consumers using ReadAllAsync() or WaitToReadAsync will wait indefinitely after the last item. 4. Do not catch `ChannelClosedException` globally -- it signals that the writer called Complete(), possibly with an error. Catch it only around ReadAsync calls; WaitToReadAsync/TryRead loops handle completion via false return. 5. Do not use `ReadAsync` in hot paths -- prefer the WaitToReadAsync + TryRead pattern to drain buffered items synchronously and reduce async state machine allocations. 6. Do not block in `TryWrite == false` overflow handling -- keep overflow accounting cheap (increment counter, log). Offload heavy work to avoid stalling the producer thread.
---
References
Code Smells
Proactive code-smell and anti-pattern detection for C# code. This skill triggers during all workflow modes -- planning, implementation, and review. Each entry identifies the smell, explains why it is harmful, provides the correct fix, and references the relevant CA rule or cross-reference.
1. Resource Management (IDisposable Misuse)
| Smell | Why Harmful | Fix | Rule |
|---|---|---|---|
Missing using on disposable locals | Leaks unmanaged handles (sockets, files, DB connections) | Wrap in using declaration or using block | CA2000 |
Undisposed IDisposable fields | Class holds disposable resource but never disposes it | Implement IDisposable; dispose fields in Dispose() | CA2213 |
| Wrong Dispose pattern (no finalizer guard) | Double-dispose or missed cleanup on GC finalization | Follow canonical Dispose(bool) pattern; call GC.SuppressFinalize(this) | CA1816 |
| Disposable created in one method, stored in field | Ownership unclear; easy to forget disposal | Document ownership; make the containing class IDisposable | CA2000 |
using on non-owned resource | Premature disposal of shared resource (e.g., injected HttpClient) | Only dispose resources you create; let DI manage injected services | -- |
See the Detailed Examples and Fixes section below for code examples of each pattern.
---
2. Warning Suppression Hacks
| Smell | Why Harmful | Fix | Rule |
|---|---|---|---|
Invoking event with null to suppress CS0067 | Creates misleading runtime behavior; masks real bugs | Use #pragma warning disable CS0067 or explicit event accessors { add {} remove {} } | CS0067 |
| Dummy variable assignments to suppress CS0219 | Dead code that confuses readers | Use _ = expression; discard or #pragma warning disable | CS0219 |
Blanket #pragma warning disable without restore | Suppresses ALL warnings for rest of file | Always pair with #pragma warning restore; suppress specific codes only | -- |
[SuppressMessage] without justification | Future maintainers cannot evaluate if suppression is still valid | Always include Justification = "reason" | CA1303 |
See the Detailed Examples and Fixes section below for the CS0067 motivating example (bad pattern to correct fix).
---
3. LINQ Anti-Patterns
| Smell | Why Harmful | Fix | Rule |
|---|---|---|---|
Premature .ToList() mid-chain | Forces full materialization; wastes memory | Keep chain lazy; materialize only at the end | CA1851 |
Multiple enumeration of IEnumerable<T> | Re-executes query or DB call on each enumeration | Materialize once with .ToList() then reuse | CA1851 |
| Client-side evaluation in EF Core | Loads entire table into memory; silent perf bomb | Rewrite query as translatable LINQ or use AsAsyncEnumerable() with explicit intent | -- |
.Count() > 0 instead of .Any() | Enumerates entire collection instead of short-circuiting | Use .Any() for existence checks | CA1827 |
Nested foreach instead of .Join() or .GroupJoin() | O(n*m) when O(n+m) is possible | Use LINQ join operations or Dictionary lookup | -- |
.Where().First() instead of .First(predicate) | Creates unnecessary intermediate iterator | Pass predicate directly to .First() or .FirstOrDefault() | CA1826 |
---
4. Event Handling Leaks
| Smell | Why Harmful | Fix | Rule |
|---|---|---|---|
| Not unsubscribing from events | Memory leak: publisher holds reference to subscriber | Unsubscribe in Dispose() or use weak event pattern | -- |
| Raising events in constructor | Subscribers may not be attached yet; derived class not fully constructed | Raise events only from fully initialized instances | CA2214 |
async void event handler (misused) | async void is the only valid signature for event handlers, but exceptions are unobservable | Wrap body in try/catch; log and handle exceptions explicitly | -- |
| Event handler not checking for null | NullReferenceException when no subscribers | Use event?.Invoke() null-conditional pattern | -- |
| Static event without cleanup | Rooted references prevent GC for application lifetime | Prefer instance events or use WeakEventManager | -- |
Cross-reference: references/async-patterns.md covers async void fire-and-forget patterns in depth.
---
5. Design Smells
| Smell | Threshold | Why Harmful | Fix |
|---|---|---|---|
| God class | >500 lines | Too many responsibilities; hard to test and maintain | Extract cohesive classes using SRP |
| Long method | >30 lines | Hard to understand, test, and review | Extract helper methods with descriptive names |
| Long parameter list | >5 parameters | Indicates missing abstraction | Introduce parameter object or builder |
| Feature envy | Method uses another class's data more than its own | Misplaced responsibility; tight coupling | Move method to the class it envies |
| Primitive obsession | Domain concepts represented as raw string/int | No type safety; validation scattered | Introduce value objects or strongly-typed IDs |
| Deep nesting | >3 levels of indentation | Hard to follow control flow | Use guard clauses (early return) and extract methods |
---
6. Exception Handling Gaps
| Smell | Why Harmful | Fix | Rule |
|---|---|---|---|
| Empty catch block | Silently swallows errors; masks bugs | At minimum, log the exception; prefer letting it propagate | CA1031 |
Catching base Exception | Catches OutOfMemoryException, StackOverflowException, etc. | Catch specific exception types | CA1031 |
Log-and-swallow (catch { log; }) | Caller never learns operation failed | Re-throw after logging, or return error result | -- |
Throwing in finally | Masks original exception with the new one | Use try/catch inside finally; never throw from finally | -- |
throw ex; instead of throw; | Resets stack trace; hides original failure location | Use bare throw; to preserve stack trace | CA2200 |
| Not including inner exception | Loses causal chain when wrapping exceptions | Pass original as innerException parameter | -- |
Cross-reference: references/async-patterns.md covers exception handling in fire-and-forget and async void scenarios.
---
Quick Reference: CA Rules
| Rule | Description |
|---|---|
| CA1031 | Do not catch general exception types |
| CA1816 | Call GC.SuppressFinalize correctly |
| CA1826 | Do not use Enumerable methods on indexable collections |
| CA1827 | Do not use Count()/LongCount() when Any() can be used |
| CA1851 | Possible multiple enumerations of IEnumerable collection |
| CA2000 | Dispose objects before losing scope |
| CA2200 | Rethrow to preserve stack details |
| CA2213 | Disposable fields should be disposed |
| CA2214 | Do not call overridable methods in constructors |
Enable these via <AnalysisLevel>latest-all</AnalysisLevel> in your project. See references/coding-standards.md for analyzer configuration.
---
References
---
Detailed Examples and Fixes
Code examples for each anti-pattern category. Each section shows the bad pattern followed by the correct fix.
---
1. Resource Management (IDisposable)
Missing using on Disposable Local (CA2000)
// BAD: StreamReader is never disposed if an exception occurs
public string ReadFile(string path)
{
var reader = new StreamReader(path);
return reader.ReadToEnd(); // reader leaked on exception or normal exit
}
// FIX: using declaration ensures disposal
public string ReadFile(string path)
{
using var reader = new StreamReader(path);
return reader.ReadToEnd();
}Undisposed IDisposable Fields (CA2213)
// BAD: _timer is never disposed
public class PollingService
{
private readonly Timer _timer = new(Callback, null, TimeSpan.Zero, TimeSpan.FromSeconds(30));
private static void Callback(object? state) { /* ... */ }
}
// FIX: implement IDisposable and dispose the field
public sealed class PollingService : IDisposable
{
private readonly Timer _timer = new(Callback, null, TimeSpan.Zero, TimeSpan.FromSeconds(30));
private static void Callback(object? state) { /* ... */ }
public void Dispose() => _timer.Dispose();
}Canonical Dispose Pattern (for unsealed classes)
public class ResourceHolder : IDisposable
{
private SafeHandle? _handle;
private bool _disposed;
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this); // CA1816
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
_handle?.Dispose();
}
_disposed = true;
}
}---
2. Warning Suppression Hacks
CS0067: Event Never Used -- Suppression via Null Invoke (Motivating Example)
This is a real-world anti-pattern where a developer invokes an event with null arguments solely to suppress compiler warning CS0067 ("The event is never used").
// BAD: invoking event with null to suppress CS0067
// Creates misleading runtime behavior -- subscribers receive null args
public class SuppressWarnings
{
public event EventHandler<EventArgs> MyEvent;
public SuppressWarnings()
{
// This "works" to suppress the warning but:
// 1. Fires the event with null sender during construction
// 2. Subscribers (if any) receive unexpected null args
// 3. Masks the real issue: the event may be genuinely unused
MyEvent?.Invoke(null, EventArgs.Empty);
}
}Correct alternatives:
// FIX Option 1: #pragma warning disable (preferred when event is needed for interface compliance)
public class SuppressWarnings
{
#pragma warning disable CS0067 // Event is required by INotifyPropertyChanged but raised via helper
public event EventHandler<EventArgs> MyEvent;
#pragma warning restore CS0067
}
// FIX Option 2: Explicit event accessors (preferred when event is a no-op by design)
public class SuppressWarnings
{
public event EventHandler<EventArgs> MyEvent { add { } remove { } }
}
// FIX Option 3: If the event is truly unused, remove it entirely---
3. LINQ Anti-Patterns
Premature .ToList() Mid-Chain
// BAD: materializes full list before filtering
var result = orders
.ToList() // forces full materialization
.Where(o => o.IsActive)
.Select(o => o.Id)
.ToList();
// FIX: keep chain lazy, materialize only at the end
var result = orders
.Where(o => o.IsActive)
.Select(o => o.Id)
.ToList();Multiple Enumeration of IEnumerable (CA1851)
// BAD: enumerates the sequence twice
public void Process(IEnumerable<Order> orders)
{
Console.WriteLine($"Count: {orders.Count()}"); // first enumeration
foreach (var order in orders) // second enumeration
{
Handle(order);
}
}
// FIX: materialize once
public void Process(IEnumerable<Order> orders)
{
var orderList = orders.ToList();
Console.WriteLine($"Count: {orderList.Count}");
foreach (var order in orderList)
{
Handle(order);
}
}Client-Side Evaluation in EF Core
// BAD: CustomFormat() cannot be translated to SQL; entire table loaded into memory
var names = dbContext.Customers
.Where(c => CustomFormat(c.Name).StartsWith("VIP"))
.ToListAsync();
// FIX: use translatable expressions or filter after explicit load
var names = await dbContext.Customers
.Where(c => c.Name.StartsWith("VIP")) // translatable to SQL
.ToListAsync();---
4. Event Handling Leaks
Not Unsubscribing from Events
// BAD: subscriber never unsubscribes; publisher holds reference forever
public class Dashboard
{
public Dashboard(OrderService service)
{
service.OrderCreated += OnOrderCreated;
// If Dashboard is disposed but OrderService lives on,
// Dashboard is never garbage collected
}
private void OnOrderCreated(object? sender, OrderEventArgs e) { /* ... */ }
}
// FIX: implement IDisposable and unsubscribe
public sealed class Dashboard : IDisposable
{
private readonly OrderService _service;
public Dashboard(OrderService service)
{
_service = service;
_service.OrderCreated += OnOrderCreated;
}
private void OnOrderCreated(object? sender, OrderEventArgs e) { /* ... */ }
public void Dispose()
{
_service.OrderCreated -= OnOrderCreated;
}
}Async Void Event Handler Exception Handling
// BAD: async void with no exception handling; crashes the process
private async void OnButtonClick(object? sender, EventArgs e)
{
await ProcessOrderAsync(); // unhandled exception terminates app
}
// FIX: wrap in try/catch since async void exceptions are unobservable
private async void OnButtonClick(object? sender, EventArgs e)
{
try
{
await ProcessOrderAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process order on button click");
// Show user-facing error or handle gracefully
}
}---
5. Async Exception Routing (Motivating Example)
TryEnqueue with Async Lambda -- Exceptions Lost
This is a real-world anti-pattern where exceptions inside an async lambda are silently lost because they are not routed through a TaskCompletionSource.
// BAD: exception inside async lambda is never observed
public Task<int> ComputeOnUiThreadAsync()
{
var tcs = new TaskCompletionSource<int>();
dispatcherQueue.TryEnqueue(async () =>
{
// If DoWorkAsync() throws, the exception is swallowed.
// The tcs never completes -- caller hangs forever.
var result = await DoWorkAsync();
tcs.SetResult(result);
});
return tcs.Task;
}
// FIX: route exceptions through the TaskCompletionSource
public Task<int> ComputeOnUiThreadAsync()
{
var tcs = new TaskCompletionSource<int>();
dispatcherQueue.TryEnqueue(async () =>
{
try
{
var result = await DoWorkAsync();
tcs.SetResult(result);
}
catch (OperationCanceledException)
{
tcs.TrySetCanceled();
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
});
return tcs.Task;
}Cross-reference: See references/async-patterns.md for broader async exception handling patterns.
---
6. Exception Handling Gaps
Empty Catch Block
// BAD: silently swallows all errors
try
{
await SaveOrderAsync(order);
}
catch (Exception)
{
// nothing -- caller thinks save succeeded
}
// FIX: at minimum log; preferably re-throw or return error
try
{
await SaveOrderAsync(order);
}
catch (DbUpdateException ex)
{
_logger.LogError(ex, "Failed to save order {OrderId}", order.Id);
throw; // let caller handle the failure
}throw ex; Resets Stack Trace (CA2200)
// BAD: resets stack trace
catch (Exception ex)
{
_logger.LogError(ex, "Operation failed");
throw ex; // CA2200: stack trace lost
}
// FIX: bare throw preserves stack trace
catch (Exception ex)
{
_logger.LogError(ex, "Operation failed");
throw; // preserves original stack trace
}Throwing in Finally
// BAD: exception in finally masks the original exception
try
{
await ProcessAsync();
}
finally
{
CleanupThatMayThrow(); // if this throws, original exception is lost
}
// FIX: guard the finally block
try
{
await ProcessAsync();
}
finally
{
try
{
CleanupThatMayThrow();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Cleanup failed; original exception preserved");
}
}---
7. Design Smells
Long Parameter List -- Introduce Parameter Object
// BAD: 7 parameters -- hard to call correctly, easy to swap arguments
public Order CreateOrder(
string customerId, string productId, int quantity,
decimal price, string currency, string shippingAddress,
DateTime requestedDelivery)
{ /* ... */ }
// FIX: introduce a parameter object
public sealed record CreateOrderRequest(
string CustomerId,
string ProductId,
int Quantity,
decimal Price,
string Currency,
string ShippingAddress,
DateTime RequestedDelivery);
public Order CreateOrder(CreateOrderRequest request) { /* ... */ }Deep Nesting -- Use Guard Clauses
// BAD: deeply nested logic
public decimal CalculateDiscount(Order order)
{
if (order != null)
{
if (order.Customer != null)
{
if (order.Customer.IsPremium)
{
if (order.Total > 100)
{
return order.Total * 0.1m;
}
}
}
}
return 0;
}
// FIX: guard clauses for early return
public decimal CalculateDiscount(Order order)
{
if (order?.Customer is not { IsPremium: true })
{
return 0;
}
if (order.Total <= 100)
{
return 0;
}
return order.Total * 0.1m;
}Coding Standards
Modern .NET coding standards based on Microsoft Framework Design Guidelines and C# Coding Conventions. This reference covers naming, file organization, and code style rules that agents should follow when generating or reviewing C# code.
This reference is a baseline dependency that should be loaded before domain-specific C#/.NET references. Load it by default for any task that plans, designs, generates, modifies, or reviews C#/.NET code.
Cross-references: references/modern-patterns.md for language feature usage, references/async-patterns.md for async naming conventions, references/solid-principles.md for SOLID, DRY, and SRP design principles.
Naming Conventions
| Element | Convention | Example |
|---|---|---|
| Namespaces | PascalCase, dot-separated | MyCompany.MyProduct.Core |
| Classes, Records, Structs | PascalCase | OrderService, OrderSummary |
| Interfaces | I + PascalCase | IOrderRepository |
| Methods | PascalCase | GetOrderAsync |
| Properties | PascalCase | OrderDate |
| Events | PascalCase | OrderCompleted |
| Public constants | PascalCase | MaxRetryCount |
| Private fields | _camelCase | _orderRepository |
| Parameters, locals | camelCase | orderId, totalAmount |
| Type parameters | T or T + PascalCase | T, TKey, TValue |
| Enum members | PascalCase | OrderStatus.Pending |
Additional naming rules:
- Suffix async methods with
Async(e.g.,GetOrderAsync,SaveChangesAsync). - Prefix booleans with
is,has,can, orshould(e.g.,IsActive,HasOrders). - Use plural nouns for collections (e.g.,
OrdersnotOrderList).
---
File Organization
- One type per file, named exactly as the type. Nested types stay in the containing type's file.
- File-scoped namespaces (C# 10+):
namespace MyApp.Services;-- avoid block-scoped namespaces. - Using directives at top of file, outside the namespace. Order:
System.*, third-party, project namespaces. - Directory structure organized by feature or layer, matching namespace hierarchy.
---
Code Style Rules
- Always use braces for control flow, even for single-line bodies.
- Expression-bodied members for single-expression properties and methods:
public string FullName => $"{FirstName} {LastName}"; - Use `var` when type is obvious from right-hand side; use explicit type when not obvious.
- Null handling: Prefer
is not null/is nullover!= null/== null. Use null-conditional (?.) and null-coalescing (??,??=) operators. - String interpolation over concatenation or
string.Format. - Explicit access modifiers always -- never rely on defaults. Order: access, static, extern, new, virtual/abstract/override/sealed, readonly, volatile, async, partial.
- Seal classes not designed for inheritance for performance and intent clarity.
---
CancellationToken Conventions
Accept CancellationToken as the last parameter in async methods with default as the default value. Always forward the token to downstream async calls:
public async Task<Order> GetOrderAsync(int id, CancellationToken ct = default)
{
return await _repo.GetByIdAsync(id, ct);
}---
XML Documentation
Add XML docs to public API surfaces. Keep them concise:
/// <summary>
/// Retrieves an order by its unique identifier.
/// </summary>
/// <param name="id">The order identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The order, or <see langword="null"/> if not found.</returns>
public Task<Order?> GetByIdAsync(int id, CancellationToken ct = default);Do not add XML docs to private/internal members, self-evident members, or test methods.
---
Analyzer Enforcement
Configure in Directory.Build.props for automated enforcement:
<PropertyGroup>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisLevel>latest-all</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>Key .editorconfig rules:
[*.cs]
csharp_style_namespace_declarations = file_scoped:warning
csharp_prefer_braces = true:warning
csharp_style_var_when_type_is_apparent = true:suggestion
dotnet_style_require_accessibility_modifiers = always:warning
csharp_style_prefer_pattern_matching = true:suggestionSee references/editorconfig.md for full editorconfig configuration. See [skill:dotnet-tooling] for analyzer package setup.
---
References
Concurrency Patterns
Thread synchronization primitives, concurrent data structures, and a decision framework for choosing the right concurrency mechanism. Covers lock/Monitor, SemaphoreSlim, Interlocked, ConcurrentDictionary, ConcurrentQueue, ReaderWriterLockSlim, and SpinLock. This skill is the authoritative source for synchronization and thread-safe data access patterns.
Version assumptions: .NET 8.0+ baseline. All primitives covered are available from .NET Core 1.0+ but examples use modern C# idioms.
Concurrency Primitive Decision Framework
Choose the simplest primitive that meets the requirement. Complexity increases downward:
Is the shared state a single scalar (int, long, reference)?
YES -> Use Interlocked (lock-free, lowest overhead)
Is the shared state a key-value lookup or queue?
YES -> Use ConcurrentDictionary / ConcurrentQueue (thread-safe by design)
Does the critical section contain `await`?
YES -> Use SemaphoreSlim (async-compatible via WaitAsync)
NO -> Does the critical section need many readers, few writers?
YES -> Use ReaderWriterLockSlim (only if profiling shows lock contention)
NO -> Use lock (simplest, lowest cognitive overhead)
Is the critical section extremely short (< 100 ns) with high contention?
YES -> Consider SpinLock (advanced, measure first)Quick Reference Table
| Primitive | Async-Safe | Reentrant | Use Case |
|---|---|---|---|
lock / Monitor | No | Yes (same thread) | Short critical sections without await |
SemaphoreSlim | Yes (WaitAsync) | No | Async-compatible mutual exclusion, throttling |
Interlocked | N/A (lock-free) | N/A | Atomic scalar operations (increment, compare-exchange) |
ConcurrentDictionary<K,V> | N/A (thread-safe) | N/A | Thread-safe key-value cache/lookup |
ConcurrentQueue<T> | N/A (thread-safe) | N/A | Thread-safe FIFO queue |
ReaderWriterLockSlim | No | Optional (LockRecursionPolicy) | Many-readers/few-writers (profile-driven only) |
SpinLock | No | No | Ultra-short critical sections under extreme contention |
---
lock and Monitor
lock is syntactic sugar for Monitor.Enter/Monitor.Exit. Use it for short, synchronous critical sections.
Correct Usage
public sealed class Counter
{
private readonly object _lock = new();
private int _count;
public void Increment()
{
lock (_lock)
{
_count++;
}
}
public int GetCount()
{
lock (_lock)
{
return _count;
}
}
}Lock Object Rules
| Rule | Rationale |
|---|---|
Use a private, dedicated object field | Prevents external code from locking on the same object |
Never lock on this | Any external code with a reference can cause deadlocks |
Never lock on typeof(T) | Global lock shared by all code in the AppDomain |
| Never lock on string literals | String interning means different code may share the same reference |
| Never lock on value types | Boxing creates a new object each time -- lock is never acquired |
Monitor.Wait / Monitor.Pulse
For signaling between threads (producer/consumer without Channel<T>):
public sealed class BoundedBuffer<T>
{
private readonly Queue<T> _queue = new();
private readonly object _lock = new();
private readonly int _maxSize;
public BoundedBuffer(int maxSize) => _maxSize = maxSize;
public void Enqueue(T item)
{
lock (_lock)
{
while (_queue.Count >= _maxSize)
Monitor.Wait(_lock);
_queue.Enqueue(item);
Monitor.Pulse(_lock);
}
}
public T Dequeue()
{
lock (_lock)
{
while (_queue.Count == 0)
Monitor.Wait(_lock);
var item = _queue.Dequeue();
Monitor.Pulse(_lock);
return item;
}
}
}For modern code, prefer Channel<T> (see references/channels.md) over Monitor.Wait/Pulse.
---
SemaphoreSlim
The only built-in .NET synchronization primitive that supports await. Use it whenever a critical section contains async operations.
Mutual Exclusion (1,1)
public sealed class AsyncCache
{
private readonly SemaphoreSlim _semaphore = new(1, 1);
private readonly Dictionary<string, object> _cache = new();
public async Task<T> GetOrAddAsync<T>(string key,
Func<CancellationToken, Task<T>> factory,
CancellationToken ct = default)
{
await _semaphore.WaitAsync(ct);
try
{
if (_cache.TryGetValue(key, out var existing))
return (T)existing;
var value = await factory(ct);
_cache[key] = value!;
return value;
}
finally
{
_semaphore.Release();
}
}
}Throttling (N concurrent operations)
public sealed class ThrottledProcessor
{
private readonly SemaphoreSlim _throttle;
public ThrottledProcessor(int maxConcurrency)
=> _throttle = new SemaphoreSlim(maxConcurrency, maxConcurrency);
public async Task ProcessAllAsync(IEnumerable<WorkItem> items,
CancellationToken ct = default)
{
var tasks = items.Select(async item =>
{
await _throttle.WaitAsync(ct);
try
{
await ProcessItemAsync(item, ct);
}
finally
{
_throttle.Release();
}
});
await Task.WhenAll(tasks);
}
private Task ProcessItemAsync(WorkItem item, CancellationToken ct) =>
Task.CompletedTask; // implementation
}SemaphoreSlim Disposal
SemaphoreSlim implements IDisposable. Dispose it when the owning object is disposed:
public sealed class ManagedResource : IDisposable
{
private readonly SemaphoreSlim _semaphore = new(1, 1);
public void Dispose() => _semaphore.Dispose();
}---
Interlocked Operations
Lock-free atomic operations for scalar values. The lowest-overhead synchronization mechanism.
Common Operations
private int _counter;
private long _totalBytes;
private object? _current;
// Atomic increment / decrement
Interlocked.Increment(ref _counter);
Interlocked.Decrement(ref _counter);
// Atomic add
Interlocked.Add(ref _totalBytes, bytesRead);
// Atomic exchange -- returns the old value
var previous = Interlocked.Exchange(ref _current, newValue);
// Compare-and-swap -- only writes if current value matches expected
var original = Interlocked.CompareExchange(ref _counter,
newValue: 10,
comparand: 0); // Sets to 10 only if current value is 0Volatile Read/Write
For visibility guarantees without atomicity (reading the latest value written by another thread):
private int _flag;
// Write with release semantics (all prior writes visible to readers)
Volatile.Write(ref _flag, 1);
// Read with acquire semantics (sees all writes prior to the last Volatile.Write)
var value = Volatile.Read(ref _flag);Interlocked vs volatile vs lock
| Mechanism | Atomicity | Ordering | Use Case |
|---|---|---|---|
Interlocked | Yes | Full fence | Counters, flags, CAS loops |
Volatile.Read/Write | No (single read/write is naturally atomic for aligned <= pointer-size) | Acquire/release | Signal flags, publication patterns |
lock | Yes (for entire block) | Full fence | Multi-step operations on shared state |
---
ConcurrentDictionary
Thread-safe key-value store. The most commonly used concurrent collection.
Safe Patterns
private readonly ConcurrentDictionary<int, Widget> _cache = new();
// Atomic get-or-add
var widget = _cache.GetOrAdd(id, key => LoadWidget(key));
// Atomic add-or-update
var updated = _cache.AddOrUpdate(id,
addValueFactory: key => CreateDefault(key),
updateValueFactory: (key, existing) => existing with { LastAccessed = DateTime.UtcNow });
// Safe removal
if (_cache.TryRemove(id, out var removed))
{
// Process removed item
}Delegate Execution Caveats
GetOrAdd and AddOrUpdate factory delegates may execute multiple times under contention. Only one result is stored, but the factory runs for each competing thread:
// WRONG -- factory has side effects (database write) that may run multiple times
var widget = _cache.GetOrAdd(id, key =>
{
var w = new Widget(key);
_db.Insert(w); // May execute more than once!
return w;
});
// CORRECT -- use Lazy<T> to ensure factory runs exactly once
private readonly ConcurrentDictionary<int, Lazy<Widget>> _cache = new();
var widget = _cache.GetOrAdd(id,
key => new Lazy<Widget>(() => LoadAndSaveWidget(key))).Value;Composite Operations Are Not Atomic
// WRONG -- check-then-act race condition
if (!_cache.ContainsKey(key))
{
_cache[key] = ComputeValue(key); // Another thread may have added between check and set
}
// CORRECT -- single atomic operation
var value = _cache.GetOrAdd(key, k => ComputeValue(k));---
ReaderWriterLockSlim
Allows concurrent reads while serializing writes. Only beneficial when reads significantly outnumber writes AND profiling shows lock contention on the read path.
public sealed class ReadHeavyCache<TKey, TValue> : IDisposable
where TKey : notnull
{
private readonly ReaderWriterLockSlim _rwLock = new();
private readonly Dictionary<TKey, TValue> _data = new();
public TValue? TryGet(TKey key)
{
_rwLock.EnterReadLock();
try
{
return _data.TryGetValue(key, out var value) ? value : default;
}
finally
{
_rwLock.ExitReadLock();
}
}
public void Set(TKey key, TValue value)
{
_rwLock.EnterWriteLock();
try
{
_data[key] = value;
}
finally
{
_rwLock.ExitWriteLock();
}
}
public void Dispose() => _rwLock.Dispose();
}When NOT to use ReaderWriterLockSlim:
- Reads and writes are roughly equal --
lockis simpler and faster - Critical sections contain
await-- not async-compatible; useSemaphoreSlim - You need a concurrent dictionary -- use
ConcurrentDictionarydirectly
---
SpinLock
A low-level primitive for ultra-short critical sections where thread switching overhead exceeds the wait time. Measure before using.
private SpinLock _spinLock = new(enableThreadOwnerTracking: false);
public void UpdateCounter()
{
bool lockTaken = false;
try
{
_spinLock.Enter(ref lockTaken);
_counter++; // Must be extremely fast -- no I/O, no allocations
}
finally
{
if (lockTaken)
_spinLock.Exit(useMemoryBarrier: false);
}
}Rules:
- Never use
SpinLockfor anything longer than ~100 nanoseconds - Never use in async code (thread affinity required)
- Never use
enableThreadOwnerTracking: truein production (debug only -- adds overhead) SpinLockis astruct-- always pass by reference, never copy
---
Thread-Safe Patterns
Immutable Snapshots
Prefer immutable data for sharing across threads without synchronization:
// Thread-safe via immutability -- no locks needed for reads
private ImmutableList<Widget> _widgets = ImmutableList<Widget>.Empty;
public void AddWidget(Widget widget)
{
// Atomic swap using Interlocked.CompareExchange loop
ImmutableList<Widget> original, updated;
do
{
original = _widgets;
updated = original.Add(widget);
}
while (Interlocked.CompareExchange(ref _widgets, updated, original) != original);
}
public ImmutableList<Widget> GetWidgets() => _widgets; // No lock neededDouble-Checked Locking
For lazy initialization when Lazy<T> is not appropriate:
private volatile Widget? _instance;
private readonly object _lock = new();
public Widget GetInstance()
{
var instance = _instance;
if (instance is not null)
return instance;
lock (_lock)
{
instance = _instance;
if (instance is not null)
return instance;
instance = CreateWidget();
_instance = instance;
return instance;
}
}For most cases, prefer Lazy<T> which handles this correctly:
private readonly Lazy<Widget> _instance = new(() => CreateWidget());
public Widget Instance => _instance.Value;---
Agent Gotchas
1. Do not use `lock` inside `async` methods -- lock is thread-affine; the continuation after await may resume on a different thread, causing SynchronizationLockException. Use SemaphoreSlim.WaitAsync instead. 2. Do not assume `volatile` provides atomicity -- volatile only provides ordering guarantees (acquire/release semantics). Compound operations like _counter++ are still non-atomic on volatile fields. Use Interlocked for atomic operations. 3. Do not use `ConcurrentDictionary.ContainsKey` followed by indexer set -- this is a check-then-act race condition. Use GetOrAdd, AddOrUpdate, or TryAdd for atomic composite operations. 4. Do not use `ReaderWriterLockSlim` without profiling evidence -- it has higher overhead than lock and is only beneficial when reads significantly outnumber writes. Default to lock and only switch if contention is measured. 5. Do not copy `SpinLock` -- it is a struct. Copying creates a new, unlocked instance. Always pass by reference and store in a field (not a local variable that gets captured by a lambda). 6. Do not use `lock(this)` or `lock(typeof(T))` -- external code can acquire the same lock, causing unexpected contention or deadlocks. Always use a private, dedicated lock object. 7. Do not forget to release `SemaphoreSlim` in `finally` -- if an exception occurs between WaitAsync and Release, the semaphore stays acquired permanently, blocking all subsequent callers. 8. Do not assume `GetOrAdd` factory executes exactly once -- under contention, the factory delegate may run on multiple threads simultaneously. Only one result is stored, but side effects in the factory execute multiple times. Use Lazy<T> wrapping for exactly-once semantics.
---
Prerequisites
- .NET 8.0+ SDK
- Understanding of async/await patterns (see
references/async-patterns.md) - Understanding of producer/consumer patterns (see
references/channels.md) System.Collections.ConcurrentnamespaceSystem.Collections.Immutablenamespace (for immutable collection patterns)
---
References
Configuration
Configuration patterns for .NET applications using Microsoft.Extensions.Configuration and Microsoft.Extensions.Options. Covers the Options pattern (IOptions<T>, IOptionsMonitor<T>, IOptionsSnapshot<T>), validation, user secrets, environment-based configuration, and feature flags with Microsoft.FeatureManagement.
Cross-references: references/dependency-injection.md for service registration patterns, references/coding-standards.md for naming conventions.
---
Configuration Sources and Precedence
Default configuration sources in WebApplication.CreateBuilder (last wins):
1. appsettings.json 2. appsettings.{Environment}.json 3. User secrets (Development only) 4. Environment variables 5. Command-line arguments
var builder = WebApplication.CreateBuilder(args);
// Sources above are loaded automatically. Add custom sources:
builder.Configuration.AddJsonFile("features.json", optional: true, reloadOnChange: true);---
Options Pattern
Bind configuration sections to strongly typed classes and inject them via DI.
Defining Options Classes
public sealed class SmtpOptions
{
public const string SectionName = "Smtp";
public string Host { get; set; } = "";
public int Port { get; set; } = 587;
public string FromAddress { get; set; } = "";
public bool UseSsl { get; set; } = true;
}Options classes use{ get; set; }(notinit) because the configuration binder andPostConfigureneed to mutate properties. Use[Required]via data annotations for mandatory fields instead.
Registration
builder.Services
.AddOptions<SmtpOptions>()
.BindConfiguration(SmtpOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();appsettings.json
{
"Smtp": {
"Host": "smtp.example.com",
"Port": 587,
"FromAddress": "noreply@example.com",
"UseSsl": true
}
}---
Options Interfaces
| Interface | Lifetime | Reload Behavior | Use Case |
|---|---|---|---|
IOptions<T> | Singleton | Never reloads after startup | Static config, most services |
IOptionsSnapshot<T> | Scoped | Reloads per request/scope | Per-request config in ASP.NET |
IOptionsMonitor<T> | Singleton | Live reload + change notification | Singletons, background services |
Injection Examples
// Static -- most common, singleton-safe
public sealed class EmailService(IOptions<SmtpOptions> options)
{
private readonly SmtpOptions _smtp = options.Value;
public Task SendAsync(string to, string subject, string body,
CancellationToken ct = default)
{
// Use _smtp.Host, _smtp.Port, etc.
return Task.CompletedTask;
}
}
// Live reload in singletons -- monitors config file changes
public sealed class FeatureService(IOptionsMonitor<FeatureOptions> monitor)
{
public bool IsEnabled(string feature)
=> monitor.CurrentValue.EnabledFeatures.Contains(feature);
}
// Per-request in scoped services -- reads latest config each request
public sealed class PricingService(IOptionsSnapshot<PricingOptions> snapshot)
{
public decimal GetMarkup() => snapshot.Value.MarkupPercent;
}Change Notifications with IOptionsMonitor<T>
public sealed class CacheService : IDisposable
{
private readonly IDisposable? _changeListener;
private CacheOptions _current;
public CacheService(IOptionsMonitor<CacheOptions> monitor)
{
_current = monitor.CurrentValue;
_changeListener = monitor.OnChange(updated =>
{
_current = updated;
// React to config change -- flush cache, resize pool, etc.
});
}
public void Dispose() => _changeListener?.Dispose();
}---
Options Validation
Data Annotations
using System.ComponentModel.DataAnnotations;
public sealed class SmtpOptions
{
public const string SectionName = "Smtp";
[Required, MinLength(1)]
public string Host { get; set; } = "";
[Range(1, 65535)]
public int Port { get; set; } = 587;
[Required, EmailAddress]
public string FromAddress { get; set; } = "";
}
builder.Services
.AddOptions<SmtpOptions>()
.BindConfiguration(SmtpOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart(); // Fail fast at startup, not on first useIValidateOptions<T> (Complex Validation)
Use when validation logic requires cross-property checks or external dependencies.
public sealed class SmtpOptionsValidator : IValidateOptions<SmtpOptions>
{
public ValidateOptionsResult Validate(string? name, SmtpOptions options)
{
var failures = new List<string>();
if (options.UseSsl && options.Port == 25)
{
failures.Add("Port 25 does not support SSL. Use 465 or 587.");
}
if (string.IsNullOrWhiteSpace(options.Host))
{
failures.Add("SMTP host is required.");
}
return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
}
}
// Register the validator
builder.Services.AddSingleton<IValidateOptions<SmtpOptions>, SmtpOptionsValidator>();ValidateOnStart (Fail Fast)
Always use .ValidateOnStart() to surface configuration errors at startup instead of at first resolution. Without it, invalid config only throws when IOptions<T>.Value is first accessed.
---
User Secrets (Development)
Store sensitive values outside source control during development.
# Initialize (once per project)
dotnet user-secrets init
# Set values
dotnet user-secrets set "Smtp:Host" "smtp.example.com"
dotnet user-secrets set "ConnectionStrings:Default" "Server=..."
# List all secrets
dotnet user-secrets list
# Clear all
dotnet user-secrets clearUser secrets are stored in ~/.microsoft/usersecrets/<UserSecretsId>/secrets.json and override appsettings.json values in Development.
Key rules:
- Never use user secrets in production -- use environment variables, Azure Key Vault, or other vault providers
- User secrets are loaded automatically when
ASPNETCORE_ENVIRONMENT=Development - For non-web hosts, explicitly add:
builder.Configuration.AddUserSecrets<Program>()
---
Environment-Based Configuration
Environment Variables
// Hierarchical keys use __ (double underscore) as separator
// Environment variable: Smtp__Host=smtp.prod.com
// Maps to: configuration["Smtp:Host"]Per-Environment Files
appsettings.json # Base (all environments)
appsettings.Development.json # Overrides for dev
appsettings.Staging.json # Overrides for staging
appsettings.Production.json # Overrides for prod// Set environment via ASPNETCORE_ENVIRONMENT or DOTNET_ENVIRONMENT
// Defaults to "Production" if not set
var env = builder.Environment.EnvironmentName; // "Development", "Staging", "Production"Conditional Service Registration
if (builder.Environment.IsDevelopment())
{
builder.Services.AddSingleton<IEmailSender, ConsoleEmailSender>();
}
else
{
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
}---
Feature Flags with Microsoft.FeatureManagement
Microsoft.FeatureManagement.AspNetCore provides structured feature flag support with filters, targeting, and gradual rollout.
Setup
dotnet add package Microsoft.FeatureManagement.AspNetCorebuilder.Services.AddFeatureManagement();Configuration
{
"FeatureManagement": {
"NewDashboard": true,
"BetaSearch": {
"EnabledFor": [
{
"Name": "Percentage",
"Parameters": { "Value": 50 }
}
]
},
"DarkMode": {
"EnabledFor": [
{
"Name": "Targeting",
"Parameters": {
"Audience": {
"Users": [ "alice@example.com" ],
"Groups": [
{ "Name": "Beta", "RolloutPercentage": 100 }
],
"DefaultRolloutPercentage": 0
}
}
}
]
}
}
}Usage in Code
// Inject IFeatureManager
public sealed class DashboardController(IFeatureManager featureManager) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get(CancellationToken ct = default)
{
if (await featureManager.IsEnabledAsync("NewDashboard"))
{
return Ok(new { version = "v2", dashboard = "new" });
}
return Ok(new { version = "v1", dashboard = "legacy" });
}
}Feature Gate Attribute
// Entire endpoint gated on feature flag
[FeatureGate("BetaSearch")]
[HttpGet("search")]
public async Task<IActionResult> Search(string query, CancellationToken ct = default)
{
var results = await _searchService.SearchAsync(query, ct);
return Ok(results);
}Feature Filters
| Filter | Purpose |
|---|---|
Percentage | Enable for N% of requests (random) |
TimeWindow | Enable between start/end dates |
Targeting | Enable for specific users, groups, or rollout percentage |
| Custom | Implement IFeatureFilter for domain-specific logic |
Custom Feature Filter
[FilterAlias("Browser")]
public sealed class BrowserFeatureFilter(IHttpContextAccessor accessor) : IFeatureFilter
{
public Task<bool> EvaluateAsync(FeatureFilterEvaluationContext context)
{
var userAgent = accessor.HttpContext?.Request.Headers.UserAgent.ToString() ?? "";
var settings = context.Parameters.Get<BrowserFilterSettings>();
return Task.FromResult(
settings?.AllowedBrowsers?.Any(b =>
userAgent.Contains(b, StringComparison.OrdinalIgnoreCase)) ?? false);
}
}
public sealed class BrowserFilterSettings
{
public string[] AllowedBrowsers { get; init; } = [];
}
// Register
builder.Services.AddFeatureManagement()
.AddFeatureFilter<BrowserFeatureFilter>();---
Named Options
Use named options when you need multiple instances of the same options type (e.g., multiple API clients).
// Registration with names
builder.Services
.AddOptions<ApiClientOptions>("GitHub")
.BindConfiguration("ApiClients:GitHub");
builder.Services
.AddOptions<ApiClientOptions>("Jira")
.BindConfiguration("ApiClients:Jira");
// Resolution via IOptionsSnapshot<T> or IOptionsMonitor<T>
public sealed class ApiClientFactory(IOptionsSnapshot<ApiClientOptions> snapshot)
{
public HttpClient CreateFor(string name)
{
var options = snapshot.Get(name); // "GitHub" or "Jira"
return new HttpClient { BaseAddress = new Uri(options.BaseUrl) };
}
}---
Post-Configuration
Apply defaults or overrides after all configuration sources have been processed.
builder.Services.PostConfigure<SmtpOptions>(options =>
{
// Ensure a default port if none specified
if (options.Port == 0)
{
options.Port = options.UseSsl ? 465 : 25;
}
});---
Testing Configuration
[Fact]
public void SmtpOptions_Validates_InvalidPort()
{
var options = new SmtpOptions
{
Host = "smtp.example.com",
FromAddress = "test@example.com",
Port = 25,
UseSsl = true
};
var validator = new SmtpOptionsValidator();
var result = validator.Validate(null, options);
Assert.True(result.Failed);
Assert.Contains("Port 25 does not support SSL", result.FailureMessage);
}
[Fact]
public void Configuration_BindsCorrectly()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Smtp:Host"] = "smtp.test.com",
["Smtp:Port"] = "465",
["Smtp:FromAddress"] = "test@test.com",
})
.Build();
var options = new SmtpOptions();
config.GetSection("Smtp").Bind(options);
Assert.Equal("smtp.test.com", options.Host);
Assert.Equal(465, options.Port);
}---
References
Dependency Injection
Advanced Microsoft.Extensions.DependencyInjection patterns for .NET applications. Covers service lifetimes, keyed services (net8.0+), decoration, factory delegates, scope validation, and hosted service registration.
Cross-references: references/async-patterns.md for BackgroundService async patterns, references/configuration.md for IOptions<T> binding.
---
Service Lifetimes
| Lifetime | Registration | When to Use |
|---|---|---|
| Transient | AddTransient<T>() | Lightweight, stateless services. New instance per injection. |
| Scoped | AddScoped<T>() | Per-request state (EF Core DbContext, unit of work). |
| Singleton | AddSingleton<T>() | Thread-safe, stateless, or shared state (caches, config). |
Lifetime Mismatches (Captive Dependencies)
Never inject a shorter-lived service into a longer-lived one:
// WRONG -- scoped DbContext captured in singleton = same context for all requests
builder.Services.AddSingleton<OrderService>(); // singleton
builder.Services.AddScoped<AppDbContext>(); // scoped -- CAPTIVE!
// CORRECT -- use IServiceScopeFactory in singletons
public sealed class OrderService(IServiceScopeFactory scopeFactory)
{
public async Task ProcessAsync(CancellationToken ct = default)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Orders.Where(o => o.IsPending).ToListAsync(ct);
}
}Enable Scope Validation (Development)
var builder = WebApplication.CreateBuilder(args);
// In Development, ValidateScopes is already true by default.
// For non-web hosts:
var host = Host.CreateDefaultBuilder(args)
.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true; // Validates all registrations at startup
})
.Build();---
Registration Patterns
Interface-Implementation Pair
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();Multiple Implementations
// Register multiple implementations
builder.Services.AddScoped<INotifier, EmailNotifier>();
builder.Services.AddScoped<INotifier, SmsNotifier>();
builder.Services.AddScoped<INotifier, PushNotifier>();
// Inject all -- order matches registration order
public sealed class CompositeNotifier(IEnumerable<INotifier> notifiers)
{
public async Task NotifyAsync(string message, CancellationToken ct = default)
{
foreach (var notifier in notifiers)
{
await notifier.NotifyAsync(message, ct);
}
}
}Factory Delegates
builder.Services.AddScoped<IOrderService>(sp =>
{
var repo = sp.GetRequiredService<IOrderRepository>();
var logger = sp.GetRequiredService<ILogger<OrderService>>();
var options = sp.GetRequiredService<IOptions<OrderOptions>>();
return new OrderService(repo, logger, options.Value.MaxRetries);
});TryAdd for Library Registrations
Libraries should use TryAdd so applications can override:
// Library code -- won't overwrite app registrations
builder.Services.TryAddScoped<IOrderRepository, DefaultOrderRepository>();
// Application code -- takes precedence if registered first
builder.Services.AddScoped<IOrderRepository, CustomOrderRepository>();---
Keyed Services (net8.0+)
Register and resolve services by a key, replacing the need for named service patterns.
// Registration
builder.Services.AddKeyedScoped<ICache, RedisCache>("distributed");
builder.Services.AddKeyedScoped<ICache, MemoryCache>("local");
// Injection via attribute
public sealed class OrderService(
[FromKeyedServices("distributed")] ICache distributedCache,
[FromKeyedServices("local")] ICache localCache)
{
public async Task<Order?> GetAsync(int id, CancellationToken ct = default)
{
// Check local cache first, then distributed
return await localCache.GetAsync<Order>(id.ToString(), ct)
?? await distributedCache.GetAsync<Order>(id.ToString(), ct);
}
}
// Manual resolution
var cache = sp.GetRequiredKeyedService<ICache>("distributed");net8.0+ only. On earlier TFMs, use factory patterns or a dictionary-based approach.
---
Decoration Pattern
The built-in container does not natively support decoration. Use one of these approaches:
Manual Decoration
builder.Services.AddScoped<SqlOrderRepository>();
builder.Services.AddScoped<IOrderRepository>(sp =>
{
var inner = sp.GetRequiredService<SqlOrderRepository>();
var logger = sp.GetRequiredService<ILogger<LoggingOrderRepository>>();
return new LoggingOrderRepository(inner, logger);
});
public sealed class LoggingOrderRepository(
IOrderRepository inner,
ILogger<LoggingOrderRepository> logger) : IOrderRepository
{
public async Task<Order?> GetByIdAsync(int id, CancellationToken ct = default)
{
logger.LogInformation("Getting order {OrderId}", id);
return await inner.GetByIdAsync(id, ct);
}
}Scrutor Library (Popular Alternative)
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.Decorate<IOrderRepository, LoggingOrderRepository>();
builder.Services.Decorate<IOrderRepository, CachingOrderRepository>();
// Outer -> CachingOrderRepository -> LoggingOrderRepository -> SqlOrderRepository---
Hosted Services and Background Workers
BackgroundService (Preferred)
public sealed class QueueProcessorWorker(
IServiceScopeFactory scopeFactory,
ILogger<QueueProcessorWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Queue processor starting");
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = scopeFactory.CreateScope();
var processor = scope.ServiceProvider
.GetRequiredService<IQueueProcessor>();
await processor.ProcessNextBatchAsync(stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Error processing queue batch");
}
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}
// Registration
builder.Services.AddHostedService<QueueProcessorWorker>();IHostedService (Startup/Shutdown Hooks)
public sealed class DatabaseMigrationService(
IServiceScopeFactory scopeFactory,
ILogger<DatabaseMigrationService> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync(cancellationToken);
logger.LogInformation("Database migration completed");
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
builder.Services.AddHostedService<DatabaseMigrationService>();Key Rules for Hosted Services
- Always use
IServiceScopeFactoryto create scopes -- hosted services are singletons - Never inject scoped services directly into hosted service constructors
- Handle exceptions inside
ExecuteAsync-- unhandled exceptions stop the host (net8.0+) - See
references/async-patterns.mdfor async patterns in background workers
---
Organizing Registrations
Group related registrations into extension methods for clean Program.cs:
// ServiceCollectionExtensions.cs
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddOrderServices(this IServiceCollection services)
{
services.AddScoped<IOrderRepository, SqlOrderRepository>();
services.AddScoped<IOrderService, OrderService>();
services.AddHostedService<OrderProcessorWorker>();
return services;
}
public static IServiceCollection AddNotificationServices(this IServiceCollection services)
{
services.AddScoped<INotifier, EmailNotifier>();
services.AddScoped<INotifier, SmsNotifier>();
return services;
}
}
// Program.cs
builder.Services.AddOrderServices();
builder.Services.AddNotificationServices();---
Testing with DI
[Fact]
public async Task OrderService_UsesRepository()
{
// Arrange -- build a service provider for integration tests
var services = new ServiceCollection();
services.AddScoped<IOrderRepository, InMemoryOrderRepository>();
services.AddScoped<IOrderService, OrderService>();
services.AddLogging();
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<IOrderService>();
// Act
var order = await service.GetByIdAsync(1);
// Assert
Assert.NotNull(order);
}For unit tests, prefer direct constructor injection with mocks rather than building a full container.
---
References
Domain Modeling
Domain-Driven Design tactical patterns in C#. Covers aggregate roots, entities, value objects, domain events, integration events, domain services, repository contract design, and the distinction between rich and anemic domain models. These patterns apply to the domain layer itself -- the pure C# model that encapsulates business rules -- independent of any persistence technology.
Aggregate Roots and Entities
An aggregate is a cluster of domain objects treated as a single unit for data changes. The aggregate root is the entry point -- all modifications to the aggregate pass through it.
Entity Base Class
Entities have identity that persists across state changes. Use a base class to standardize identity and equality:
public abstract class Entity<TId> : IEquatable<Entity<TId>>
where TId : notnull
{
// default! required for ORM hydration; Id is set immediately after construction
public TId Id { get; protected set; } = default!;
protected Entity() { } // Required for ORM hydration
protected Entity(TId id) => Id = id;
public override bool Equals(object? obj) =>
obj is Entity<TId> other && Equals(other);
public bool Equals(Entity<TId>? other) =>
other is not null
&& GetType() == other.GetType()
&& EqualityComparer<TId>.Default.Equals(Id, other.Id);
public override int GetHashCode() =>
EqualityComparer<TId>.Default.GetHashCode(Id);
public static bool operator ==(Entity<TId>? left, Entity<TId>? right) =>
Equals(left, right);
public static bool operator !=(Entity<TId>? left, Entity<TId>? right) =>
!Equals(left, right);
}Aggregate Root Base Class
The aggregate root extends Entity and collects domain events:
public abstract class AggregateRoot<TId> : Entity<TId>
where TId : notnull
{
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyList<IDomainEvent> DomainEvents =>
_domainEvents.AsReadOnly();
protected AggregateRoot() { }
protected AggregateRoot(TId id) : base(id) { }
protected void RaiseDomainEvent(IDomainEvent domainEvent) =>
_domainEvents.Add(domainEvent);
public void ClearDomainEvents() => _domainEvents.Clear();
}Concrete Aggregate Example
public sealed class Order : AggregateRoot<Guid>
{
public CustomerId CustomerId { get; private set; } = default!;
public OrderStatus Status { get; private set; }
public Money Total { get; private set; } = Money.Zero("USD");
private readonly List<OrderLine> _lines = [];
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
private Order() { } // ORM constructor
public static Order Create(CustomerId customerId)
{
var order = new Order(Guid.NewGuid())
{
CustomerId = customerId,
Status = OrderStatus.Draft
};
order.RaiseDomainEvent(new OrderCreated(order.Id, customerId));
return order;
}
public void AddLine(ProductId productId, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Cannot modify a non-draft order.");
if (quantity <= 0)
throw new DomainException("Quantity must be positive.");
var line = new OrderLine(productId, quantity, unitPrice);
_lines.Add(line);
RecalculateTotal();
}
public void Submit()
{
if (Status != OrderStatus.Draft)
throw new DomainException("Only draft orders can be submitted.");
if (_lines.Count == 0)
throw new DomainException("Cannot submit an empty order.");
Status = OrderStatus.Submitted;
RaiseDomainEvent(new OrderSubmitted(Id, Total));
}
private void RecalculateTotal() =>
Total = _lines.Aggregate(
Money.Zero(Total.Currency),
(sum, line) => sum.Add(line.LineTotal));
}Aggregate Design Rules
| Rule | Rationale |
|---|---|
| All mutations go through the aggregate root | Enforces invariants in one place |
| Reference other aggregates by ID only | Prevents cross-aggregate coupling; use CustomerId not Customer |
| Keep aggregates small | Large aggregates cause lock contention and slow loads |
| One aggregate per transaction | Cross-aggregate changes use domain events and eventual consistency |
Expose collections as IReadOnlyList<T> | Prevents external code from bypassing root methods to mutate children |
For the EF Core persistence implications of these rules (navigation properties, owned types, cascade behavior), see [skill:dotnet-api].
---
Value Objects
Value objects have no identity -- they are defined by their attribute values. Two value objects with the same attributes are equal. In C#, record and record struct provide natural value semantics.
Record-Based Value Objects
// Simple value object -- wraps a primitive to enforce constraints
public sealed record CustomerId
{
public string Value { get; }
public CustomerId(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new DomainException("Customer ID cannot be empty.");
Value = value;
}
public override string ToString() => Value;
}
// Composite value object -- multiple properties with validation
public sealed record Address
{
public string Street { get; }
public string City { get; }
public string State { get; }
public string PostalCode { get; }
public string Country { get; }
public Address(string street, string city, string state,
string postalCode, string country)
{
if (string.IsNullOrWhiteSpace(street))
throw new DomainException("Street is required.");
if (string.IsNullOrWhiteSpace(city))
throw new DomainException("City is required.");
if (string.IsNullOrWhiteSpace(postalCode))
throw new DomainException("Postal code is required.");
Street = street;
City = city;
State = state;
PostalCode = postalCode;
Country = country;
}
}Money Value Object
Money is the canonical example of a multi-field value object with behavior:
public sealed record Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (string.IsNullOrWhiteSpace(currency))
throw new DomainException("Currency is required.");
Amount = amount;
Currency = currency.ToUpperInvariant();
}
public static Money Zero(string currency) => new(0m, currency);
public Money Add(Money other)
{
EnsureSameCurrency(other);
return new Money(Amount + other.Amount, Currency);
}
public Money Subtract(Money other)
{
EnsureSameCurrency(other);
return new Money(Amount - other.Amount, Currency);
}
public Money Multiply(int quantity) =>
new(Amount * quantity, Currency);
public Money Multiply(decimal factor) =>
new(Amount * factor, Currency);
private void EnsureSameCurrency(Money other)
{
if (Currency != other.Currency)
throw new DomainException(
$"Cannot operate on {Currency} and {other.Currency}.");
}
public override string ToString() => $"{Amount:F2} {Currency}";
}Value Object EF Core Mapping
Map value objects using owned types or value conversions (implementation in [skill:dotnet-api]):
// Owned type -- maps to columns in the parent table
builder.OwnsOne(o => o.Total, money =>
{
money.Property(m => m.Amount).HasColumnName("TotalAmount");
money.Property(m => m.Currency).HasColumnName("TotalCurrency")
.HasMaxLength(3);
});
// Value conversion -- single-property value objects
builder.Property(o => o.CustomerId)
.HasConversion(
id => id.Value,
value => new CustomerId(value))
.HasMaxLength(50);When to Use Value Objects
| Use value object | Use primitive |
|---|---|
| Domain concept with constraints (email, money, quantity) | Infrastructure IDs with no domain rules (correlation IDs, trace IDs) |
| Multiple properties that form a unit (address, date range) | Single value with no validation needed |
| Need to prevent primitive obsession in domain methods | Simple DTO fields at API boundary |
---
Domain Events
Domain events represent something meaningful that happened in the domain. They enable loose coupling between aggregates and trigger side effects (sending emails, updating read models, publishing integration events).
Event Contracts
// Marker interface for all domain events
public interface IDomainEvent
{
Guid EventId { get; }
DateTimeOffset OccurredAt { get; }
}
// Base record for convenience
public abstract record DomainEventBase : IDomainEvent
{
public Guid EventId { get; } = Guid.NewGuid();
public DateTimeOffset OccurredAt { get; } = DateTimeOffset.UtcNow;
}
// Concrete events
public sealed record OrderCreated(
Guid OrderId, CustomerId CustomerId) : DomainEventBase;
public sealed record OrderSubmitted(
Guid OrderId, Money Total) : DomainEventBase;
public sealed record OrderCancelled(
Guid OrderId, string Reason) : DomainEventBase;Dispatching Domain Events
Dispatch events after SaveChangesAsync succeeds to ensure the aggregate state is persisted before side effects execute:
public sealed class DomainEventDispatcher(
IServiceProvider serviceProvider)
{
public async Task DispatchAsync(
IEnumerable<IDomainEvent> events,
CancellationToken ct)
{
foreach (var domainEvent in events)
{
var handlerType = typeof(IDomainEventHandler<>)
.MakeGenericType(domainEvent.GetType());
var handlers = serviceProvider.GetServices(handlerType);
foreach (var handler in handlers)
{
await ((dynamic)handler).HandleAsync(
(dynamic)domainEvent, ct);
}
}
}
}
// Note: The (dynamic) dispatch pattern is simple but not AOT-compatible.
// For Native AOT scenarios, use a source-generated or dictionary-based
// dispatcher. See [skill:dotnet-tooling] for AOT constraints.
// Handler interface
public interface IDomainEventHandler<in TEvent>
where TEvent : IDomainEvent
{
Task HandleAsync(TEvent domainEvent, CancellationToken ct);
}Saving with Event Dispatch
Use an EF Core SaveChangesInterceptor or a wrapper to dispatch events after save:
public sealed class EventDispatchingSaveChangesInterceptor(
DomainEventDispatcher dispatcher)
: SaveChangesInterceptor
{
public override async ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData,
int result,
CancellationToken ct)
{
if (eventData.Context is not null)
{
var aggregates = eventData.Context.ChangeTracker
.Entries<AggregateRoot<Guid>>()
.Where(e => e.Entity.DomainEvents.Count > 0)
.Select(e => e.Entity)
.ToList();
var events = aggregates
.SelectMany(a => a.DomainEvents)
.ToList();
foreach (var aggregate in aggregates)
{
aggregate.ClearDomainEvents();
}
await dispatcher.DispatchAsync(events, ct);
}
return result;
}
}Domain Events vs Integration Events
| Aspect | Domain Event | Integration Event |
|---|---|---|
| Scope | Within a bounded context | Across bounded contexts / services |
| Transport | In-process (dispatcher) | Message broker (Service Bus, RabbitMQ) |
| Coupling | References domain types | Uses primitive/DTO types only |
| Reliability | Same transaction scope | At-least-once with idempotent consumers |
| Example | OrderSubmitted (triggers email handler) | OrderSubmittedIntegration (notifies shipping service) |
A domain event handler may publish an integration event to a message broker. See [skill:dotnet-api] for integration event infrastructure.
// Domain event handler that publishes an integration event
public sealed class OrderSubmittedHandler(
IPublishEndpoint publishEndpoint)
: IDomainEventHandler<OrderSubmitted>
{
public async Task HandleAsync(
OrderSubmitted domainEvent, CancellationToken ct)
{
// Map domain event to integration event (no domain types)
await publishEndpoint.Publish(
new OrderSubmittedIntegration(
domainEvent.OrderId,
domainEvent.Total.Amount,
domainEvent.Total.Currency),
ct);
}
}---
Rich vs Anemic Domain Models
Rich Domain Model
Business logic lives inside the domain entities. Methods enforce invariants and return meaningful results:
public sealed class ShoppingCart : AggregateRoot<Guid>
{
private readonly List<CartItem> _items = [];
public IReadOnlyList<CartItem> Items => _items.AsReadOnly();
public void AddItem(ProductId productId, int quantity, Money unitPrice)
{
var existing = _items.Find(i => i.ProductId == productId);
if (existing is not null)
{
existing.IncreaseQuantity(quantity);
}
else
{
_items.Add(new CartItem(productId, quantity, unitPrice));
}
}
public void RemoveItem(ProductId productId)
{
var item = _items.Find(i => i.ProductId == productId)
?? throw new DomainException(
$"Product {productId} not in cart.");
_items.Remove(item);
}
public Money GetTotal(string currency) =>
_items.Aggregate(
Money.Zero(currency),
(sum, item) => sum.Add(item.LineTotal));
}Anemic Domain Model (Anti-Pattern)
Entities are data bags with public setters. Business logic lives in external services:
// ANTI-PATTERN: Entity is just a data container
public class ShoppingCart
{
public Guid Id { get; set; }
public List<CartItem> Items { get; set; } = [];
}
// All logic lives here -- the entity has no behavior
public class ShoppingCartService
{
public void AddItem(ShoppingCart cart, string productId,
int quantity, decimal unitPrice)
{
var existing = cart.Items.Find(i => i.ProductId == productId);
if (existing != null)
existing.Quantity += quantity;
else
cart.Items.Add(new CartItem { ... });
}
}Decision Guide
| Factor | Rich model | Anemic model |
|---|---|---|
| Complex invariants | Enforced in entity | Scattered across services |
| Testability | Test entity behavior directly | Test service + entity together |
| Discoverability | Methods on entity show capabilities | Must find the right service class |
| Persistence coupling | Requires ORM-friendly private setters | Simple property mapping |
| Team familiarity | DDD experience required | Familiar to most developers |
Recommendation: Start with a rich model for aggregates with complex business rules. Anemic models are acceptable for simple CRUD entities where the domain logic is minimal (e.g., reference data, configuration records).
---
Domain Services
Domain services encapsulate business logic that does not naturally belong to a single entity or value object. They operate on domain types and enforce cross-aggregate rules.
public sealed class PricingService
{
public Money CalculateDiscount(
Order order,
CustomerTier tier,
IReadOnlyList<PromotionRule> activePromotions)
{
var discount = Money.Zero(order.Total.Currency);
// Tier-based discount
discount = tier switch
{
CustomerTier.Gold => discount.Add(
order.Total.Multiply(0.10m)),
CustomerTier.Platinum => discount.Add(
order.Total.Multiply(0.15m)),
_ => discount
};
// Promotion-based discounts
foreach (var promo in activePromotions)
{
if (promo.AppliesTo(order))
{
discount = discount.Add(promo.Calculate(order));
}
}
return discount;
}
}When to Use Domain Services
- Logic requires data from multiple aggregates that should not reference each other
- A business rule does not belong to any single entity (e.g., pricing across products and customer tiers)
- External policy or configuration drives the logic (e.g., tax calculation rules)
Domain services should remain pure -- no infrastructure dependencies. If the logic needs a database or external API, place it in an application service that calls the domain service with pre-loaded data.
---
Repository Contracts
Repository interfaces belong in the domain layer and express aggregate loading and saving semantics. Implementation details (EF Core, Dapper) live in the infrastructure layer.
// Domain layer -- defines the contract
public interface IOrderRepository
{
Task<Order?> FindByIdAsync(Guid id, CancellationToken ct);
Task AddAsync(Order order, CancellationToken ct);
Task SaveChangesAsync(CancellationToken ct);
}
// Domain layer -- unit of work abstraction (optional)
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken ct);
}For EF Core repository implementations, see [skill:dotnet-api].
Repository Design Rules
| Rule | Rationale |
|---|---|
| One repository per aggregate root | Child entities are accessed through the root |
No IQueryable<T> return types | Prevents persistence concerns from leaking into domain |
No generic IRepository<T> | Cannot express aggregate-specific loading rules |
| Return domain types, not DTOs | Repositories serve the domain; read models use projections |
Include CancellationToken on all async methods | Required for proper cancellation propagation |
---
Domain Exceptions
Use domain-specific exceptions to signal invariant violations. This separates domain errors from infrastructure errors:
public class DomainException : Exception
{
public DomainException(string message) : base(message) { }
public DomainException(string message, Exception inner)
: base(message, inner) { }
}
// Specific domain exceptions for different invariant violations
public sealed class InsufficientStockException(
ProductId productId, int requested, int available)
: DomainException(
$"Insufficient stock for {productId}: " +
$"requested {requested}, available {available}")
{
public ProductId ProductId => productId;
public int Requested => requested;
public int Available => available;
}Map domain exceptions to HTTP responses at the API boundary (e.g., DomainException to 422 Unprocessable Entity). Do not let infrastructure concerns like HTTP status codes leak into the domain layer.
---
Agent Gotchas
1. Do not expose public setters on aggregate properties -- all state changes must go through methods on the aggregate root that enforce invariants. Use private set or init for properties. 2. Do not create navigation properties between aggregate roots -- reference other aggregates by ID value objects (e.g., CustomerId) not by entity navigation. Cross-aggregate navigation breaks bounded context isolation. 3. Do not dispatch domain events inside the transaction -- dispatch after SaveChangesAsync succeeds. Dispatching before save means side effects fire even if the save fails. 4. Do not use domain types in integration events -- integration events cross bounded context boundaries and must use primitives or DTOs. Domain type changes would break other services. 5. Do not put validation logic only in the API layer -- domain invariants belong in the domain model. API validation (references/validation-patterns.md) catches malformed input; domain validation enforces business rules. 6. Do not create anemic entities with public `List<T>` properties -- expose collections as IReadOnlyList<T> and provide mutation methods on the aggregate root that enforce business rules. 7. Do not inject infrastructure services into domain entities -- entities should be pure C# objects. Use domain services for logic that needs external data, and application services for infrastructure orchestration.
---
References
Related skills
How it compares
Choose dotnet-csharp over general backend skills when the target stack is specifically C# and .NET and idiomatic framework patterns matter.
FAQ
What does the dotnet-csharp skill generate?
dotnet-csharp generates C# and .NET code for APIs, services, and libraries using idiomatic patterns from the dotnet-artisan pack, including dependency injection, async/await, records, and framework conventions aligned with ASP.NET Core projects.
When should developers use dotnet-csharp?
Developers should use dotnet-csharp when building or extending ASP.NET Core APIs, microservices, or .NET class libraries and need agent output that follows Microsoft ecosystem conventions rather than generic C# examples.