--- name: csharp-patterns description: "C#/.NET: LINQ, async/await, DI, records, nullable refs, ASP.NET Core, EF Core, MediatR. Triggers: C#, .NET, dotnet, ASP.NET, EF Core, LINQ, record type, IServiceCollection." effort: medium user-invocable: false allowed-tools: Read --- # C# / .NET Patterns ## Project Structure ### Solution Layout ``` MyApp.sln Directory.Build.props # Shared build properties Directory.Packages.props # Central package management src/ MyApp.Api/ # ASP.NET Core host (Controllers, Middleware, Program.cs) MyApp.Application/ # Use cases, MediatR handlers, Behaviors MyApp.Domain/ # Entities, value objects, domain events MyApp.Infrastructure/ # EF Core, external services tests/ MyApp.UnitTests/ MyApp.IntegrationTests/ ``` ### Directory.Build.props ```xml net9.0 enable enable true true latest-recommended ``` ### Central Package Management (Directory.Packages.props) ```xml true ``` --- ## Idioms / Code Style ### Nullable Reference Types ```csharp public class Order { public required string Id { get; init; } public string? Notes { get; set; } // explicitly nullable public required Customer Customer { get; init; } // never null public string Summary => $"Order {Id}: {Notes ?? "no notes"}"; } ``` ### Records ```csharp public record Money(decimal Amount, string Currency) { public Money Add(Money other) => Currency != other.Currency ? throw new InvalidOperationException("Currency mismatch") : this with { Amount = Amount + other.Amount }; } public record CreateOrderRequest(string CustomerId, List Lines); public record OrderLineDto(string ProductId, int Quantity); ``` ### Pattern Matching ```csharp public decimal CalculateDiscount(Customer c) => c switch { { Tier: CustomerTier.Gold, TotalSpent: > 10_000m } => 0.20m, { Tier: CustomerTier.Gold } => 0.15m, { Tier: CustomerTier.Silver } => 0.10m, { IsNewCustomer: true } => 0.05m, _ => 0m }; // List patterns (.NET 8+) public string Describe(int[] v) => v switch { [] => "empty", [var x] => $"one: {x}", [var f, .., var l] => $"{f}..{l}", }; ``` ### LINQ ```csharp var activeUsers = users .Where(u => u.IsActive) .OrderByDescending(u => u.LastLogin) .Select(u => new UserDto(u.Id, u.Name)) .ToList(); ``` ### Async/Await ```csharp // Always: Async suffix, CancellationToken parameter, never .Result/.Wait() public async Task GetOrderAsync(string id, CancellationToken ct = default) => await _db.Orders.Include(o => o.Lines).FirstOrDefaultAsync(o => o.Id == id, ct); // Parallel independent tasks var ordersTask = _orderRepo.GetRecentAsync(ct); var statsTask = _statsService.ComputeAsync(ct); await Task.WhenAll(ordersTask, statsTask); ``` ### Primary Constructors (C# 12) ```csharp public class OrderService(IOrderRepository repo, ILogger logger, IPublisher pub) { public async Task CreateAsync(CreateOrderRequest req, CancellationToken ct) { logger.LogInformation("Creating order for {CustomerId}", req.CustomerId); var order = Order.Create(req); await repo.AddAsync(order, ct); await pub.Publish(new OrderCreatedEvent(order.Id), ct); return order; } } ``` --- ## Error Handling ### Result Pattern ```csharp public sealed class Result { public T? Value { get; } public Error? Error { get; } public bool IsSuccess => Error is null; private Result(T value) => Value = value; private Result(Error error) => Error = error; public static Result Success(T value) => new(value); public static Result Failure(Error error) => new(error); public TOut Match(Func ok, Func err) => IsSuccess ? ok(Value!) : err(Error!); } public record Error(string Code, string Message); ``` ### FluentValidation + MediatR Pipeline ```csharp public class CreateOrderValidator : AbstractValidator { public CreateOrderValidator() { RuleFor(x => x.CustomerId).NotEmpty().MaximumLength(36); RuleForEach(x => x.Lines).ChildRules(line => { line.RuleFor(l => l.ProductId).NotEmpty(); line.RuleFor(l => l.Quantity).GreaterThan(0).LessThanOrEqualTo(1000); }); } } public class ValidationBehavior(IEnumerable> validators) : IPipelineBehavior where TReq : IRequest { public async Task Handle(TReq req, RequestHandlerDelegate next, CancellationToken ct) { var failures = validators.Select(v => v.Validate(req)).SelectMany(r => r.Errors).ToList(); return failures.Count > 0 ? throw new ValidationException(failures) : await next(); } } ``` ### IAsyncDisposable ```csharp public sealed class TempFileHandle(string path) : IAsyncDisposable { public async ValueTask DisposeAsync() { if (File.Exists(path)) await Task.Run(() => File.Delete(path)); } } // Usage: await using var handle = new TempFileHandle("/tmp/export.csv"); ``` --- ## Testing Patterns ### xUnit + NSubstitute + FluentAssertions ```csharp public class OrderServiceTests { private readonly IOrderRepository _repo = Substitute.For(); private readonly IPublisher _pub = Substitute.For(); private readonly OrderService _sut; public OrderServiceTests() => _sut = new(_repo, Substitute.For>(), _pub); [Fact] public async Task CreateAsync_ValidRequest_ReturnsOrder() { var order = await _sut.CreateAsync(new("cust-1", [new("prod-1", 2)]), CancellationToken.None); order.Should().NotBeNull(); order.Lines.Should().ContainSingle().Which.Quantity.Should().Be(2); await _repo.Received(1).AddAsync(Arg.Any(), Arg.Any()); } [Theory] [InlineData(0)] [InlineData(-1)] public async Task CreateAsync_InvalidQuantity_Throws(int qty) { var act = () => _sut.CreateAsync(new("cust-1", [new("prod-1", qty)]), CancellationToken.None); await act.Should().ThrowAsync(); } } ``` ### WebApplicationFactory (Integration) ```csharp public class OrdersApiTests(WebApplicationFactory factory) : IClassFixture> { [Fact] public async Task PostOrder_Returns201() { var client = factory.WithWebHostBuilder(b => b.ConfigureServices(s => { s.RemoveAll(); s.AddDbContext(o => o.UseInMemoryDatabase("test")); })).CreateClient(); var response = await client.PostAsJsonAsync("/api/orders", new { CustomerId = "cust-1", Lines = new[] { new { ProductId = "prod-1", Quantity = 2 } } }); response.StatusCode.Should().Be(HttpStatusCode.Created); } } ``` ### Testcontainers ```csharp public class PostgresFixture : IAsyncLifetime { private readonly PostgreSqlContainer _pg = new PostgreSqlBuilder().WithDatabase("testdb").Build(); public string ConnectionString => _pg.GetConnectionString(); public Task InitializeAsync() => _pg.StartAsync(); public Task DisposeAsync() => _pg.DisposeAsync().AsTask(); } ``` --- ## Common Frameworks ### ASP.NET Core Minimal API ```csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly)); var app = builder.Build(); app.MapGet("/api/orders/{id}", async (string id, ISender sender, CancellationToken ct) => await sender.Send(new GetOrderQuery(id), ct) is { } order ? Results.Ok(order) : Results.NotFound()); app.MapPost("/api/orders", async (CreateOrderRequest req, ISender sender, CancellationToken ct) => (await sender.Send(new CreateOrderCommand(req), ct)).Match( o => Results.Created($"/api/orders/{o.Id}", o), e => Results.Problem(e.Message, statusCode: 400))); app.Run(); ``` ### Entity Framework Core ```csharp public class AppDbContext(DbContextOptions options) : DbContext(options) { public DbSet Orders => Set(); protected override void OnModelCreating(ModelBuilder mb) { mb.Entity(b => { b.HasKey(o => o.Id); b.Property(o => o.Id).HasMaxLength(36); b.HasMany(o => o.Lines).WithOne().HasForeignKey(l => l.OrderId); b.HasIndex(o => o.CustomerId); b.Property(o => o.Total).HasPrecision(18, 2); }); mb.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly); } } ``` ### MediatR (CQRS) ```csharp public record CreateOrderCommand(CreateOrderRequest Request) : IRequest>; public class CreateOrderHandler(IOrderRepository repo, IPublisher pub) : IRequestHandler> { public async Task> Handle(CreateOrderCommand cmd, CancellationToken ct) { var order = Order.Create(cmd.Request); await repo.AddAsync(order, ct); await pub.Publish(new OrderCreatedEvent(order.Id), ct); return Result.Success(order); } } ``` ### Polly (Resilience) ```csharp builder.Services.AddHttpClient("Payments", c => c.BaseAddress = new Uri("https://api.payments.com")) .AddResilienceHandler("default", p => { p.AddRetry(new() { MaxRetryAttempts = 3, Delay = TimeSpan.FromMilliseconds(500), BackoffType = DelayBackoffType.Exponential, ShouldHandle = new PredicateBuilder() .HandleResult(r => r.StatusCode >= HttpStatusCode.InternalServerError) }); p.AddCircuitBreaker(new() { FailureRatio = 0.5, SamplingDuration = TimeSpan.FromSeconds(30), BreakDuration = TimeSpan.FromSeconds(15) }); p.AddTimeout(TimeSpan.FromSeconds(5)); }); ``` ### MassTransit (Messaging) ```csharp builder.Services.AddMassTransit(x => { x.AddConsumer(); x.UsingRabbitMq((ctx, cfg) => { cfg.Host("rabbitmq://localhost"); cfg.ConfigureEndpoints(ctx); }); }); ``` --- ## Performance Tips ### Span -- Zero-Allocation Parsing ```csharp public static ReadOnlySpan ExtractDomain(ReadOnlySpan email) { int at = email.IndexOf('@'); return at >= 0 ? email[(at + 1)..] : ReadOnlySpan.Empty; } // Stack-allocated buffer: Span buf = stackalloc byte[256]; ``` ### ValueTask -- Cache-Hit Fast Path ```csharp public ValueTask GetProductAsync(string id, CancellationToken ct) { if (_cache.TryGetValue(id, out var cached)) return ValueTask.FromResult(cached); return new ValueTask(LoadFromDbAsync(id, ct)); } ``` ### Source Generators ```csharp // JSON -- avoids runtime reflection [JsonSerializable(typeof(Order))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] public partial class AppJsonContext : JsonSerializerContext; // Logging -- avoids boxing public static partial class Log { [LoggerMessage(Level = LogLevel.Information, Message = "Order {OrderId} created")] public static partial void OrderCreated(ILogger logger, string orderId); } ``` ### BenchmarkDotNet ```csharp [MemoryDiagnoser, SimpleJob(RuntimeMoniker.Net90)] public class ParsingBenchmarks { private readonly string _email = "user@example.com"; [Benchmark(Baseline = true)] public string Sub() => _email[(_email.IndexOf('@') + 1)..]; [Benchmark] public ReadOnlySpan Span() => ExtractDomain(_email.AsSpan()); } // dotnet run -c Release --project Benchmarks ``` --- ## Build / Package Management ### dotnet CLI ```bash dotnet new sln -n MyApp && dotnet new webapi -n MyApp.Api -o src/MyApp.Api dotnet build -c Release dotnet test -c Release --collect:"XPlat Code Coverage" dotnet publish src/MyApp.Api -c Release -o ./publish dotnet ef migrations add Init --project src/MyApp.Infrastructure --startup-project src/MyApp.Api ``` ### .editorconfig ```ini [*.cs] dotnet_sort_system_directives_first = true csharp_style_namespace_declarations = file_scoped:error csharp_prefer_primary_constructors = true:suggestion dotnet_diagnostic.CA1848.severity = warning ``` ### CI (GitHub Actions) ```yaml steps: - uses: actions/checkout@v6 - uses: actions/setup-dotnet@v4 with: { dotnet-version: '9.0.x' } - run: dotnet restore - run: dotnet build --no-restore -c Release -warnaserror - run: dotnet test --no-build -c Release --collect:"XPlat Code Coverage" - run: dotnet publish src/MyApp.Api -c Release -o publish --no-build ``` ### Dockerfile ```dockerfile FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build WORKDIR /src COPY . . RUN dotnet publish src/MyApp.Api -c Release -o /app FROM mcr.microsoft.com/dotnet/aspnet:9.0 COPY --from=build /app /app WORKDIR /app ENTRYPOINT ["dotnet", "MyApp.Api.dll"] ``` --- ## Anti-Patterns - `async void` outside event handlers -- always return `Task` - Blocking with `.Result` / `.Wait()` -- deadlock risk - Missing `CancellationToken` propagation through the call chain - Catching bare `Exception` without re-throw or structured logging - Service locator via `IServiceProvider.GetService` in business logic - Exposing `IQueryable` from repositories -- leaks persistence details - Public setters on domain entities -- use methods + private setters - String concatenation in hot paths -- use `StringBuilder` or string handlers