mirror of
https://github.com/pnx/dotfiles
synced 2026-11-01 19:56:46 +01:00
update opencode
This commit is contained in:
parent
c7cf88af98
commit
38989f191a
9 changed files with 2240 additions and 14 deletions
903
opencode/context/core/standards/csharp.md
Normal file
903
opencode/context/core/standards/csharp.md
Normal file
|
|
@ -0,0 +1,903 @@
|
|||
---
|
||||
id: csharp
|
||||
name: C# Standards
|
||||
description: "Universal C# best practices for naming, async, LINQ, error handling, and testing"
|
||||
category: core
|
||||
type: standard
|
||||
version: 1.1.0
|
||||
author: community
|
||||
---
|
||||
|
||||
<!-- Context: core/standards | Priority: critical | Version: 1.1 | Updated: 2026-03-15 -->
|
||||
|
||||
# Universal C# Standards
|
||||
|
||||
**Purpose**: Universal C# best practices for AI agents working on .NET projects
|
||||
**Scope**: Language-level patterns, not framework-specific
|
||||
**Last Updated**: 2026-03-15
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Naming Conventions](#1-naming-conventions)
|
||||
2. [Type Safety & Nullability](#2-type-safety--nullability)
|
||||
3. [Async/Await Patterns](#3-asyncawait-patterns)
|
||||
4. [LINQ](#4-linq)
|
||||
5. [Error Handling](#5-error-handling)
|
||||
6. [Pattern Matching](#6-pattern-matching)
|
||||
7. [Code Organization](#7-code-organization)
|
||||
8. [Records & Immutability](#8-records--immutability)
|
||||
9. [Dependency Injection](#9-dependency-injection)
|
||||
10. [Testing](#10-testing)
|
||||
|
||||
---
|
||||
|
||||
## 1. Naming Conventions
|
||||
|
||||
### 1.1 General Rules
|
||||
|
||||
| Element | Convention | Example |
|
||||
|---------|-----------|---------|
|
||||
| Classes, structs, records | PascalCase | `UserService`, `OrderItem` |
|
||||
| Interfaces | `I` + PascalCase | `IUserRepository`, `IOrderService` |
|
||||
| Methods | PascalCase | `GetUserById`, `ProcessOrder` |
|
||||
| Properties | PascalCase | `FirstName`, `CreatedAt` |
|
||||
| Fields (private) | `_` + camelCase | `_logger`, `_repository` |
|
||||
| Local variables | camelCase | `userId`, `orderTotal` |
|
||||
| Parameters | camelCase | `userId`, `cancellationToken` |
|
||||
| Constants | PascalCase | `MaxRetryCount`, `DefaultTimeout` |
|
||||
| Enums | PascalCase (type and members) | `OrderStatus.Pending` |
|
||||
| Async methods | `Async` suffix | `GetUserAsync`, `SaveOrderAsync` |
|
||||
|
||||
### 1.2 Method Naming
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Verb + noun, PascalCase
|
||||
public async Task<User> GetUserByIdAsync(Guid userId) { }
|
||||
public async Task<IReadOnlyList<Order>> ListOrdersAsync(Guid userId) { }
|
||||
public async Task DeleteUserAsync(Guid userId) { }
|
||||
public bool IsEligibleForDiscount(Order order) { }
|
||||
public bool HasPermission(string action) { }
|
||||
|
||||
// ❌ AVOID - Ambiguous or wrong case
|
||||
public async Task<User> fetchuser(Guid id) { }
|
||||
public async Task<User> DoUserGet(Guid id) { }
|
||||
```
|
||||
|
||||
### 1.3 Interface Naming
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Always prefix with I
|
||||
public interface IUserRepository { }
|
||||
public interface IOrderService { }
|
||||
public interface INotificationSender { }
|
||||
|
||||
// ❌ AVOID - No prefix, or wrong prefix
|
||||
public interface UserRepository { }
|
||||
public interface TUserRepository { }
|
||||
```
|
||||
|
||||
### 1.4 Private Fields
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Underscore prefix, camelCase
|
||||
public class OrderService
|
||||
{
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly ILogger<OrderService> _logger;
|
||||
private int _retryCount;
|
||||
}
|
||||
|
||||
// ❌ AVOID - No prefix, or m_ prefix
|
||||
private IOrderRepository orderRepository;
|
||||
private IOrderRepository m_orderRepository;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Type Safety & Nullability
|
||||
|
||||
### 2.1 Enable Nullable Reference Types
|
||||
|
||||
**Rule: Always enable nullable reference types in all projects**
|
||||
|
||||
```xml
|
||||
<!-- .csproj -->
|
||||
<PropertyGroup>
|
||||
<Nullable>enable</Nullable>
|
||||
<WarningsAsErrors>nullable</WarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Explicit nullability
|
||||
public string Name { get; set; } // Non-nullable: must be assigned
|
||||
public string? MiddleName { get; set; } // Nullable: may be null
|
||||
|
||||
public User? FindUser(Guid id) { } // Returns null if not found
|
||||
public User GetUser(Guid id) { } // Never returns null (throws if not found)
|
||||
```
|
||||
|
||||
### 2.2 Null Handling
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Null-coalescing and conditional operators
|
||||
var name = user?.Name ?? "Unknown";
|
||||
var city = user?.Address?.City ?? string.Empty;
|
||||
user?.Notify("Welcome");
|
||||
|
||||
// ✅ GOOD - Null guard at method entry
|
||||
public void ProcessOrder(Order order)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(order);
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ GOOD - Null-coalescing assignment
|
||||
_cache ??= new Dictionary<string, User>();
|
||||
|
||||
// ❌ AVOID - Manual null checks where operators suffice
|
||||
if (user != null && user.Address != null)
|
||||
city = user.Address.City;
|
||||
```
|
||||
|
||||
### 2.3 Avoid Primitive Obsession
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Strongly typed IDs prevent mixing up parameters
|
||||
public readonly record struct UserId(Guid Value);
|
||||
public readonly record struct OrderId(Guid Value);
|
||||
|
||||
public Task<Order> GetOrderAsync(OrderId orderId, UserId userId) { }
|
||||
|
||||
// ❌ AVOID - Raw Guids are easy to mix up
|
||||
public Task<Order> GetOrderAsync(Guid orderId, Guid userId) { }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Async/Await Patterns
|
||||
|
||||
### 3.1 Always Use CancellationToken
|
||||
|
||||
**Rule: Every public async method must accept a CancellationToken**
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - CancellationToken flows through all async calls
|
||||
public async Task<User> GetUserAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _repository.FindAsync(userId, cancellationToken);
|
||||
return user ?? throw new NotFoundException($"User {userId} not found");
|
||||
}
|
||||
|
||||
// ❌ AVOID - No way to cancel long-running operations
|
||||
public async Task<User> GetUserAsync(Guid userId)
|
||||
{
|
||||
return await _repository.FindAsync(userId);
|
||||
}
|
||||
```
|
||||
|
||||
### 10.6 When to Use Moq Instead of NSubstitute
|
||||
|
||||
While **NSubstitute is the default**, use **Moq** for:
|
||||
|
||||
```csharp
|
||||
// Use Moq when you need MockBehavior.Strict (fail on unexpected calls)
|
||||
var mockRepository = new Mock<IOrderRepository>(MockBehavior.Strict);
|
||||
mockRepository.Setup(r => r.GetOrderAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Order());
|
||||
|
||||
// Use Moq for verifying complex call sequences
|
||||
var sequence = new MockSequence();
|
||||
_serviceA.InSequence(sequence).Setup(x => x.MethodA()).ReturnsAsync(true);
|
||||
_serviceB.InSequence(sequence).Setup(x => x.MethodB()).ReturnsAsync(false);
|
||||
|
||||
// Use Moq for verifying no other calls were made
|
||||
mock.VerifyNoOtherCalls();
|
||||
|
||||
// Use Moq for mocking protected members
|
||||
mock.Protected().Setup<string>("GetName").Returns("test");
|
||||
```
|
||||
|
||||
**Note**: NSubstitute covers ~95% of real-world test scenarios. Only reach for Moq's advanced features if NSubstitute doesn't provide what you need.
|
||||
|
||||
---
|
||||
|
||||
## 4. LINQ
|
||||
|
||||
### 4.1 Prefer Method Syntax for Simple Chains
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Method syntax for filter/project/sort
|
||||
var activeUserNames = users
|
||||
.Where(u => u.IsActive)
|
||||
.OrderBy(u => u.LastName)
|
||||
.Select(u => u.FullName)
|
||||
.ToList();
|
||||
|
||||
// ✅ GOOD - Query syntax for complex joins (more readable)
|
||||
var result =
|
||||
from order in orders
|
||||
join user in users on order.UserId equals user.Id
|
||||
where order.Status == OrderStatus.Pending
|
||||
select new { order.Id, user.Name };
|
||||
```
|
||||
|
||||
### 4.2 Materialize at the Right Time
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Materialize once, use the list
|
||||
var activeUsers = users.Where(u => u.IsActive).ToList();
|
||||
var count = activeUsers.Count;
|
||||
var first = activeUsers.FirstOrDefault();
|
||||
|
||||
// ❌ AVOID - Multiple enumerations of IEnumerable (re-evaluates query each time)
|
||||
var activeUsers = users.Where(u => u.IsActive);
|
||||
var count = activeUsers.Count(); // evaluates query
|
||||
var first = activeUsers.First(); // evaluates query again
|
||||
```
|
||||
|
||||
### 4.3 Use Appropriate Termination Methods
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Choose the right method for the intent
|
||||
var first = items.FirstOrDefault(); // null if empty
|
||||
var single = items.SingleOrDefault(); // null if empty, throws if >1
|
||||
var any = items.Any(x => x.IsActive); // bool, stops at first match
|
||||
var all = items.All(x => x.IsActive); // bool, fails fast on false
|
||||
var count = items.Count(x => x.IsActive); // full enumeration
|
||||
|
||||
// ❌ AVOID - Using Count() to check existence (full enumeration)
|
||||
if (items.Count() > 0) { } // Use Any() instead
|
||||
```
|
||||
|
||||
### 4.4 Avoid LINQ in Performance-Critical Paths
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Direct loop when allocation matters
|
||||
var total = 0m;
|
||||
foreach (var item in orderItems)
|
||||
total += item.Price * item.Quantity;
|
||||
|
||||
// LINQ alternative (fine for most code, avoids premature optimization)
|
||||
var total = orderItems.Sum(item => item.Price * item.Quantity);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Error Handling
|
||||
|
||||
### 5.1 Use Specific Exception Types
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Specific, meaningful exceptions
|
||||
public async Task<User> GetUserAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var user = await _repository.FindAsync(userId, ct);
|
||||
if (user is null)
|
||||
throw new NotFoundException($"User '{userId}' was not found.");
|
||||
return user;
|
||||
}
|
||||
|
||||
// ✅ GOOD - Domain exception hierarchy
|
||||
public class DomainException : Exception
|
||||
{
|
||||
public DomainException(string message) : base(message) { }
|
||||
public DomainException(string message, Exception inner) : base(message, inner) { }
|
||||
}
|
||||
|
||||
public class NotFoundException : DomainException
|
||||
{
|
||||
public NotFoundException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
public class ValidationException : DomainException
|
||||
{
|
||||
public IReadOnlyList<string> Errors { get; }
|
||||
|
||||
public ValidationException(IReadOnlyList<string> errors)
|
||||
: base("One or more validation errors occurred.")
|
||||
=> Errors = errors;
|
||||
}
|
||||
|
||||
// ❌ AVOID - Generic exceptions with no context
|
||||
throw new Exception("Not found");
|
||||
throw new ApplicationException("Something went wrong");
|
||||
```
|
||||
|
||||
### 5.2 Validate at Entry Points
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Fail fast with guard clauses
|
||||
public void PlaceOrder(Order order, Guid userId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(order);
|
||||
ArgumentNullException.ThrowIfNull(userId);
|
||||
|
||||
if (order.Items.Count == 0)
|
||||
throw new ValidationException(["Order must contain at least one item."]);
|
||||
|
||||
if (order.TotalAmount <= 0)
|
||||
throw new ValidationException(["Order total must be greater than zero."]);
|
||||
|
||||
// proceed with valid input
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Result Pattern (for Expected Failures)
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Use Result<T> when failure is a normal outcome (not exceptional)
|
||||
public readonly record struct Result<T>
|
||||
{
|
||||
public bool IsSuccess { get; }
|
||||
public T? Value { get; }
|
||||
public string? Error { get; }
|
||||
|
||||
private Result(bool isSuccess, T? value, string? error)
|
||||
{
|
||||
IsSuccess = isSuccess;
|
||||
Value = value;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public static Result<T> Success(T value) => new(true, value, null);
|
||||
public static Result<T> Failure(string error) => new(false, default, error);
|
||||
}
|
||||
|
||||
// Usage
|
||||
public Result<Order> SubmitOrder(Cart cart)
|
||||
{
|
||||
if (!cart.HasItems)
|
||||
return Result<Order>.Failure("Cart is empty.");
|
||||
|
||||
var order = CreateOrderFromCart(cart);
|
||||
return Result<Order>.Success(order);
|
||||
}
|
||||
|
||||
var result = SubmitOrder(cart);
|
||||
if (result.IsSuccess)
|
||||
Console.WriteLine($"Order created: {result.Value!.Id}");
|
||||
else
|
||||
Console.WriteLine($"Failed: {result.Error}");
|
||||
|
||||
// Reserve exceptions for truly exceptional, unexpected conditions.
|
||||
// Use Result<T> for expected business failures (validation, not found in context of search, etc.)
|
||||
```
|
||||
|
||||
### 5.4 Catch Specific Exceptions
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Catch what you can handle
|
||||
try
|
||||
{
|
||||
await _paymentGateway.ChargeAsync(amount, ct);
|
||||
}
|
||||
catch (PaymentDeclinedException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Payment declined for amount {Amount}", amount);
|
||||
return Result<Receipt>.Failure("Payment was declined.");
|
||||
}
|
||||
catch (TimeoutException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Payment gateway timed out");
|
||||
throw; // re-throw: let caller or middleware handle retries
|
||||
}
|
||||
|
||||
// ❌ AVOID - Swallowing exceptions silently
|
||||
try { await _paymentGateway.ChargeAsync(amount, ct); }
|
||||
catch { }
|
||||
|
||||
// ❌ AVOID - Catching Exception broadly without re-throw
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error");
|
||||
return null; // hides the real problem
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Pattern Matching
|
||||
|
||||
### 6.1 Switch Expressions (Prefer over switch statements)
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Switch expression: concise, exhaustive
|
||||
public decimal GetDiscount(CustomerTier tier) => tier switch
|
||||
{
|
||||
CustomerTier.Bronze => 0.00m,
|
||||
CustomerTier.Silver => 0.05m,
|
||||
CustomerTier.Gold => 0.10m,
|
||||
CustomerTier.Platinum => 0.20m,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(tier), tier, null)
|
||||
};
|
||||
|
||||
// ❌ AVOID - Verbose switch statement for simple value mapping
|
||||
switch (tier)
|
||||
{
|
||||
case CustomerTier.Bronze: return 0.00m;
|
||||
case CustomerTier.Silver: return 0.05m;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Type Patterns
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Type pattern with declaration
|
||||
public string Describe(Shape shape) => shape switch
|
||||
{
|
||||
Circle c => $"Circle with radius {c.Radius}",
|
||||
Rectangle r => $"Rectangle {r.Width}x{r.Height}",
|
||||
Triangle t => $"Triangle with base {t.Base}",
|
||||
_ => "Unknown shape"
|
||||
};
|
||||
|
||||
// ✅ GOOD - is pattern for type checking with binding
|
||||
if (notification is EmailNotification email)
|
||||
{
|
||||
await SendEmailAsync(email.Address, email.Body, ct);
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Property Patterns
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Property pattern for readable conditionals
|
||||
public decimal CalculateShipping(Order order) => order switch
|
||||
{
|
||||
{ TotalAmount: >= 100 } => 0m, // free shipping
|
||||
{ IsExpressDelivery: true } => 15m, // express
|
||||
{ ShippingAddress.Country: "FI" } => 5m, // domestic
|
||||
_ => 10m // international
|
||||
};
|
||||
```
|
||||
|
||||
### 6.4 Deconstruction
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Deconstruct tuples and records
|
||||
var (firstName, lastName) = GetFullName(userId);
|
||||
var (lat, lon) = location;
|
||||
|
||||
// ✅ GOOD - Discard unused parts
|
||||
var (id, _, createdAt) = GetOrderSummary(orderId);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Code Organization
|
||||
|
||||
### 7.1 Namespace Per Feature (not per type)
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Feature-based namespaces
|
||||
namespace MyApp.Orders; // all order-related types together
|
||||
namespace MyApp.Users; // all user-related types together
|
||||
namespace MyApp.Notifications;
|
||||
|
||||
// ❌ AVOID - Layer-based namespaces that scatter a feature across the codebase
|
||||
namespace MyApp.Repositories;
|
||||
namespace MyApp.Services;
|
||||
namespace MyApp.Controllers;
|
||||
```
|
||||
|
||||
### 7.2 File-Scoped Namespaces
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - File-scoped namespace (C# 10+): less indentation
|
||||
namespace MyApp.Orders;
|
||||
|
||||
public class OrderService { }
|
||||
|
||||
// ❌ AVOID - Block-scoped namespace adds unnecessary indentation
|
||||
namespace MyApp.Orders
|
||||
{
|
||||
public class OrderService { }
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 One Type Per File
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - OrderService.cs contains only OrderService
|
||||
// ✅ ACCEPTABLE - Small, closely related types in one file (e.g., value objects + their exceptions)
|
||||
|
||||
// ❌ AVOID - Multiple unrelated types in one file
|
||||
// OrderService.cs containing OrderService + UserService + ProductRepository
|
||||
```
|
||||
|
||||
### 7.4 Using Directives
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Global usings for commonly used namespaces (in a GlobalUsings.cs file)
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
global using Microsoft.Extensions.Logging;
|
||||
|
||||
// ✅ GOOD - File-level usings at the top, outside namespace
|
||||
using System.Text.Json;
|
||||
using MyApp.Common;
|
||||
|
||||
namespace MyApp.Orders;
|
||||
```
|
||||
|
||||
### 7.5 Class Structure Order
|
||||
|
||||
Follow this order within a class:
|
||||
|
||||
```csharp
|
||||
public class OrderService : IOrderService
|
||||
{
|
||||
// 1. Constants
|
||||
private const int MaxRetryCount = 3;
|
||||
|
||||
// 2. Static fields
|
||||
private static readonly JsonSerializerOptions JsonOptions = new();
|
||||
|
||||
// 3. Instance fields (private)
|
||||
private readonly IOrderRepository _repository;
|
||||
private readonly ILogger<OrderService> _logger;
|
||||
|
||||
// 4. Constructor(s)
|
||||
public OrderService(IOrderRepository repository, ILogger<OrderService> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// 5. Properties
|
||||
|
||||
// 6. Public methods
|
||||
|
||||
// 7. Private methods
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Records & Immutability
|
||||
|
||||
### 8.1 Use Records for Value Objects and DTOs
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Record for immutable data (value semantics, built-in equality)
|
||||
public record UserDto(Guid Id, string Name, string Email);
|
||||
|
||||
public record Address(string Street, string City, string PostalCode, string Country);
|
||||
|
||||
// ✅ GOOD - Readonly record struct for small value objects (stack allocated)
|
||||
public readonly record struct Money(decimal Amount, string Currency);
|
||||
public readonly record struct Coordinates(double Latitude, double Longitude);
|
||||
|
||||
// Non-destructive mutation via 'with'
|
||||
var updated = originalUser with { Email = "new@example.com" };
|
||||
```
|
||||
|
||||
### 8.2 Immutable Collections
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Expose immutable views
|
||||
public class Order
|
||||
{
|
||||
private readonly List<OrderItem> _items = new();
|
||||
|
||||
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
|
||||
|
||||
public void AddItem(OrderItem item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
_items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ GOOD - ImmutableList for truly immutable scenarios
|
||||
using System.Collections.Immutable;
|
||||
|
||||
public record ShoppingCart(ImmutableList<CartItem> Items)
|
||||
{
|
||||
public ShoppingCart AddItem(CartItem item) =>
|
||||
this with { Items = Items.Add(item) };
|
||||
|
||||
public ShoppingCart RemoveItem(Guid itemId) =>
|
||||
this with { Items = Items.RemoveAll(i => i.Id == itemId) };
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 init-only Properties
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - init allows construction but prevents later mutation
|
||||
public class OrderConfiguration
|
||||
{
|
||||
public Guid OrderId { get; init; }
|
||||
public string Currency { get; init; } = "EUR";
|
||||
public int MaxItems { get; init; } = 100;
|
||||
}
|
||||
|
||||
// Can set during object initializer, but not after
|
||||
var config = new OrderConfiguration { OrderId = Guid.NewGuid(), Currency = "USD" };
|
||||
// config.Currency = "EUR"; // ❌ compile error
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Dependency Injection
|
||||
|
||||
### 9.1 Constructor Injection (Preferred)
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - All dependencies injected through constructor, stored readonly
|
||||
public class OrderService : IOrderService
|
||||
{
|
||||
private readonly IOrderRepository _repository;
|
||||
private readonly IPaymentGateway _paymentGateway;
|
||||
private readonly ILogger<OrderService> _logger;
|
||||
|
||||
public OrderService(
|
||||
IOrderRepository repository,
|
||||
IPaymentGateway paymentGateway,
|
||||
ILogger<OrderService> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_paymentGateway = paymentGateway;
|
||||
_logger = logger;
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ AVOID - Service locator pattern (hidden dependencies)
|
||||
public class OrderService
|
||||
{
|
||||
public async Task ProcessAsync()
|
||||
{
|
||||
var repo = ServiceLocator.Get<IOrderRepository>(); // hidden dependency
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Lifetime Registration
|
||||
|
||||
```csharp
|
||||
// Registration in Program.cs or an extension method
|
||||
services.AddScoped<IOrderService, OrderService>(); // per HTTP request
|
||||
services.AddTransient<IEmailSender, SmtpEmailSender>(); // new instance each time
|
||||
services.AddSingleton<ICacheService, MemoryCacheService>(); // single instance
|
||||
|
||||
// ✅ GOOD - Extension method groups related registrations
|
||||
public static class OrdersServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddOrders(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IOrderService, OrderService>();
|
||||
services.AddScoped<IOrderRepository, OrderRepository>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
// In Program.cs
|
||||
builder.Services.AddOrders();
|
||||
```
|
||||
|
||||
### 9.3 Options Pattern for Configuration
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Strongly-typed configuration
|
||||
public class PaymentOptions
|
||||
{
|
||||
public const string SectionName = "Payment";
|
||||
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
public int TimeoutSeconds { get; set; } = 30;
|
||||
}
|
||||
|
||||
// Registration
|
||||
builder.Services.Configure<PaymentOptions>(
|
||||
builder.Configuration.GetSection(PaymentOptions.SectionName));
|
||||
|
||||
// Usage
|
||||
public class PaymentGateway
|
||||
{
|
||||
private readonly PaymentOptions _options;
|
||||
|
||||
public PaymentGateway(IOptions<PaymentOptions> options)
|
||||
{
|
||||
_options = options.Value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Testing
|
||||
|
||||
### 10.1 Framework & Structure
|
||||
|
||||
**Use xUnit** as the default test framework. Use **Shouldly** for readable assertions. Use **NSubstitute** for mocking (pragmatic, readable syntax). Use **Moq** only for scenarios requiring strict mock behavior or complex verification.
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - xUnit test class structure with NSubstitute
|
||||
public class OrderServiceTests
|
||||
{
|
||||
// Arrange shared fixtures in constructor or use class fixtures
|
||||
private readonly IOrderRepository _repositorySubstitute = Substitute.For<IOrderRepository>();
|
||||
private readonly ILogger<OrderService> _loggerSubstitute = Substitute.For<ILogger<OrderService>>();
|
||||
private readonly OrderService _sut;
|
||||
|
||||
public OrderServiceTests()
|
||||
{
|
||||
_sut = new OrderService(_repositorySubstitute, _loggerSubstitute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrderAsync_WhenOrderExists_ReturnsOrder()
|
||||
{
|
||||
// Arrange
|
||||
var orderId = Guid.NewGuid();
|
||||
var expected = new Order { Id = orderId };
|
||||
_repositorySubstitute
|
||||
.FindAsync(orderId, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<Order?>(expected));
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetOrderAsync(orderId);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeEquivalentTo(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrderAsync_WhenOrderNotFound_ThrowsNotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
var orderId = Guid.NewGuid();
|
||||
_repositorySubstitute
|
||||
.FindAsync(orderId, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<Order?>(null));
|
||||
|
||||
// Act
|
||||
var act = () => _sut.GetOrderAsync(orderId);
|
||||
|
||||
// Assert
|
||||
await act.ShouldThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveOrderAsync_CallsRepository_VerifiesInteraction()
|
||||
{
|
||||
// Arrange
|
||||
var order = new Order { Id = Guid.NewGuid() };
|
||||
|
||||
// Act
|
||||
await _sut.SaveOrderAsync(order);
|
||||
|
||||
// Assert
|
||||
await _repositorySubstitute.Received(1).SaveAsync(order, Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why NSubstitute?**
|
||||
- ✅ Fluent, readable syntax — substitutes ARE the interfaces, no `.Object` indirection
|
||||
- ✅ Lower ceremony, less noise in tests
|
||||
- ✅ `Arg.Any<T>()` is cleaner than `It.IsAny<T>()`
|
||||
- ✅ `Received()` reads naturally as "verify it received this call"
|
||||
- ✅ Faster to write and understand, even for complex scenarios
|
||||
|
||||
### 10.2 Parameterized Tests
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Theory with InlineData for multiple cases
|
||||
[Theory]
|
||||
[InlineData(CustomerTier.Bronze, 0.00)]
|
||||
[InlineData(CustomerTier.Silver, 0.05)]
|
||||
[InlineData(CustomerTier.Gold, 0.10)]
|
||||
[InlineData(CustomerTier.Platinum, 0.20)]
|
||||
public void GetDiscount_ReturnsCorrectRate(CustomerTier tier, decimal expected)
|
||||
{
|
||||
var result = _sut.GetDiscount(tier);
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ✅ GOOD - MemberData for complex input objects
|
||||
public static IEnumerable<object[]> InvalidOrders =>
|
||||
[
|
||||
[new Order { Items = [] }, "Order must contain at least one item."],
|
||||
[new Order { Items = [item], TotalAmount = -1 }, "Order total must be greater than zero."],
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidOrders))]
|
||||
public void PlaceOrder_WithInvalidOrder_ThrowsValidationException(Order order, string expectedError)
|
||||
{
|
||||
var act = () => _sut.PlaceOrder(order, Guid.NewGuid());
|
||||
act.ShouldThrow<ValidationException>()
|
||||
.Errors.ShouldContain(expectedError);
|
||||
}
|
||||
```
|
||||
|
||||
### 10.3 Test Naming
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - MethodName_StateUnderTest_ExpectedBehavior
|
||||
public async Task GetOrderAsync_WhenOrderExists_ReturnsOrder() { }
|
||||
public async Task GetOrderAsync_WhenOrderNotFound_ThrowsNotFoundException() { }
|
||||
public void PlaceOrder_WithEmptyCart_ThrowsValidationException() { }
|
||||
public void CalculateDiscount_ForPlatinumCustomer_Returns20Percent() { }
|
||||
```
|
||||
|
||||
### 10.4 Avoid Logic in Tests
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Direct, no conditionals or loops
|
||||
[Fact]
|
||||
public void FormatName_ReturnsFullName()
|
||||
{
|
||||
var user = new User { FirstName = "John", LastName = "Doe" };
|
||||
var result = _sut.FormatName(user);
|
||||
result.ShouldBe("John Doe");
|
||||
}
|
||||
|
||||
// ❌ AVOID - Logic in tests (makes failures hard to diagnose)
|
||||
[Fact]
|
||||
public void FormatNames_ReturnsFullNames()
|
||||
{
|
||||
var users = GetTestUsers();
|
||||
foreach (var user in users)
|
||||
{
|
||||
if (user.FirstName != null)
|
||||
_sut.FormatName(user).ShouldContain(user.FirstName);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.5 Integration Tests
|
||||
|
||||
```csharp
|
||||
// ✅ GOOD - Use WebApplicationFactory for ASP.NET Core integration tests
|
||||
public class OrdersApiTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public OrdersApiTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_client = factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Replace real DB with in-memory for tests
|
||||
services.RemoveAll<DbContext>();
|
||||
services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase("TestDb"));
|
||||
});
|
||||
}).CreateClient();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrder_ReturnsOk()
|
||||
{
|
||||
var response = await _client.GetAsync("/api/orders/123");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Topic | Key Rule |
|
||||
|-------|---------|
|
||||
| Naming | PascalCase methods/properties, `_camelCase` fields, `I`-prefix interfaces, `Async` suffix |
|
||||
| Nullability | Enable `<Nullable>enable</Nullable>`, use `?` explicitly, guard with `ArgumentNullException.ThrowIfNull` |
|
||||
| Async | Always pass `CancellationToken`, avoid `async void`, prefer `Task.WhenAll` for parallel ops |
|
||||
| LINQ | Materialize with `ToList()` once, use `Any()` not `Count() > 0` |
|
||||
| Errors | Specific exception types, validate at entry, `Result<T>` for expected failures |
|
||||
| Pattern matching | Switch expressions over switch statements, property patterns for readable conditionals |
|
||||
| Organization | File-scoped namespaces, feature-based folders, one type per file |
|
||||
| Immutability | Records for value objects/DTOs, `IReadOnlyList` for exposed collections |
|
||||
| DI | Constructor injection, `readonly` fields, Options pattern for config |
|
||||
| Testing | xUnit + Shouldly, `[Fact]`/`[Theory]`, Arrange-Act-Assert, no logic in tests |
|
||||
Loading…
Add table
Add a link
Reference in a new issue