Master C/.NET backend development patterns for building

by wshobson · MIT license · ★ 39,857 on GitHub · updated Sep 21

.NET Backend Development Patterns cover

Master C#/.NET backend development patterns for building robust APIs, MCP servers, and enterprise applications. Covers async/await, dependency injection, Entity Framework Core, Dapper, configuration, caching, and testing with xUnit.

---
name: dotnet-backend-patterns
description: Master C#/.NET backend development patterns for building robust APIs, MCP servers, and enterprise applications. Covers async/await, dependency injection, Entity Framework Core, Dapper, configuration, caching, and testing with xUnit. Use when developing .NET backends, reviewing C# code, or designing API architectures.
---

# .NET Backend Development Patterns

Master C#/.NET patterns for building production-grade APIs, MCP servers, and enterprise backends with modern best practices (2024/2025).

## When to Use This Skill

- Developing new .NET Web APIs or MCP servers
- Reviewing C# code for quality and performance
- Designing service architectures with dependency injection
- Implementing caching strategies with Redis
- Writing unit and integration tests
- Optimizing database access with EF Core or Dapper
- Configuring applications with IOptions pattern

Overview

Master C#/.NET patterns for building production-grade APIs, MCP servers, and enterprise backends with modern best practices (2024/2025).

Manual install (npx, ZIP)
Claude Codenpx skills add wshobson/agents --skill dotnet-backend-patterns -g -a claude-code
Claude Code (manual)mkdir -p ~/.claude/skills && curl -sL https://codeload.github.com/wshobson/agents/tar.gz/4236bb91f839 \ | tar -xz -C ~/.claude/skills --strip-components=4 agents-4236bb91f839/plugins/dotnet-contribution/skills/dotnet-backend-patterns
Windows (PowerShell)New-Item -ItemType Directory -Force "$HOME\.claude\skills" | Out-Null; Invoke-WebRequest "https://m.agentalley.io/zip/2418c2557c2db4b34061/dotnet-backend-patterns.zip" -OutFile "$env:TEMP\dotnet-backend-patterns.zip"; Expand-Archive "$env:TEMP\dotnet-backend-patterns.zip" -DestinationPath "$HOME\.claude\skills" -Force
Claude.ai / desktopDownload the ZIP and upload it under Settings → Capabilities → Skills.
Codex, Cursor, other agentsPoint them at SKILL.md. The other files are plain text.

Connects with Agent Alley MCP · agentalley.io/mcp

Files included

  • SKILL.md25.8 KB
  • assets/2 files · 28.6 KB
  • references/2 files · 23.8 KB
Read the files here
wshobson/4236bb95 files
SKILL.md
Show the full text811 lines

.NET Backend Development Patterns

Master C#/.NET patterns for building production-grade APIs, MCP servers, and enterprise backends with modern best practices (2024/2025).

When to Use This Skill

  • Developing new .NET Web APIs or MCP servers
  • Reviewing C# code for quality and performance
  • Designing service architectures with dependency injection
  • Implementing caching strategies with Redis
  • Writing unit and integration tests
  • Optimizing database access with EF Core or Dapper
  • Configuring applications with IOptions pattern
  • Handling errors and implementing resilience patterns

Core Concepts

1. Project Structure (Clean Architecture)
src/
├── Domain/                     # Core business logic (no dependencies)
│   ├── Entities/
│   ├── Interfaces/
│   ├── Exceptions/
│   └── ValueObjects/
├── Application/                # Use cases, DTOs, validation
│   ├── Services/
│   ├── DTOs/
│   ├── Validators/
│   └── Interfaces/
├── Infrastructure/             # External implementations
│   ├── Data/                   # EF Core, Dapper repositories
│   ├── Caching/                # Redis, Memory cache
│   ├── External/               # HTTP clients, third-party APIs
│   └── DependencyInjection/    # Service registration
└── Api/                        # Entry point
    ├── Controllers/            # Or MinimalAPI endpoints
    ├── Middleware/
    ├── Filters/
    └── Program.cs
2. Dependency Injection Patterns
// Service registration by lifetime
public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddApplicationServices(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        // Scoped: One instance per HTTP request
        services.AddScoped<IProductService, ProductService>();
        services.AddScoped<IOrderService, OrderService>();

        // Singleton: One instance for app lifetime
        services.AddSingleton<ICacheService, RedisCacheService>();
        services.AddSingleton<IConnectionMultiplexer>(_ =>
            ConnectionMultiplexer.Connect(configuration["Redis:Connection"]!));

        // Transient: New instance every time
        services.AddTransient<IValidator<CreateOrderRequest>, CreateOrderValidator>();

        // Options pattern for configuration
        services.Configure<CatalogOptions>(configuration.GetSection("Catalog"));
        services.Configure<RedisOptions>(configuration.GetSection("Redis"));

        // Factory pattern for conditional creation
        services.AddScoped<IPriceCalculator>(sp =>
        {
            var options = sp.GetRequiredService<IOptions<PricingOptions>>().Value;
            return options.UseNewEngine
                ? sp.GetRequiredService<NewPriceCalculator>()
                : sp.GetRequiredService<LegacyPriceCalculator>();
        });

        // Keyed services (.NET 8+)
        services.AddKeyedScoped<IPaymentProcessor, StripeProcessor>("stripe");
        services.AddKeyedScoped<IPaymentProcessor, PayPalProcessor>("paypal");

        return services;
    }
}

// Usage with keyed services
public class CheckoutService
{
    public CheckoutService(
        [FromKeyedServices("stripe")] IPaymentProcessor stripeProcessor)
    {
        _processor = stripeProcessor;
    }
}
3. Async/Await Patterns
// ✅ CORRECT: Async all the way down
public async Task<Product> GetProductAsync(string id, CancellationToken ct = default)
{
    return await _repository.GetByIdAsync(id, ct);
}

// ✅ CORRECT: Parallel execution with WhenAll
public async Task<(Stock, Price)> GetStockAndPriceAsync(
    string productId,
    CancellationToken ct = default)
{
    var stockTask = _stockService.GetAsync(productId, ct);
    var priceTask = _priceService.GetAsync(productId, ct);

    await Task.WhenAll(stockTask, priceTask);

    return (await stockTask, await priceTask);
}

// ✅ CORRECT: ConfigureAwait in libraries
public async Task<T> LibraryMethodAsync<T>(CancellationToken ct = default)
{
    var result = await _httpClient.GetAsync(url, ct).ConfigureAwait(false);
    return await result.Content.ReadFromJsonAsync<T>(ct).ConfigureAwait(false);
}

// ✅ CORRECT: ValueTask for hot paths with caching
public ValueTask<Product?> GetCachedProductAsync(string id)
{
    if (_cache.TryGetValue(id, out Product? product))
        return ValueTask.FromResult(product);

    return new ValueTask<Product?>(GetFromDatabaseAsync(id));
}

// ❌ WRONG: Blocking on async (deadlock risk)
var result = GetProductAsync(id).Result;  // NEVER do this
var result2 = GetProductAsync(id).GetAwaiter().GetResult(); // Also bad

// ❌ WRONG: async void (except event handlers)
public async void ProcessOrder() { }  // Exceptions are lost

// ❌ WRONG: Unnecessary Task.Run for already async code
await Task.Run(async () => await GetDataAsync());  // Wastes thread
4. Configuration with IOptions
// Configuration classes
public class CatalogOptions
{
    public const string SectionName = "Catalog";

