Back to Backend Development

dotnet-backend-patterns

C#.NETBackend DevelopmentAPI DesignEntity Framework CoreDapperMicroservicesXUnit Testing
⭐ 36.8kπŸ“„ MITπŸ•’ 2026-06-16Source β†—

Install this skill

npx skills add wshobson/agents

Works across Claude Code, Cursor, Codex, Copilot & Antigravity

The dotnet-backend-patterns skill provides a structural framework for building C# applications. It emphasizes Clean Architecture to decouple core business logic from external concerns like databases or web frameworks. The skill covers dependency injection lifetimes, including scoped, transient, and singleton patterns, alongside modern .NET 8 features like keyed services. It enforces non-blocking asynchronous coding standards to prevent deadlocks and optimize performance in high-concurrency environments. Configuration is handled through the IOptions pattern to ensure type-safe settings across environment variables and JSON files. This approach ensures code testability, maintainability, and scalability by strictly separating domain models from API implementations and infrastructure layers, resulting in predictable, modular codebases that follow industry-standard conventions for .NET development.

When to Use This Skill

  • β€’Building scalable MCP servers in .NET
  • β€’Refactoring legacy monoliths into layered architectures
  • β€’Implementing performant background data retrieval
  • β€’Standardizing dependency injection across multi-project solutions

How to Invoke This Skill

Example prompts that trigger this skill in Claude Code, Cursor, or Antigravity:

  • β€œrefactor my .NET service to clean architecture
  • β€œshow me how to use keyed services in .NET 8
  • β€œconfigure dependency injection lifetimes properly
  • β€œfix async await deadlocks in my controller
  • β€œimplement the options pattern for my configuration settings

Pro Tips

  • πŸ’‘Combine this skill with a 'unit-testing' skill to automatically generate comprehensive test cases for new backend components.
  • πŸ’‘Use it during code reviews to highlight deviations from established .NET patterns and suggest improvements.
  • πŸ’‘Prompt for architectural diagrams or design discussions leveraging specific patterns like Clean Architecture, then request code examples for chosen patterns.

What this skill does

  • β€’Organize projects into Domain, Application, and Infrastructure layers
  • β€’Configure service lifetimes and keyed DI registrations
  • β€’Implement asynchronous patterns to prevent thread blocking
  • β€’Manage app configuration using IOptions and validation
  • β€’Optimize high-frequency code paths with ValueTask

When not to use it

  • βœ•Small, single-file scripts or console tools where overhead is unnecessary
  • βœ•Prototyping where rapid iteration is prioritized over architectural rigor

Example workflow

  1. Define domain entities and interfaces in the Domain project
  2. Implement use cases and DTOs within the Application layer
  3. Register infrastructure services using appropriate DI lifetimes
  4. Configure API middleware and endpoints for request handling
  5. Apply async/await patterns to all I/O bound operations

Prerequisites

  • –Fundamental knowledge of C# syntax
  • –Basic understanding of .NET CLI and NuGet
  • –Familiarity with .NET 6+ features

Pitfalls & limitations

  • !Blocking async code with .Result or .GetAwaiter().GetResult() causing deadlocks
  • !Excessive use of async void leading to unhandled exceptions
  • !Over-engineering simple projects by strictly enforcing layered separation

FAQ

Why should I use ValueTask instead of Task?
ValueTask is more memory-efficient for methods that often return synchronously, such as cache hits, reducing heap allocations in hot code paths.
Is Clean Architecture overkill for my project?
It adds boilerplate, but it provides significant benefits for long-term maintainability and testability in enterprise-scale applications.
What is the primary benefit of the IOptions pattern?
It provides strongly-typed, validated access to configuration settings, preventing errors associated with raw string-based configuration lookups.

How it compares

While manual implementation relies on ad-hoc patterns or standard tutorials, this skill enforces a strict, architecture-focused standard that guarantees high code quality and consistency across complex C# projects.

Source & trust

⭐ 37k starsπŸ“„ MITπŸ•’ Updated 2026-06-16
πŸ“„ Full skill instructions β€” original source: wshobson/agents
# .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

## Resources

- **assets/service-template.cs**: Complete service implementation template
- **assets/repository-template.cs**: Repository pattern implementation
- **references/ef-core-best-practices.md**: EF Core optimization guide
- **references/dapper-patterns.md**: Advanced Dapper usage patterns

How to Use This Skill Unit

Option A: Project-Specific (Recommended)

  1. Click "Download" above
  2. In your project, create the directory: .agent/skills/dotnet-backend-patterns/
  3. Save the file as SKILL.md
  4. The agent will automatically discover the skill based on its description.

Option B: Global Installation (All Agents)

Save the file to these locations to make it available across all projects:

  • Claude Code: ~/.claude/skills/wshobson/agents/dotnet-backend-patterns/SKILL.md
  • Cursor: ~/.cursor/skills/wshobson/agents/dotnet-backend-patterns/SKILL.md
  • Antigravity: ~/.gemini/antigravity/skills/wshobson/agents/dotnet-backend-patterns/SKILL.md

πŸš€ Install with CLI:
npx skills add wshobson/agents

Read the Master Guide: Mastering Agent Skills β†’

Recommended Rules

View more rules β†’

Recommended Workflows

View more workflows β†’

Recommended MCP Servers

View more MCP servers β†’

Take It Further

Maximize your productivity with these powerful resources

πŸ“‹

Define Your Standards

Set up coding standards to ensure this workflow produces consistent, high-quality results.

Browse Rules Library
πŸ“–

Master Workflows

Learn how to create custom workflows, use Turbo Mode, and build your automation library.

Complete Guide

How to use this Skill in Claude Code & Cursor

For Claude Code (CLI)

To use this skill in Claude Code, copy the rule content into your project's custom instructions or follow our Add-Skill CLI guide. This ensures Claude follows your standards during every code generation.

For Cursor & Windsurf

For Cursor or Windsurf, individual skills are best used in the "Rules for AI" section. This specific unit helps the agent avoid backend development issues, leading to cleaner, more efficient code.

Why the skill format matters: the standardized Agent Skills format lets your AI agent load detailed instructions only when they are relevant, keeping your prompt clean while improving results.

Source & attribution

This skill is categorized under Backend Development and is published by W. Shobson, maintained in wshobson/agents.

← Browse All Agent Skills
Sponsored AI assistant. Recommendations may be paid.