    public int DefaultPageSize { get; set; } = 50;
    public int MaxPageSize { get; set; } = 200;
    public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(15);
    public bool EnableEnrichment { get; set; } = true;
}

public class RedisOptions
{
    public const string SectionName = "Redis";

    public string Connection { get; set; } = "localhost:6379";
    public string KeyPrefix { get; set; } = "mcp:";
    public int Database { get; set; } = 0;
}

// appsettings.json
{
    "Catalog": {
        "DefaultPageSize": 50,
        "MaxPageSize": 200,
        "CacheDuration": "00:15:00",
        "EnableEnrichment": true
    },
    "Redis": {
        "Connection": "localhost:6379",
        "KeyPrefix": "mcp:",
        "Database": 0
    }
}

// Registration
services.Configure<CatalogOptions>(configuration.GetSection(CatalogOptions.SectionName));
services.Configure<RedisOptions>(configuration.GetSection(RedisOptions.SectionName));

// Usage with IOptions (singleton, read once at startup)
public class CatalogService
{
    private readonly CatalogOptions _options;

    public CatalogService(IOptions<CatalogOptions> options)
    {
        _options = options.Value;
    }
}

// Usage with IOptionsSnapshot (scoped, re-reads on each request)
public class DynamicService
{
    private readonly CatalogOptions _options;

    public DynamicService(IOptionsSnapshot<CatalogOptions> options)
    {
        _options = options.Value;  // Fresh value per request
    }
}

// Usage with IOptionsMonitor (singleton, notified on changes)
public class MonitoredService
{
    private CatalogOptions _options;

    public MonitoredService(IOptionsMonitor<CatalogOptions> monitor)
    {
        _options = monitor.CurrentValue;
        monitor.OnChange(newOptions => _options = newOptions);
    }
}
5. Result Pattern (Avoiding Exceptions for Flow Control)
// Generic Result type
public class Result<T>
{
    public bool IsSuccess { get; }
    public T? Value { get; }
    public string? Error { get; }
    public string? ErrorCode { get; }

    private Result(bool isSuccess, T? value, string? error, string? errorCode)
    {
        IsSuccess = isSuccess;
        Value = value;
        Error = error;
        ErrorCode = errorCode;
    }

    public static Result<T> Success(T value) => new(true, value, null, null);
    public static Result<T> Failure(string error, string? code = null) => new(false, default, error, code);

    public Result<TNew> Map<TNew>(Func<T, TNew> mapper) =>
        IsSuccess ? Result<TNew>.Success(mapper(Value!)) : Result<TNew>.Failure(Error!, ErrorCode);

    public async Task<Result<TNew>> MapAsync<TNew>(Func<T, Task<TNew>> mapper) =>
        IsSuccess ? Result<TNew>.Success(await mapper(Value!)) : Result<TNew>.Failure(Error!, ErrorCode);
}

// Usage in service
public async Task<Result<Order>> CreateOrderAsync(CreateOrderRequest request, CancellationToken ct)
{
    // Validation
    var validation = await _validator.ValidateAsync(request, ct);
    if (!validation.IsValid)
        return Result<Order>.Failure(
            validation.Errors.First().ErrorMessage,
            "VALIDATION_ERROR");

    // Business rule check
    var stock = await _stockService.CheckAsync(request.ProductId, request.Quantity, ct);
    if (!stock.IsAvailable)
        return Result<Order>.Failure(
            $"Insufficient stock: {stock.Available} available, {request.Quantity} requested",
            "INSUFFICIENT_STOCK");

    // Create order
    var order = await _repository.CreateAsync(request.ToEntity(), ct);

    return Result<Order>.Success(order);
}

// Usage in controller/endpoint
app.MapPost("/orders", async (
    CreateOrderRequest request,
    IOrderService orderService,
    CancellationToken ct) =>
{
    var result = await orderService.CreateOrderAsync(request, ct);

    return result.IsSuccess
        ? Results.Created($"/orders/{result.Value!.Id}", result.Value)
        : Results.BadRequest(new { error = result.Error, code = result.ErrorCode });
});

Data Access Patterns

Entity Framework Core
// DbContext configuration
public class AppDbContext : DbContext
{
    public DbSet<Product> Products => Set<Product>();
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Apply all configurations from assembly
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);

        // Global query filters
        modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted);
    }
}

// Entity configuration
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.ToTable("Products");

        builder.HasKey(p => p.Id);
        builder.Property(p => p.Id).HasMaxLength(40);
        builder.Property(p => p.Name).HasMaxLength(200).IsRequired();
        builder.Property(p => p.Price).HasPrecision(18, 2);

        builder.HasIndex(p => p.Sku).IsUnique();
        builder.HasIndex(p => new { p.CategoryId, p.Name });

        builder.HasMany(p => p.OrderItems)
            .WithOne(oi => oi.Product)
            .HasForeignKey(oi => oi.ProductId);
    }
}

// Repository with EF Core
public class ProductRepository : IProductRepository
{
    private readonly AppDbContext _context;

    public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
    {
        return await _context.Products
            .AsNoTracking()
            .FirstOrDefaultAsync(p => p.Id == id, ct);
    }

    public async Task<IReadOnlyList<Product>> SearchAsync(
        ProductSearchCriteria criteria,
        CancellationToken ct = default)
    {
        var query = _context.Products.AsNoTracking();

        if (!string.IsNullOrWhiteSpace(criteria.SearchTerm))
            query = query.Where(p => EF.Functions.Like(p.Name, $"%{criteria.SearchTerm}%"));

        if (criteria.CategoryId.HasValue)
            query = query.Where(p => p.CategoryId == criteria.CategoryId);

        if (criteria.MinPrice.HasValue)
            query = query.Where(p => p.Price >= criteria.MinPrice);

        if (criteria.MaxPrice.HasValue)
            query = query.Where(p => p.Price <= criteria.MaxPrice);

        return await query
            .OrderBy(p => p.Name)
            .Skip((criteria.Page - 1) * criteria.PageSize)
            .Take(criteria.PageSize)
            .ToListAsync(ct);
    }
}
Dapper for Performance
public class DapperProductRepository : IProductRepository
{
    private readonly IDbConnection _connection;

    public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
    {
        const string sql = """
            SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt
            FROM Products
            WHERE Id = @Id AND IsDeleted = 0
            """;

        return await _connection.QueryFirstOrDefaultAsync<Product>(
            new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
    }

    public async Task<IReadOnlyList<Product>> SearchAsync(
        ProductSearchCriteria criteria,
        CancellationToken ct = default)
    {
        var sql = new StringBuilder("""
            SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt
            FROM Products
            WHERE IsDeleted = 0
            """);

        var parameters = new DynamicParameters();

        if (!string.IsNullOrWhiteSpace(criteria.SearchTerm))
        {
            sql.Append(" AND Name LIKE @SearchTerm");
            parameters.Add("SearchTerm", $"%{criteria.SearchTerm}%");
        }

        if (criteria.CategoryId.HasValue)
        {
            sql.Append(" AND CategoryId = @CategoryId");
            parameters.Add("CategoryId", criteria.CategoryId);
        }

        if (criteria.MinPrice.HasValue)
        {
            sql.Append(" AND Price >= @MinPrice");
            parameters.Add("MinPrice", criteria.MinPrice);
        }

        if (criteria.MaxPrice.HasValue)
        {
            sql.Append(" AND Price <= @MaxPrice");
            parameters.Add("MaxPrice", criteria.MaxPrice);
        }

        sql.Append(" ORDER BY Name OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY");
        parameters.Add("Offset", (criteria.Page - 1) * criteria.PageSize);
        parameters.Add("PageSize", criteria.PageSize);

        var results = await _connection.QueryAsync<Product>(
            new CommandDefinition(sql.ToString(), parameters, cancellationToken: ct));

        return results.ToList();
    }

    // Multi-mapping for related data
    public async Task<Order?> GetOrderWithItemsAsync(int orderId, CancellationToken ct = default)
    {
        const string sql = """
            SELECT o.*, oi.*, p.*
            FROM Orders o
            LEFT JOIN OrderItems oi ON o.Id = oi.OrderId
            LEFT JOIN Products p ON oi.ProductId = p.Id
            WHERE o.Id = @OrderId
            """;

        var orderDictionary = new Dictionary<int, Order>();

        await _connection.QueryAsync<Order, OrderItem, Product, Order>(
            new CommandDefinition(sql, new { OrderId = orderId }, cancellationToken: ct),
            (order, item, product) =>
            {
                if (!orderDictionary.TryGetValue(order.Id, out var existingOrder))
                {
                    existingOrder = order;
                    existingOrder.Items = new List<OrderItem>();
                    orderDictionary.Add(order.Id, existingOrder);
                }

                if (item != null)
                {
                    item.Product = product;
                    existingOrder.Items.Add(item);
                }

                return existingOrder;
            },
            splitOn: "Id,Id");

        return orderDictionary.Values.FirstOrDefault();
    }
}

Caching Patterns

Multi-Level Cache with Redis
public class CachedProductService : IProductService
{
    private readonly IProductRepository _repository;
    private readonly IMemoryCache _memoryCache;
    private readonly IDistributedCache _distributedCache;
    private readonly ILogger<CachedProductService> _logger;

    private static readonly TimeSpan MemoryCacheDuration = TimeSpan.FromMinutes(1);
    private static readonly TimeSpan DistributedCacheDuration = TimeSpan.FromMinutes(15);

    public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
    {
        var cacheKey = $"product:{id}";

        // L1: Memory cache (in-process, fastest)
        if (_memoryCache.TryGetValue(cacheKey, out Product? cached))
        {
            _logger.LogDebug("L1 cache hit for {CacheKey}", cacheKey);
            return cached;
        }

        // L2: Distributed cache (Redis)
        var distributed = await _distributedCache.GetStringAsync(cacheKey, ct);
        if (distributed != null)
        {
            _logger.LogDebug("L2 cache hit for {CacheKey}", cacheKey);
            var product = JsonSerializer.Deserialize<Product>(distributed);

            // Populate L1
            _memoryCache.Set(cacheKey, product, MemoryCacheDuration);
            return product;
        }

        // L3: Database
        _logger.LogDebug("Cache miss for {CacheKey}, fetching from database", cacheKey);
        var fromDb = await _repository.GetByIdAsync(id, ct);

        if (fromDb != null)
        {
            var serialized = JsonSerializer.Serialize(fromDb);

            // Populate both caches
            await _distributedCache.SetStringAsync(
                cacheKey,
                serialized,
                new DistributedCacheEntryOptions
                {
                    AbsoluteExpirationRelativeToNow = DistributedCacheDuration
                },
                ct);

            _memoryCache.Set(cacheKey, fromDb, MemoryCacheDuration);
        }

        return fromDb;
    }

    public async Task InvalidateAsync(string id, CancellationToken ct = default)
    {
        var cacheKey = $"product:{id}";

        _memoryCache.Remove(cacheKey);
        await _distributedCache.RemoveAsync(cacheKey, ct);

        _logger.LogInformation("Invalidated cache for {CacheKey}", cacheKey);
    }
}

// Stale-while-revalidate pattern
public class StaleWhileRevalidateCache<T>
{
    private readonly IDistributedCache _cache;
    private readonly TimeSpan _freshDuration;
    private readonly TimeSpan _staleDuration;

    public async Task<T?> GetOrCreateAsync(
        string key,
        Func<CancellationToken, Task<T>> factory,
        CancellationToken ct = default)
    {
        var cached = await _cache.GetStringAsync(key, ct);

        if (cached != null)
        {
            var entry = JsonSerializer.Deserialize<CacheEntry<T>>(cached)!;

            if (entry.IsStale && !entry.IsExpired)
            {
                // Return stale data immediately, refresh in background
                _ = Task.Run(async () =>
                {
                    var fresh = await factory(CancellationToken.None);
                    await SetAsync(key, fresh, CancellationToken.None);
                });
            }

            if (!entry.IsExpired)
                return entry.Value;
        }

        // Cache miss or expired
        var value = await factory(ct);
        await SetAsync(key, value, ct);
        return value;
    }

    private record CacheEntry<TValue>(TValue Value, DateTime CreatedAt)
    {
        public bool IsStale => DateTime.UtcNow - CreatedAt > _freshDuration;
        public bool IsExpired => DateTime.UtcNow - CreatedAt > _staleDuration;
    }
}

Testing Patterns

Unit Tests with xUnit and Moq
public class OrderServiceTests
{
    private readonly Mock<IOrderRepository> _mockRepository;
    private readonly Mock<IStockService> _mockStockService;
    private readonly Mock<IValidator<CreateOrderRequest>> _mockValidator;
    private readonly OrderService _sut; // System Under Test

    public OrderServiceTests()
    {
        _mockRepository = new Mock<IOrderRepository>();
        _mockStockService = new Mock<IStockService>();
        _mockValidator = new Mock<IValidator<CreateOrderRequest>>();

        // Default: validation passes
        _mockValidator
            .Setup(v => v.ValidateAsync(It.IsAny<CreateOrderRequest>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(new ValidationResult());

        _sut = new OrderService(
            _mockRepository.Object,
            _mockStockService.Object,
            _mockValidator.Object);
    }

    [Fact]
    public async Task CreateOrderAsync_WithValidRequest_ReturnsSuccess()
    {
        // Arrange
        var request = new CreateOrderRequest
        {
            ProductId = "PROD-001",
            Quantity = 5,
            CustomerOrderCode = "ORD-2024-001"
        };

        _mockStockService
            .Setup(s => s.CheckAsync("PROD-001", 5, It.IsAny<CancellationToken>()))
            .ReturnsAsync(new StockResult { IsAvailable = true, Available = 10 });

        _mockRepository
            .Setup(r => r.CreateAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(new Order { Id = 1, CustomerOrderCode = "ORD-2024-001" });

        // Act
        var result = await _sut.CreateOrderAsync(request);

        // Assert
        Assert.True(result.IsSuccess);
        Assert.NotNull(result.Value);
        Assert.Equal(1, result.Value.Id);

        _mockRepository.Verify(
            r => r.CreateAsync(It.Is<Order>(o => o.CustomerOrderCode == "ORD-2024-001"),
            It.IsAny<CancellationToken>()),
            Times.Once);
    }

    [Fact]
    public async Task CreateOrderAsync_WithInsufficientStock_ReturnsFailure()
    {
        // Arrange
        var request = new CreateOrderRequest { ProductId = "PROD-001", Quantity = 100 };

        _mockStockService
            .Setup(s => s.CheckAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
            .ReturnsAsync(new StockResult { IsAvailable = false, Available = 5 });

        // Act
        var result = await _sut.CreateOrderAsync(request);

        // Assert
        Assert.False(result.IsSuccess);
        Assert.Equal("INSUFFICIENT_STOCK", result.ErrorCode);
        Assert.Contains("5 available", result.Error);

        _mockRepository.Verify(
            r => r.CreateAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()),
            Times.Never);
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    [InlineData(-100)]
    public async Task CreateOrderAsync_WithInvalidQuantity_ReturnsValidationError(int quantity)
    {
        // Arrange
        var request = new CreateOrderRequest { ProductId = "PROD-001", Quantity = quantity };

        _mockValidator
            .Setup(v => v.ValidateAsync(request, It.IsAny<CancellationToken>()))
            .ReturnsAsync(new ValidationResult(new[]
            {
                new ValidationFailure("Quantity", "Quantity must be greater than 0")
            }));

        // Act
        var result = await _sut.CreateOrderAsync(request);

        // Assert
        Assert.False(result.IsSuccess);
        Assert.Equal("VALIDATION_ERROR", result.ErrorCode);
    }
}
Integration Tests with WebApplicationFactory
public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;
    private readonly HttpClient _client;

    public ProductsApiTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // Replace real database with in-memory
                services.RemoveAll<DbContextOptions<AppDbContext>>();
                services.AddDbContext<AppDbContext>(options =>
                    options.UseInMemoryDatabase("TestDb"));

                // Replace Redis with memory cache
                services.RemoveAll<IDistributedCache>();
                services.AddDistributedMemoryCache();
            });
        });

        _client = _factory.CreateClient();
    }

    [Fact]
    public async Task GetProduct_WithValidId_ReturnsProduct()
    {
        // Arrange
        using var scope = _factory.Services.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();

        context.Products.Add(new Product
        {
            Id = "TEST-001",
            Name = "Test Product",
            Price = 99.99m
        });
        await context.SaveChangesAsync();

        // Act
        var response = await _client.GetAsync("/api/products/TEST-001");

        // Assert
        response.EnsureSuccessStatusCode();
        var product = await response.Content.ReadFromJsonAsync<Product>();
        Assert.Equal("Test Product", product!.Name);
    }

    [Fact]
    public async Task GetProduct_WithInvalidId_Returns404()
    {
        // Act
        var response = await _client.GetAsync("/api/products/NONEXISTENT");

        // Assert
        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
    }
}

Best Practices

DO
  1. Use async/await all the way through the call stack
  2. Inject dependencies through constructor injection
  3. Use IOptions<T> for typed configuration
  4. Return Result types instead of throwing exceptions for business logic
  5. Use CancellationToken in all async methods
  6. Prefer Dapper for read-heavy, performance-critical queries
  7. Use EF Core for complex domain models with change tracking
  8. Cache aggressively with proper invalidation strategies
  9. Write unit tests for business logic, integration tests for APIs
  10. Use record types for DTOs and immutable data
DON'T
  1. Don't block on async with .Result or .Wait()
  2. Don't use async void except for event handlers
  3. Don't catch generic Exception without re-throwing or logging
  4. Don't hardcode configuration values
  5. Don't expose EF entities directly in APIs (use DTOs)
  6. Don't forget AsNoTracking() for read-only queries
  7. Don't ignore CancellationToken parameters
  8. Don't create new HttpClient() manually (use IHttpClientFactory)
  9. Don't mix sync and async code unnecessarily
  10. Don't skip validation at API boundaries

Common Pitfalls

  • N+1 Queries: Use .Include() or explicit joins
  • Memory Leaks: Dispose IDisposable resources, use using
  • Deadlocks: Don't mix sync and async, use ConfigureAwait(false) in libraries
  • Over-fetching: Select only needed columns, use projections
  • Missing Indexes: Check query plans, add indexes for common filters
  • Timeout Issues: Configure appropriate timeouts for HTTP clients
  • Cache Stampede: Use distributed locks for cache population
1---
2name: dotnet-backend-patterns
3description: Master C#/.NET backend development patterns for building robust APIs, MCP servers, and enterprise applications. Covers async/await, dependency injection, Entity Framework Core, Dapper, configuration, caching, and testing with xUnit. Use when developing .NET backends, reviewing C# code, or designing API architectures.
4---
5 
6# .NET Backend Development Patterns
7 
8Master C#/.NET patterns for building production-grade APIs, MCP servers, and enterprise backends with modern best practices (2024/2025).
9 
10## When to Use This Skill
11 
12- Developing new .NET Web APIs or MCP servers
13- Reviewing C# code for quality and performance
14- Designing service architectures with dependency injection
15- Implementing caching strategies with Redis
16- Writing unit and integration tests
17- Optimizing database access with EF Core or Dapper
18- Configuring applications with IOptions pattern
19- Handling errors and implementing resilience patterns
20 
21## Core Concepts
22 
23### 1. Project Structure (Clean Architecture)
24 
25```
26src/
27├── Domain/ # Core business logic (no dependencies)
28│ ├── Entities/
29│ ├── Interfaces/
30│ ├── Exceptions/
31│ └── ValueObjects/
32├── Application/ # Use cases, DTOs, validation
33│ ├── Services/
34│ ├── DTOs/
35│ ├── Validators/
36│ └── Interfaces/
37├── Infrastructure/ # External implementations
38│ ├── Data/ # EF Core, Dapper repositories
39│ ├── Caching/ # Redis, Memory cache
40│ ├── External/ # HTTP clients, third-party APIs
41│ └── DependencyInjection/ # Service registration
42└── Api/ # Entry point
43 ├── Controllers/ # Or MinimalAPI endpoints
44 ├── Middleware/
45 ├── Filters/
46 └── Program.cs
47```
48 
49### 2. Dependency Injection Patterns
50 
51```csharp
52// Service registration by lifetime
53public static class ServiceCollectionExtensions
54{
55 public static IServiceCollection AddApplicationServices(
56 this IServiceCollection services,
57 IConfiguration configuration)
58 {
59 // Scoped: One instance per HTTP request
60 services.AddScoped<IProductService, ProductService>();
61 services.AddScoped<IOrderService, OrderService>();
62 
63 // Singleton: One instance for app lifetime
64 services.AddSingleton<ICacheService, RedisCacheService>();
65 services.AddSingleton<IConnectionMultiplexer>(_ =>
66 ConnectionMultiplexer.Connect(configuration["Redis:Connection"]!));
67 
68 // Transient: New instance every time
69 services.AddTransient<IValidator<CreateOrderRequest>, CreateOrderValidator>();
70 
71 // Options pattern for configuration
72 services.Configure<CatalogOptions>(configuration.GetSection("Catalog"));
73 services.Configure<RedisOptions>(configuration.GetSection("Redis"));
74 
75 // Factory pattern for conditional creation
76 services.AddScoped<IPriceCalculator>(sp =>
77 {
78 var options = sp.GetRequiredService<IOptions<PricingOptions>>().Value;
79 return options.UseNewEngine
80 ? sp.GetRequiredService<NewPriceCalculator>()
81 : sp.GetRequiredService<LegacyPriceCalculator>();
82 });
83 
84 // Keyed services (.NET 8+)
85 services.AddKeyedScoped<IPaymentProcessor, StripeProcessor>("stripe");
86 services.AddKeyedScoped<IPaymentProcessor, PayPalProcessor>("paypal");
87 
88 return services;
89 }
90}
91 
92// Usage with keyed services
93public class CheckoutService
94{
95 public CheckoutService(
96 [FromKeyedServices("stripe")] IPaymentProcessor stripeProcessor)
97 {
98 _processor = stripeProcessor;
99 }
100}
101```
102 
103### 3. Async/Await Patterns
104 
105```csharp
106// ✅ CORRECT: Async all the way down
107public async Task<Product> GetProductAsync(string id, CancellationToken ct = default)
108{
109 return await _repository.GetByIdAsync(id, ct);
110}
111 
112// ✅ CORRECT: Parallel execution with WhenAll
113public async Task<(Stock, Price)> GetStockAndPriceAsync(
114 string productId,
115 CancellationToken ct = default)
116{
117 var stockTask = _stockService.GetAsync(productId, ct);
118 var priceTask = _priceService.GetAsync(productId, ct);
119 
120 await Task.WhenAll(stockTask, priceTask);
121 
122 return (await stockTask, await priceTask);
123}
124 
125// ✅ CORRECT: ConfigureAwait in libraries
126public async Task<T> LibraryMethodAsync<T>(CancellationToken ct = default)
127{
128 var result = await _httpClient.GetAsync(url, ct).ConfigureAwait(false);
129 return await result.Content.ReadFromJsonAsync<T>(ct).ConfigureAwait(false);
130}
131 
132// ✅ CORRECT: ValueTask for hot paths with caching
133public ValueTask<Product?> GetCachedProductAsync(string id)
134{
135 if (_cache.TryGetValue(id, out Product? product))
136 return ValueTask.FromResult(product);
137 
138 return new ValueTask<Product?>(GetFromDatabaseAsync(id));
139}
140 
141// ❌ WRONG: Blocking on async (deadlock risk)
142var result = GetProductAsync(id).Result; // NEVER do this
143var result2 = GetProductAsync(id).GetAwaiter().GetResult(); // Also bad
144 
145// ❌ WRONG: async void (except event handlers)
146public async void ProcessOrder() { } // Exceptions are lost
147 
148// ❌ WRONG: Unnecessary Task.Run for already async code
149await Task.Run(async () => await GetDataAsync()); // Wastes thread
150```
151 
152### 4. Configuration with IOptions
153 
154```csharp
155// Configuration classes
156public class CatalogOptions
157{
158 public const string SectionName = "Catalog";
159 
160 public int DefaultPageSize { get; set; } = 50;
161 public int MaxPageSize { get; set; } = 200;
162 public TimeSpan CacheDuration { get; set; } = TimeSpan.FromMinutes(15);
163 public bool EnableEnrichment { get; set; } = true;
164}
165 
166public class RedisOptions
167{
168 public const string SectionName = "Redis";
169 
170 public string Connection { get; set; } = "localhost:6379";
171 public string KeyPrefix { get; set; } = "mcp:";
172 public int Database { get; set; } = 0;
173}
174 
175// appsettings.json
176{
177 "Catalog": {
178 "DefaultPageSize": 50,
179 "MaxPageSize": 200,
180 "CacheDuration": "00:15:00",
181 "EnableEnrichment": true
182 },
183 "Redis": {
184 "Connection": "localhost:6379",
185 "KeyPrefix": "mcp:",
186 "Database": 0
187 }
188}
189 
190// Registration
191services.Configure<CatalogOptions>(configuration.GetSection(CatalogOptions.SectionName));
192services.Configure<RedisOptions>(configuration.GetSection(RedisOptions.SectionName));
193 
194// Usage with IOptions (singleton, read once at startup)
195public class CatalogService
196{
197 private readonly CatalogOptions _options;
198 
199 public CatalogService(IOptions<CatalogOptions> options)
200 {
201 _options = options.Value;
202 }
203}
204 
205// Usage with IOptionsSnapshot (scoped, re-reads on each request)
206public class DynamicService
207{
208 private readonly CatalogOptions _options;
209 
210 public DynamicService(IOptionsSnapshot<CatalogOptions> options)
211 {
212 _options = options.Value; // Fresh value per request
213 }
214}
215 
216// Usage with IOptionsMonitor (singleton, notified on changes)
217public class MonitoredService
218{
219 private CatalogOptions _options;
220 
221 public MonitoredService(IOptionsMonitor<CatalogOptions> monitor)
222 {
223 _options = monitor.CurrentValue;
224 monitor.OnChange(newOptions => _options = newOptions);
225 }
226}
227```
228 
229### 5. Result Pattern (Avoiding Exceptions for Flow Control)
230 
231```csharp
232// Generic Result type
233public class Result<T>
234{
235 public bool IsSuccess { get; }
236 public T? Value { get; }
237 public string? Error { get; }
238 public string? ErrorCode { get; }
239 
240 private Result(bool isSuccess, T? value, string? error, string? errorCode)
241 {
242 IsSuccess = isSuccess;
243 Value = value;
244 Error = error;
245 ErrorCode = errorCode;
246 }
247 
248 public static Result<T> Success(T value) => new(true, value, null, null);
249 public static Result<T> Failure(string error, string? code = null) => new(false, default, error, code);
250 
251 public Result<TNew> Map<TNew>(Func<T, TNew> mapper) =>
252 IsSuccess ? Result<TNew>.Success(mapper(Value!)) : Result<TNew>.Failure(Error!, ErrorCode);
253 
254 public async Task<Result<TNew>> MapAsync<TNew>(Func<T, Task<TNew>> mapper) =>
255 IsSuccess ? Result<TNew>.Success(await mapper(Value!)) : Result<TNew>.Failure(Error!, ErrorCode);
256}
257 
258// Usage in service
259public async Task<Result<Order>> CreateOrderAsync(CreateOrderRequest request, CancellationToken ct)
260{
261 // Validation
262 var validation = await _validator.ValidateAsync(request, ct);
263 if (!validation.IsValid)
264 return Result<Order>.Failure(
265 validation.Errors.First().ErrorMessage,
266 "VALIDATION_ERROR");
267 
268 // Business rule check
269 var stock = await _stockService.CheckAsync(request.ProductId, request.Quantity, ct);
270 if (!stock.IsAvailable)
271 return Result<Order>.Failure(
272 $"Insufficient stock: {stock.Available} available, {request.Quantity} requested",
273 "INSUFFICIENT_STOCK");
274 
275 // Create order
276 var order = await _repository.CreateAsync(request.ToEntity(), ct);
277 
278 return Result<Order>.Success(order);
279}
280 
281// Usage in controller/endpoint
282app.MapPost("/orders", async (
283 CreateOrderRequest request,
284 IOrderService orderService,
285 CancellationToken ct) =>
286{
287 var result = await orderService.CreateOrderAsync(request, ct);
288 
289 return result.IsSuccess
290 ? Results.Created($"/orders/{result.Value!.Id}", result.Value)
291 : Results.BadRequest(new { error = result.Error, code = result.ErrorCode });
292});
293```
294 
295## Data Access Patterns
296 
297### Entity Framework Core
298 
299```csharp
300// DbContext configuration
301public class AppDbContext : DbContext
302{
303 public DbSet<Product> Products => Set<Product>();
304 public DbSet<Order> Orders => Set<Order>();
305 
306 protected override void OnModelCreating(ModelBuilder modelBuilder)
307 {
308 // Apply all configurations from assembly
309 modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
310 
311 // Global query filters
312 modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted);
313 }
314}
315 
316// Entity configuration
317public class ProductConfiguration : IEntityTypeConfiguration<Product>
318{
319 public void Configure(EntityTypeBuilder<Product> builder)
320 {
321 builder.ToTable("Products");
322 
323 builder.HasKey(p => p.Id);
324 builder.Property(p => p.Id).HasMaxLength(40);
325 builder.Property(p => p.Name).HasMaxLength(200).IsRequired();
326 builder.Property(p => p.Price).HasPrecision(18, 2);
327 
328 builder.HasIndex(p => p.Sku).IsUnique();
329 builder.HasIndex(p => new { p.CategoryId, p.Name });
330 
331 builder.HasMany(p => p.OrderItems)
332 .WithOne(oi => oi.Product)
333 .HasForeignKey(oi => oi.ProductId);
334 }
335}
336 
337// Repository with EF Core
338public class ProductRepository : IProductRepository
339{
340 private readonly AppDbContext _context;
341 
342 public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
343 {
344 return await _context.Products
345 .AsNoTracking()
346 .FirstOrDefaultAsync(p => p.Id == id, ct);
347 }
348 
349 public async Task<IReadOnlyList<Product>> SearchAsync(
350 ProductSearchCriteria criteria,
351 CancellationToken ct = default)
352 {
353 var query = _context.Products.AsNoTracking();
354 
355 if (!string.IsNullOrWhiteSpace(criteria.SearchTerm))
356 query = query.Where(p => EF.Functions.Like(p.Name, $"%{criteria.SearchTerm}%"));
357 
358 if (criteria.CategoryId.HasValue)
359 query = query.Where(p => p.CategoryId == criteria.CategoryId);
360 
361 if (criteria.MinPrice.HasValue)
362 query = query.Where(p => p.Price >= criteria.MinPrice);
363 
364 if (criteria.MaxPrice.HasValue)
365 query = query.Where(p => p.Price <= criteria.MaxPrice);
366 
367 return await query
368 .OrderBy(p => p.Name)
369 .Skip((criteria.Page - 1) * criteria.PageSize)
370 .Take(criteria.PageSize)
371 .ToListAsync(ct);
372 }
373}
374```
375 
376### Dapper for Performance
377 
378```csharp
379public class DapperProductRepository : IProductRepository
380{
381 private readonly IDbConnection _connection;
382 
383 public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
384 {
385 const string sql = """
386 SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt
387 FROM Products
388 WHERE Id = @Id AND IsDeleted = 0
389 """;
390 
391 return await _connection.QueryFirstOrDefaultAsync<Product>(
392 new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
393 }
394 
395 public async Task<IReadOnlyList<Product>> SearchAsync(
396 ProductSearchCriteria criteria,
397 CancellationToken ct = default)
398 {
399 var sql = new StringBuilder("""
400 SELECT Id, Name, Sku, Price, CategoryId, Stock, CreatedAt
401 FROM Products
402 WHERE IsDeleted = 0
403 """);
404 
405 var parameters = new DynamicParameters();
406 
407 if (!string.IsNullOrWhiteSpace(criteria.SearchTerm))
408 {
409 sql.Append(" AND Name LIKE @SearchTerm");
410 parameters.Add("SearchTerm", $"%{criteria.SearchTerm}%");
411 }
412 
413 if (criteria.CategoryId.HasValue)
414 {
415 sql.Append(" AND CategoryId = @CategoryId");
416 parameters.Add("CategoryId", criteria.CategoryId);
417 }
418 
419 if (criteria.MinPrice.HasValue)
420 {
421 sql.Append(" AND Price >= @MinPrice");
422 parameters.Add("MinPrice", criteria.MinPrice);
423 }
424 
425 if (criteria.MaxPrice.HasValue)
426 {
427 sql.Append(" AND Price <= @MaxPrice");
428 parameters.Add("MaxPrice", criteria.MaxPrice);
429 }
430 
431 sql.Append(" ORDER BY Name OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY");
432 parameters.Add("Offset", (criteria.Page - 1) * criteria.PageSize);
433 parameters.Add("PageSize", criteria.PageSize);
434 
435 var results = await _connection.QueryAsync<Product>(
436 new CommandDefinition(sql.ToString(), parameters, cancellationToken: ct));
437 
438 return results.ToList();
439 }
440 
441 // Multi-mapping for related data
442 public async Task<Order?> GetOrderWithItemsAsync(int orderId, CancellationToken ct = default)
443 {
444 const string sql = """
445 SELECT o.*, oi.*, p.*
446 FROM Orders o
447 LEFT JOIN OrderItems oi ON o.Id = oi.OrderId
448 LEFT JOIN Products p ON oi.ProductId = p.Id
449 WHERE o.Id = @OrderId
450 """;
451 
452 var orderDictionary = new Dictionary<int, Order>();
453 
454 await _connection.QueryAsync<Order, OrderItem, Product, Order>(
455 new CommandDefinition(sql, new { OrderId = orderId }, cancellationToken: ct),
456 (order, item, product) =>
457 {
458 if (!orderDictionary.TryGetValue(order.Id, out var existingOrder))
459 {
460 existingOrder = order;
461 existingOrder.Items = new List<OrderItem>();
462 orderDictionary.Add(order.Id, existingOrder);
463 }
464 
465 if (item != null)
466 {
467 item.Product = product;
468 existingOrder.Items.Add(item);
469 }
470 
471 return existingOrder;
472 },
473 splitOn: "Id,Id");
474 
475 return orderDictionary.Values.FirstOrDefault();
476 }
477}
478```
479 
480## Caching Patterns
481 
482### Multi-Level Cache with Redis
483 
484```csharp
485public class CachedProductService : IProductService
486{
487 private readonly IProductRepository _repository;
488 private readonly IMemoryCache _memoryCache;
489 private readonly IDistributedCache _distributedCache;
490 private readonly ILogger<CachedProductService> _logger;
491 
492 private static readonly TimeSpan MemoryCacheDuration = TimeSpan.FromMinutes(1);
493 private static readonly TimeSpan DistributedCacheDuration = TimeSpan.FromMinutes(15);
494 
495 public async Task<Product?> GetByIdAsync(string id, CancellationToken ct = default)
496 {
497 var cacheKey = $"product:{id}";
498 
499 // L1: Memory cache (in-process, fastest)
500 if (_memoryCache.TryGetValue(cacheKey, out Product? cached))
501 {
502 _logger.LogDebug("L1 cache hit for {CacheKey}", cacheKey);
503 return cached;
504 }
505 
506 // L2: Distributed cache (Redis)
507 var distributed = await _distributedCache.GetStringAsync(cacheKey, ct);
508 if (distributed != null)
509 {
510 _logger.LogDebug("L2 cache hit for {CacheKey}", cacheKey);
511 var product = JsonSerializer.Deserialize<Product>(distributed);
512 
513 // Populate L1
514 _memoryCache.Set(cacheKey, product, MemoryCacheDuration);
515 return product;
516 }
517 
518 // L3: Database
519 _logger.LogDebug("Cache miss for {CacheKey}, fetching from database", cacheKey);
520 var fromDb = await _repository.GetByIdAsync(id, ct);
521 
522 if (fromDb != null)
523 {
524 var serialized = JsonSerializer.Serialize(fromDb);
525 
526 // Populate both caches
527 await _distributedCache.SetStringAsync(
528 cacheKey,
529 serialized,
530 new DistributedCacheEntryOptions
531 {
532 AbsoluteExpirationRelativeToNow = DistributedCacheDuration
533 },
534 ct);
535 
536 _memoryCache.Set(cacheKey, fromDb, MemoryCacheDuration);
537 }
538 
539 return fromDb;
540 }
541 
542 public async Task InvalidateAsync(string id, CancellationToken ct = default)
543 {
544 var cacheKey = $"product:{id}";
545 
546 _memoryCache.Remove(cacheKey);
547 await _distributedCache.RemoveAsync(cacheKey, ct);
548 
549 _logger.LogInformation("Invalidated cache for {CacheKey}", cacheKey);
550 }
551}
552 
553// Stale-while-revalidate pattern
554public class StaleWhileRevalidateCache<T>
555{
556 private readonly IDistributedCache _cache;
557 private readonly TimeSpan _freshDuration;
558 private readonly TimeSpan _staleDuration;
559 
560 public async Task<T?> GetOrCreateAsync(
561 string key,
562 Func<CancellationToken, Task<T>> factory,
563 CancellationToken ct = default)
564 {
565 var cached = await _cache.GetStringAsync(key, ct);
566 
567 if (cached != null)
568 {
569 var entry = JsonSerializer.Deserialize<CacheEntry<T>>(cached)!;
570 
571 if (entry.IsStale && !entry.IsExpired)
572 {
573 // Return stale data immediately, refresh in background
574 _ = Task.Run(async () =>
575 {
576 var fresh = await factory(CancellationToken.None);
577 await SetAsync(key, fresh, CancellationToken.None);
578 });
579 }
580 
581 if (!entry.IsExpired)
582 return entry.Value;
583 }
584 
585 // Cache miss or expired
586 var value = await factory(ct);
587 await SetAsync(key, value, ct);
588 return value;
589 }
590 
591 private record CacheEntry<TValue>(TValue Value, DateTime CreatedAt)
592 {
593 public bool IsStale => DateTime.UtcNow - CreatedAt > _freshDuration;
594 public bool IsExpired => DateTime.UtcNow - CreatedAt > _staleDuration;
595 }
596}
597```
598 
599## Testing Patterns
600 
601### Unit Tests with xUnit and Moq
602 
603```csharp
604public class OrderServiceTests
605{
606 private readonly Mock<IOrderRepository> _mockRepository;
607 private readonly Mock<IStockService> _mockStockService;
608 private readonly Mock<IValidator<CreateOrderRequest>> _mockValidator;
609 private readonly OrderService _sut; // System Under Test
610 
611 public OrderServiceTests()
612 {
613 _mockRepository = new Mock<IOrderRepository>();
614 _mockStockService = new Mock<IStockService>();
615 _mockValidator = new Mock<IValidator<CreateOrderRequest>>();
616 
617 // Default: validation passes
618 _mockValidator
619 .Setup(v => v.ValidateAsync(It.IsAny<CreateOrderRequest>(), It.IsAny<CancellationToken>()))
620 .ReturnsAsync(new ValidationResult());
621 
622 _sut = new OrderService(
623 _mockRepository.Object,
624 _mockStockService.Object,
625 _mockValidator.Object);
626 }
627 
628 [Fact]
629 public async Task CreateOrderAsync_WithValidRequest_ReturnsSuccess()
630 {
631 // Arrange
632 var request = new CreateOrderRequest
633 {
634 ProductId = "PROD-001",
635 Quantity = 5,
636 CustomerOrderCode = "ORD-2024-001"
637 };
638 
639 _mockStockService
640 .Setup(s => s.CheckAsync("PROD-001", 5, It.IsAny<CancellationToken>()))
641 .ReturnsAsync(new StockResult { IsAvailable = true, Available = 10 });
642 
643 _mockRepository
644 .Setup(r => r.CreateAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()))
645 .ReturnsAsync(new Order { Id = 1, CustomerOrderCode = "ORD-2024-001" });
646 
647 // Act
648 var result = await _sut.CreateOrderAsync(request);
649 
650 // Assert
651 Assert.True(result.IsSuccess);
652 Assert.NotNull(result.Value);
653 Assert.Equal(1, result.Value.Id);
654 
655 _mockRepository.Verify(
656 r => r.CreateAsync(It.Is<Order>(o => o.CustomerOrderCode == "ORD-2024-001"),
657 It.IsAny<CancellationToken>()),
658 Times.Once);
659 }
660 
661 [Fact]
662 public async Task CreateOrderAsync_WithInsufficientStock_ReturnsFailure()
663 {
664 // Arrange
665 var request = new CreateOrderRequest { ProductId = "PROD-001", Quantity = 100 };
666 
667 _mockStockService
668 .Setup(s => s.CheckAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
669 .ReturnsAsync(new StockResult { IsAvailable = false, Available = 5 });
670 
671 // Act
672 var result = await _sut.CreateOrderAsync(request);
673 
674 // Assert
675 Assert.False(result.IsSuccess);
676 Assert.Equal("INSUFFICIENT_STOCK", result.ErrorCode);
677 Assert.Contains("5 available", result.Error);
678 
679 _mockRepository.Verify(
680 r => r.CreateAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()),
681 Times.Never);
682 }
683 
684 [Theory]
685 [InlineData(0)]
686 [InlineData(-1)]
687 [InlineData(-100)]
688 public async Task CreateOrderAsync_WithInvalidQuantity_ReturnsValidationError(int quantity)
689 {
690 // Arrange
691 var request = new CreateOrderRequest { ProductId = "PROD-001", Quantity = quantity };
692 
693 _mockValidator
694 .Setup(v => v.ValidateAsync(request, It.IsAny<CancellationToken>()))
695 .ReturnsAsync(new ValidationResult(new[]
696 {
697 new ValidationFailure("Quantity", "Quantity must be greater than 0")
698 }));
699 
700 // Act
701 var result = await _sut.CreateOrderAsync(request);
702 
703 // Assert
704 Assert.False(result.IsSuccess);
705 Assert.Equal("VALIDATION_ERROR", result.ErrorCode);
706 }
707}
708```
709 
710### Integration Tests with WebApplicationFactory
711 
712```csharp
713public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
714{
715 private readonly WebApplicationFactory<Program> _factory;
716 private readonly HttpClient _client;
717 
718 public ProductsApiTests(WebApplicationFactory<Program> factory)
719 {
720 _factory = factory.WithWebHostBuilder(builder =>
721 {
722 builder.ConfigureServices(services =>
723 {
724 // Replace real database with in-memory
725 services.RemoveAll<DbContextOptions<AppDbContext>>();
726 services.AddDbContext<AppDbContext>(options =>
727 options.UseInMemoryDatabase("TestDb"));
728 
729 // Replace Redis with memory cache
730 services.RemoveAll<IDistributedCache>();
731 services.AddDistributedMemoryCache();
732 });
733 });
734 
735 _client = _factory.CreateClient();
736 }
737 
738 [Fact]
739 public async Task GetProduct_WithValidId_ReturnsProduct()
740 {
741 // Arrange
742 using var scope = _factory.Services.CreateScope();
743 var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
744 
745 context.Products.Add(new Product
746 {
747 Id = "TEST-001",
748 Name = "Test Product",
749 Price = 99.99m
750 });
751 await context.SaveChangesAsync();
752 
753 // Act
754 var response = await _client.GetAsync("/api/products/TEST-001");
755 
756 // Assert
757 response.EnsureSuccessStatusCode();
758 var product = await response.Content.ReadFromJsonAsync<Product>();
759 Assert.Equal("Test Product", product!.Name);
760 }
761 
762 [Fact]
763 public async Task GetProduct_WithInvalidId_Returns404()
764 {
765 // Act
766 var response = await _client.GetAsync("/api/products/NONEXISTENT");
767 
768 // Assert
769 Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
770 }
771}
772```
773 
774## Best Practices
775 
776### DO
777 
7781. **Use async/await** all the way through the call stack
7792. **Inject dependencies** through constructor injection
7803. **Use IOptions<T>** for typed configuration
7814. **Return Result types** instead of throwing exceptions for business logic
7825. **Use CancellationToken** in all async methods
7836. **Prefer Dapper** for read-heavy, performance-critical queries
7847. **Use EF Core** for complex domain models with change tracking
7858. **Cache aggressively** with proper invalidation strategies
7869. **Write unit tests** for business logic, integration tests for APIs
78710. **Use record types** for DTOs and immutable data
788 
789### DON'T
790 
7911. **Don't block on async** with `.Result` or `.Wait()`
7922. **Don't use async void** except for event handlers
7933. **Don't catch generic Exception** without re-throwing or logging
7944. **Don't hardcode** configuration values
7955. **Don't expose EF entities** directly in APIs (use DTOs)
7966. **Don't forget** `AsNoTracking()` for read-only queries
7977. **Don't ignore** CancellationToken parameters
7988. **Don't create** `new HttpClient()` manually (use IHttpClientFactory)
7999. **Don't mix** sync and async code unnecessarily
80010. **Don't skip** validation at API boundaries
801 
802## Common Pitfalls
803 
804- **N+1 Queries**: Use `.Include()` or explicit joins
805- **Memory Leaks**: Dispose IDisposable resources, use `using`
806- **Deadlocks**: Don't mix sync and async, use ConfigureAwait(false) in libraries
807- **Over-fetching**: Select only needed columns, use projections
808- **Missing Indexes**: Check query plans, add indexes for common filters
809- **Timeout Issues**: Configure appropriate timeouts for HTTP clients
810- **Cache Stampede**: Use distributed locks for cache population
811 
View all files on GitHub →

Details

Updated
Sep 21, 2026
License
MIT
Stars on the repo
★ 39,857
Checked
Source
wshobson/agents ↗
Creator
wshobson · 179 listings
View creator

Similar skills

See all →
SkillGuides stable API and interface designaddyosmani/agent-skills · by addyosmaniGuides stable API and interface design. Use when designing APIs, module boundaries, or any public interface. Use when creating REST or GraphQL endpoints, defining type contracts between modules, or establishing boundaries between frontend and backend.★ 98.7k GitHub starsMCPPulls up-to-date, version-specific library docsContext7upstash/context7 · by upstashPulls up-to-date, version-specific library docs and code examples into the prompt so the AI stops inventing old APIs.★ 62.3k GitHub starsSkillFetch up-to-date documentation and code examples for anyContext7 Documentation Lookupupstash/context7 · by upstashFetch up-to-date documentation and code examples for any library, framework, SDK, CLI tool, or cloud service. Use whenever the user asks about a specific library — even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot — because training data may not reflect recent API changes or version updates. Always use for: API syntax questions, configuration options, version migration issues, "how do I" questions mentioning a library name, debugging that involves library-specific behavior, setup instructions, and CLI tool usage. Use even when you think you know the answer. Do not rely on training data for API details, signatures, or configuration options — they are frequently out of date. Prefer this over web search for library documentation.★ 62.3k GitHub starsSkillHow to use the Adaptyv Bio Foundry API and Python SDKK-Dense-AI/scientific-agent-skills · by K-Dense-AIHow to use the Adaptyv Bio Foundry API and Python SDK for protein experiment design, submission, and results retrieval. Use this skill whenever the user mentions Adaptyv, Foundry API, protein binding assays, protein screening experiments, BLI/SPR assays, thermostability assays, or wants to submit protein sequences for experimental characterization. Also trigger when code imports `adaptyv`, `adaptyv_sdk`, or `FoundryClient`, or references `foundry-api-public.adaptyvbio.com`.★ 46k GitHub starsSkillGuides the usage of Gemini Interactions API on GeminiGemini Interactions API Skillgoogle/skills · by googleGuides the usage of Gemini Interactions API on Gemini Enterprise Agent Platform. Use when the user wants to use the stateful, server-managed Interactions API for multi-turn conversations, background execution, streaming, structured output, and function calling on the Agent Platform.★ 20.9k GitHub starsSkillInstall, authenticate, troubleshootGemini Notebook Automationteng-lin/notebooklm-py · by teng-linInstall, authenticate, troubleshoot, and operate Gemini Notebook through the notebooklm-py CLI or typed async Python API. Use for notebook and source management, grounded chat and research, and artifact generation or download when the user mentions Gemini Notebook, notebooklm-py, the notebooklm CLI, or its Python API. Do not use for the generic Gemini API or unrelated content creation.★ 19.6k GitHub stars

More from wshobson

SkillImplement WCAG 2.2 compliant interfaces with mobileAccessibility compliancewshobson/agents · by wshobsonImplement WCAG 2.2 compliant interfaces with mobile accessibility, inclusive design patterns, and assistive technology support. Use when auditing accessibility, implementing ARIA patterns, building for screen readers, or ensuring inclusive user experiences.★ 39.9k GitHub starsSkillMaster Material Design 3 and Jetpack Compose patternsAndroid Mobile Designwshobson/agents · by wshobsonMaster Material Design 3 and Jetpack Compose patterns for building native Android apps. Use when designing Android interfaces, implementing Compose UI, or following Google's Material Design guidelines.★ 39.9k GitHub starsSkillMigrate from AngularJS to Angular using hybrid modeAngular migrationwshobson/agents · by wshobsonMigrate from AngularJS to Angular using hybrid mode, incremental component rewriting, and dependency injection updates. Use when upgrading AngularJS applications, planning framework migrations, or modernizing legacy Angular code.★ 39.9k GitHub starsSkillUnderstand anti-reversing, obfuscationAnti reversing techniqueswshobson/agents · by wshobsonUnderstand anti-reversing, obfuscation, and protection techniques encountered during software analysis. Use this skill when analyzing malware evasion techniques, when implementing anti-debugging protections for CTF challenges, when reverse engineering packed binaries, or when building security research tools that need to detect virtualized environments.★ 39.9k GitHub starsSkillBuild production Apache Airflow DAGs with best practicesApache Airflow DAG Patternswshobson/agents · by wshobsonBuild production Apache Airflow DAGs with best practices for operators, sensors, testing, and deployment. Use when creating data pipelines, orchestrating workflows, or scheduling batch jobs.★ 39.9k GitHub starsSkillOptimize Apache Spark jobs with partitioning, cachingApache Spark Optimizationwshobson/agents · by wshobsonOptimize Apache Spark jobs with partitioning, caching, shuffle optimization, and memory tuning. Use when improving Spark performance, debugging slow jobs, or scaling data processing pipelines.★ 39.9k GitHub stars