Feature/add dotnet support (#275)
* 📝 docs: add C# and ASP.NET Core context files for agent guidance * 📝 docs: add C# / .NET to supported languages across docs * docs: bump C# project structure to v1.4.0 with project initialization section
This commit is contained in:
parent
d2a633d7dc
commit
2fa39e97bd
5 changed files with 1599 additions and 5 deletions
669
.opencode/context/core/standards/csharp-project-structure.md
Normal file
669
.opencode/context/core/standards/csharp-project-structure.md
Normal file
|
|
@ -0,0 +1,669 @@
|
||||||
|
---
|
||||||
|
id: csharp-project-structure
|
||||||
|
name: C# Project Structure
|
||||||
|
description: "ASP.NET Core project structure with Minimal APIs, CQRS, EF Core, and PostgreSQL patterns"
|
||||||
|
category: core
|
||||||
|
type: standard
|
||||||
|
version: 1.4.0
|
||||||
|
author: community
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Context: core/standards | Priority: critical | Version: 1.3 | Updated: 2026-03-15 -->
|
||||||
|
|
||||||
|
# C# Project Structure Standards
|
||||||
|
|
||||||
|
**Purpose**: Standard project structure for ASP.NET Core APIs using Minimal APIs, CQRS (MediatR), Vertical Slice Architecture, and EF Core with PostgreSQL
|
||||||
|
**Scope**: Project layout, wiring patterns, and conventions — not language-level rules (see `csharp.md`)
|
||||||
|
**Last Updated**: 2026-03-15
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
- **`Features/`** contains only business logic — Commands, Queries, Validators, Handlers. No HTTP or framework code.
|
||||||
|
- **`Features/Common/`** contains shared feature-level concerns — pipeline behaviors, shared exceptions.
|
||||||
|
- **`Infrastructure/`** contains all technical wiring — endpoint registration, DB context, external services.
|
||||||
|
- **Command/Query records are the API contract** — they are sent directly from endpoints. No separate DTO mapping layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Project Initialization](#1-project-initialization)
|
||||||
|
2. [Project Layout](#2-project-layout)
|
||||||
|
3. [Program.cs](#3-programcs)
|
||||||
|
4. [Features — Vertical Slices](#4-features--vertical-slices)
|
||||||
|
5. [Infrastructure](#5-infrastructure)
|
||||||
|
6. [EF Core & PostgreSQL](#6-ef-core--postgresql)
|
||||||
|
7. [Migrations](#7-migrations)
|
||||||
|
8. [NuGet Packages](#8-nuget-packages)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Project Initialization
|
||||||
|
|
||||||
|
**Always run these commands FIRST when creating a new ASP.NET Core project:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create .gitignore (C# patterns)
|
||||||
|
dotnet new gitignore
|
||||||
|
|
||||||
|
# Create .gitattributes (normalize line endings)
|
||||||
|
dotnet new gitattributes
|
||||||
|
|
||||||
|
# Create the project
|
||||||
|
dotnet new web -n MyApi
|
||||||
|
cd MyApi
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this order?**
|
||||||
|
- `.gitignore` must exist before any build artifacts are created (prevents committing `bin/`, `obj/`, etc.)
|
||||||
|
- `.gitattributes` ensures consistent line endings across team members
|
||||||
|
- Projects created after these are in place benefit from proper version control setup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Project Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
MyApi/
|
||||||
|
├── Program.cs # DI wiring + endpoint mapping only
|
||||||
|
├── MyApi.csproj
|
||||||
|
│
|
||||||
|
├── Api/ # Endpoint entry points — discover processes here
|
||||||
|
│ ├── OrderEndpoints.cs # IEndpointRouteBuilder extension — all /orders routes
|
||||||
|
│ ├── UserEndpoints.cs
|
||||||
|
│ └── ProductEndpoints.cs
|
||||||
|
│
|
||||||
|
├── Features/ # Pure business logic — no HTTP/framework code
|
||||||
|
│ ├── Orders/
|
||||||
|
│ │ ├── CreateOrder.cs # Command + Validator + Handler (co-located)
|
||||||
|
│ │ ├── GetOrder.cs # Query + Handler
|
||||||
|
│ │ ├── GetAllOrders.cs # Query + Handler
|
||||||
|
│ │ └── CancelOrder.cs # Command + Handler + Domain Event
|
||||||
|
│ │
|
||||||
|
│ ├── Users/
|
||||||
|
│ │ ├── CreateUser.cs
|
||||||
|
│ │ └── GetUser.cs
|
||||||
|
│ │
|
||||||
|
│ ├── Products/
|
||||||
|
│ │ └── CreateProduct.cs
|
||||||
|
│ │
|
||||||
|
│ └── Common/ # Shared feature-level concerns
|
||||||
|
│ ├── Behaviors/
|
||||||
|
│ │ ├── LoggingBehavior.cs
|
||||||
|
│ │ └── ValidationBehavior.cs
|
||||||
|
│ └── Exceptions/
|
||||||
|
│ ├── NotFoundException.cs
|
||||||
|
│ └── ValidationException.cs
|
||||||
|
│
|
||||||
|
├── Infrastructure/ # All framework/technical wiring (non-endpoint)
|
||||||
|
│ ├── Persistence/
|
||||||
|
│ │ ├── AppDbContext.cs
|
||||||
|
│ │ ├── Configurations/ # IEntityTypeConfiguration<T> classes
|
||||||
|
│ │ │ ├── OrderConfiguration.cs
|
||||||
|
│ │ │ └── UserConfiguration.cs
|
||||||
|
│ │ └── Migrations/ # EF Core migrations (auto-generated)
|
||||||
|
│ ├── Services/ # External HTTP clients, email, storage, etc.
|
||||||
|
│ └── Extensions/
|
||||||
|
│ └── InfrastructureExtensions.cs # AddInfrastructure() registration
|
||||||
|
│
|
||||||
|
├── Domain/ # Optional: rich domain model (for DDD projects)
|
||||||
|
│ ├── Entities/
|
||||||
|
│ └── Events/
|
||||||
|
│
|
||||||
|
├── appsettings.json
|
||||||
|
├── appsettings.Development.json
|
||||||
|
└── GlobalUsings.cs # global using directives
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Program.cs
|
||||||
|
|
||||||
|
`Program.cs` contains **only** DI registration and endpoint mapping. No business logic.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Program.cs
|
||||||
|
using MyApi.Api;
|
||||||
|
using MyApi.Infrastructure.Extensions;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// ── Infrastructure (DB, external services) ───────────────────────────────
|
||||||
|
builder.Services.AddInfrastructure(builder.Configuration);
|
||||||
|
|
||||||
|
// ── MediatR (CQRS) ───────────────────────────────────────────────────────
|
||||||
|
builder.Services.AddMediatR(cfg =>
|
||||||
|
{
|
||||||
|
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
|
||||||
|
cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
|
||||||
|
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Validation ────────────────────────────────────────────────────────────
|
||||||
|
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);
|
||||||
|
|
||||||
|
// ── OpenAPI ───────────────────────────────────────────────────────────────
|
||||||
|
builder.Services.AddOpenApi(); // .NET 9 native; or Swashbuckle for earlier versions
|
||||||
|
|
||||||
|
// ── Auth ──────────────────────────────────────────────────────────────────
|
||||||
|
builder.Services.AddAuthentication().AddJwtBearer();
|
||||||
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
|
// ── Build ─────────────────────────────────────────────────────────────────
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
app.MapOpenApi();
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
// ── Endpoint registration ─────────────────────────────────────────────────
|
||||||
|
app.MapOrderEndpoints();
|
||||||
|
app.MapUserEndpoints();
|
||||||
|
app.MapProductEndpoints();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
|
||||||
|
public partial class Program { } // allows WebApplicationFactory in integration tests
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Features — Vertical Slices
|
||||||
|
|
||||||
|
Each use case lives in its own file: **Command or Query record + Validator + Handler — nothing else**.
|
||||||
|
The Command/Query record is the API contract — it is bound directly from the HTTP request body/route.
|
||||||
|
No separate DTO types, no mapping layer.
|
||||||
|
|
||||||
|
### 3.1 Command Slice (write operation)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Features/Orders/CreateOrder.cs
|
||||||
|
namespace MyApi.Features.Orders;
|
||||||
|
|
||||||
|
// ── Command = API request contract ───────────────────────────────────────
|
||||||
|
// Bound directly from HTTP request body. No separate DTO needed.
|
||||||
|
public record CreateOrderCommand(Guid UserId, List<OrderItem> Items) : IRequest<CreatedOrderResult>;
|
||||||
|
|
||||||
|
// Return type is also a record — represents the response shape
|
||||||
|
public record CreatedOrderResult(Guid Id, Guid UserId, DateTime CreatedAt);
|
||||||
|
|
||||||
|
// ── Validator ─────────────────────────────────────────────────────────────
|
||||||
|
public class CreateOrderValidator : AbstractValidator<CreateOrderCommand>
|
||||||
|
{
|
||||||
|
public CreateOrderValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.UserId).NotEmpty();
|
||||||
|
RuleFor(x => x.Items).NotEmpty().WithMessage("Order must contain at least one item.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handler ───────────────────────────────────────────────────────────────
|
||||||
|
public class CreateOrderHandler(AppDbContext db) : IRequestHandler<CreateOrderCommand, CreatedOrderResult>
|
||||||
|
{
|
||||||
|
public async Task<CreatedOrderResult> Handle(CreateOrderCommand cmd, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var order = new Order
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = cmd.UserId,
|
||||||
|
Items = cmd.Items,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Orders.Add(order);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return new CreatedOrderResult(order.Id, order.UserId, order.CreatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Query Slice (read operation)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Features/Orders/GetOrder.cs
|
||||||
|
namespace MyApi.Features.Orders;
|
||||||
|
|
||||||
|
// ── Query = API request contract ──────────────────────────────────────────
|
||||||
|
// Route parameter bound directly. Return type is the response shape.
|
||||||
|
public record GetOrderQuery(Guid Id) : IRequest<OrderResult?>;
|
||||||
|
|
||||||
|
public record OrderResult(Guid Id, Guid UserId, OrderStatus Status, DateTime CreatedAt);
|
||||||
|
|
||||||
|
// ── Handler ───────────────────────────────────────────────────────────────
|
||||||
|
public class GetOrderHandler(AppDbContext db) : IRequestHandler<GetOrderQuery, OrderResult?>
|
||||||
|
{
|
||||||
|
public async Task<OrderResult?> Handle(GetOrderQuery query, CancellationToken ct)
|
||||||
|
=> await db.Orders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(o => o.Id == query.Id)
|
||||||
|
.Select(o => new OrderResult(o.Id, o.UserId, o.Status, o.CreatedAt))
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Domain Events (INotification)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Features/Orders/CancelOrder.cs
|
||||||
|
namespace MyApi.Features.Orders;
|
||||||
|
|
||||||
|
public record CancelOrderCommand(Guid Id) : IRequest<bool>;
|
||||||
|
|
||||||
|
// ── Domain event ──────────────────────────────────────────────────────────
|
||||||
|
public record OrderCancelledEvent(Guid OrderId) : INotification;
|
||||||
|
|
||||||
|
// ── Handler ───────────────────────────────────────────────────────────────
|
||||||
|
public class CancelOrderHandler(AppDbContext db, IPublisher publisher)
|
||||||
|
: IRequestHandler<CancelOrderCommand, bool>
|
||||||
|
{
|
||||||
|
public async Task<bool> Handle(CancelOrderCommand cmd, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var order = await db.Orders.FindAsync([cmd.Id], ct);
|
||||||
|
if (order is null) return false;
|
||||||
|
|
||||||
|
order.Status = OrderStatus.Cancelled;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
await publisher.Publish(new OrderCancelledEvent(order.Id), ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Side-effect handlers (each independent, all run on Publish) ──────────
|
||||||
|
public class SendCancellationEmailHandler(IEmailService email)
|
||||||
|
: INotificationHandler<OrderCancelledEvent>
|
||||||
|
{
|
||||||
|
public async Task Handle(OrderCancelledEvent e, CancellationToken ct)
|
||||||
|
=> await email.SendCancellationAsync(e.OrderId, ct);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Features/Common — Pipeline Behaviors
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Features/Common/Behaviors/LoggingBehavior.cs
|
||||||
|
namespace MyApi.Features.Common.Behaviors;
|
||||||
|
|
||||||
|
public class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
|
||||||
|
: IPipelineBehavior<TRequest, TResponse>
|
||||||
|
where TRequest : notnull
|
||||||
|
{
|
||||||
|
public async Task<TResponse> Handle(
|
||||||
|
TRequest request,
|
||||||
|
RequestHandlerDelegate<TResponse> next,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var name = typeof(TRequest).Name;
|
||||||
|
logger.LogInformation("Handling {Request}", name);
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await next();
|
||||||
|
logger.LogInformation("Handled {Request} in {ElapsedMs}ms", name, sw.ElapsedMilliseconds);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Error handling {Request} after {ElapsedMs}ms", name, sw.ElapsedMilliseconds);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Features/Common/Behaviors/ValidationBehavior.cs
|
||||||
|
namespace MyApi.Features.Common.Behaviors;
|
||||||
|
|
||||||
|
public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
|
||||||
|
: IPipelineBehavior<TRequest, TResponse>
|
||||||
|
where TRequest : notnull
|
||||||
|
{
|
||||||
|
public async Task<TResponse> Handle(
|
||||||
|
TRequest request,
|
||||||
|
RequestHandlerDelegate<TResponse> next,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!validators.Any()) return await next();
|
||||||
|
|
||||||
|
var context = new ValidationContext<TRequest>(request);
|
||||||
|
var results = await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, ct)));
|
||||||
|
var failures = results.SelectMany(r => r.Errors).Where(f => f is not null).ToList();
|
||||||
|
|
||||||
|
if (failures.Count > 0)
|
||||||
|
throw new ValidationException(failures);
|
||||||
|
|
||||||
|
return await next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Infrastructure & API Endpoints
|
||||||
|
|
||||||
|
### 4.1 API Endpoints (Entry Points)
|
||||||
|
|
||||||
|
Endpoint files live in the `Api/` directory at the project root — they are the entry points for discovering business processes. These files handle HTTP concerns only: routing, parameter binding, response shaping, and dispatching to MediatR. No business logic here.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Api/OrderEndpoints.cs
|
||||||
|
namespace MyApi.Api;
|
||||||
|
|
||||||
|
public static class OrderEndpoints
|
||||||
|
{
|
||||||
|
public static void MapOrderEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/orders")
|
||||||
|
.WithTags("Orders")
|
||||||
|
.WithOpenApi()
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/", GetAllOrders).WithName("GetAllOrders");
|
||||||
|
group.MapGet("/{id}", GetOrder) .WithName("GetOrder");
|
||||||
|
group.MapPost("/", CreateOrder) .WithName("CreateOrder");
|
||||||
|
group.MapDelete("/{id}", CancelOrder) .WithName("CancelOrder");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command/Query records are bound directly from HTTP — no mapping needed
|
||||||
|
// Use ISender (not IMediator) — exposes only Send/CreateStream
|
||||||
|
|
||||||
|
private static async Task<Ok<List<OrderResult>>> GetAllOrders(
|
||||||
|
ISender sender, CancellationToken ct)
|
||||||
|
=> TypedResults.Ok(await sender.Send(new GetAllOrdersQuery(), ct));
|
||||||
|
|
||||||
|
private static async Task<Results<Ok<OrderResult>, NotFound>> GetOrder(
|
||||||
|
Guid id, ISender sender, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new GetOrderQuery(id), ct);
|
||||||
|
return result is not null ? TypedResults.Ok(result) : TypedResults.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Results<Created<CreatedOrderResult>, ValidationProblem>> CreateOrder(
|
||||||
|
CreateOrderCommand command, ISender sender, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var order = await sender.Send(command, ct);
|
||||||
|
return TypedResults.Created($"/orders/{order.Id}", order);
|
||||||
|
}
|
||||||
|
catch (ValidationException ex)
|
||||||
|
{
|
||||||
|
return TypedResults.ValidationProblem(ex.ToDictionary());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Results<NoContent, NotFound>> CancelOrder(
|
||||||
|
Guid id, ISender sender, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cancelled = await sender.Send(new CancelOrderCommand(id), ct);
|
||||||
|
return cancelled ? TypedResults.NoContent() : TypedResults.NotFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 InfrastructureExtensions
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Infrastructure/Extensions/InfrastructureExtensions.cs
|
||||||
|
namespace MyApi.Infrastructure.Extensions;
|
||||||
|
|
||||||
|
public static class InfrastructureExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddInfrastructure(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
// ── Database ──────────────────────────────────────────────────────
|
||||||
|
services.AddDbContext<AppDbContext>(options =>
|
||||||
|
options.UseNpgsql(
|
||||||
|
configuration.GetConnectionString("DefaultConnection")
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Connection string 'DefaultConnection' not found.")));
|
||||||
|
|
||||||
|
// ── External services ─────────────────────────────────────────────
|
||||||
|
services.AddHttpClient<IExternalService, ExternalService>(client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new Uri(
|
||||||
|
configuration["ExternalService:BaseUrl"]
|
||||||
|
?? throw new InvalidOperationException("ExternalService:BaseUrl not configured."));
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddScoped<IEmailService, SmtpEmailService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 AppDbContext
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Infrastructure/Persistence/AppDbContext.cs
|
||||||
|
namespace MyApi.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
||||||
|
{
|
||||||
|
public DbSet<Order> Orders => Set<Order>();
|
||||||
|
public DbSet<User> Users => Set<User>();
|
||||||
|
public DbSet<Product> Products => Set<Product>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
// Auto-discovers all IEntityTypeConfiguration<T> classes in the assembly
|
||||||
|
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Entity Configuration
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Infrastructure/Persistence/Configurations/OrderConfiguration.cs
|
||||||
|
namespace MyApi.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
|
public sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Order> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("orders");
|
||||||
|
|
||||||
|
builder.HasKey(o => o.Id);
|
||||||
|
|
||||||
|
builder.Property(o => o.Id)
|
||||||
|
.HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
|
||||||
|
builder.Property(o => o.Status)
|
||||||
|
.HasConversion<string>()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(o => o.CreatedAt)
|
||||||
|
.HasDefaultValueSql("now()")
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.HasQueryFilter(o => !o.IsDeleted);
|
||||||
|
|
||||||
|
builder.HasOne(o => o.User)
|
||||||
|
.WithMany(u => u.Orders)
|
||||||
|
.HasForeignKey(o => o.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. EF Core & PostgreSQL
|
||||||
|
|
||||||
|
### 5.1 Connection String (appsettings.json)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Host=localhost;Port=5432;Database=myapp;Username=postgres;Password=yourpassword"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Override in environment / Docker / Kubernetes using double-underscore notation:
|
||||||
|
```
|
||||||
|
ConnectionStrings__DefaultConnection=Host=prod-db;...
|
||||||
|
```
|
||||||
|
|
||||||
|
For local development, use `dotnet user-secrets`:
|
||||||
|
```bash
|
||||||
|
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Host=localhost;..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Async Query Patterns
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ✅ Always pass CancellationToken to all EF async methods
|
||||||
|
var orders = await db.Orders
|
||||||
|
.AsNoTracking() // read-only queries: skip change tracking
|
||||||
|
.Where(o => o.UserId == userId)
|
||||||
|
.OrderByDescending(o => o.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// ✅ FindAsync for primary key lookup (uses change tracker cache first)
|
||||||
|
var order = await db.Orders.FindAsync([orderId], ct);
|
||||||
|
|
||||||
|
// ✅ Bulk update / delete without loading entities (EF 7+)
|
||||||
|
await db.Orders
|
||||||
|
.Where(o => o.Status == OrderStatus.Pending && o.CreatedAt < cutoff)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, OrderStatus.Expired), ct);
|
||||||
|
|
||||||
|
await db.Orders
|
||||||
|
.Where(o => o.IsDeleted && o.CreatedAt < cutoff)
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 AsNoTracking
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ✅ Use AsNoTracking for all read/query handlers — no change tracker overhead
|
||||||
|
var result = await db.Orders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(o => o.Id == id)
|
||||||
|
.Select(o => new OrderResult(o.Id, o.UserId, o.Status, o.CreatedAt))
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
// ✅ Omit AsNoTracking in command handlers that modify and call SaveChangesAsync
|
||||||
|
var order = await db.Orders.FindAsync([id], ct);
|
||||||
|
order!.Status = OrderStatus.Shipped;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Migrations
|
||||||
|
|
||||||
|
### Common Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add a new migration after model changes
|
||||||
|
dotnet ef migrations add MigrationName
|
||||||
|
|
||||||
|
# Apply pending migrations to the database
|
||||||
|
dotnet ef database update
|
||||||
|
|
||||||
|
# List all migrations and their applied status
|
||||||
|
dotnet ef migrations list
|
||||||
|
|
||||||
|
# Remove the last unapplied migration
|
||||||
|
dotnet ef migrations remove
|
||||||
|
|
||||||
|
# Multi-project setup (DbContext in separate library)
|
||||||
|
dotnet ef migrations add MigrationName \
|
||||||
|
--project src/MyApp.Data \
|
||||||
|
--startup-project src/MyApp.Api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate idempotent SQL script — safe to run multiple times (recommended for CI/CD)
|
||||||
|
dotnet ef migrations script --idempotent --output migrations.sql
|
||||||
|
|
||||||
|
# EF 9: self-contained migration bundle (no dotnet SDK needed at deploy time)
|
||||||
|
dotnet ef migrations bundle --output migrations-bundle
|
||||||
|
./migrations-bundle --connection "${DB_CONNECTION_STRING}"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Do not** auto-migrate in `Program.cs` (`db.Database.MigrateAsync()`) in production multi-instance deployments — use SQL scripts or migration bundles instead to avoid race conditions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. NuGet Packages
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Minimal API + ASP.NET Core (included via SDK, listed for clarity) -->
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.*" />
|
||||||
|
|
||||||
|
<!-- CQRS -->
|
||||||
|
<PackageReference Include="MediatR" Version="12.*" />
|
||||||
|
|
||||||
|
<!-- Validation -->
|
||||||
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.*" />
|
||||||
|
|
||||||
|
<!-- EF Core + PostgreSQL -->
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.*" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.*" />
|
||||||
|
|
||||||
|
<!-- Testing -->
|
||||||
|
<PackageReference Include="xunit" Version="2.*" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
|
||||||
|
<PackageReference Include="Shouldly" Version="4.*" />
|
||||||
|
<PackageReference Include="NSubstitute" Version="5.*" />
|
||||||
|
<PackageReference Include="NSubstitute.Analyzers.CSharp" Version="1.*" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.*" />
|
||||||
|
<PackageReference Include="Testcontainers.PostgreSql" Version="3.*" />
|
||||||
|
</ItemGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GlobalUsings.cs
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
global using System;
|
||||||
|
global using System.Collections.Generic;
|
||||||
|
global using System.Linq;
|
||||||
|
global using System.Threading;
|
||||||
|
global using System.Threading.Tasks;
|
||||||
|
global using FluentValidation;
|
||||||
|
global using MediatR;
|
||||||
|
global using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
global using Microsoft.AspNetCore.Routing;
|
||||||
|
global using Microsoft.EntityFrameworkCore;
|
||||||
|
global using Microsoft.Extensions.Configuration;
|
||||||
|
global using Microsoft.Extensions.DependencyInjection;
|
||||||
|
global using Microsoft.Extensions.Logging;
|
||||||
|
global using MyApi.Infrastructure.Persistence;
|
||||||
|
global using NSubstitute;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Concern | Where it lives | Rule |
|
||||||
|
|---------|---------------|------|
|
||||||
|
| DI wiring + endpoint mapping | `Program.cs` | Declarative only, no logic |
|
||||||
|
| API entry points (discover processes) | `Api/{Name}Endpoints.cs` | `IEndpointRouteBuilder` extension, routing + dispatch only |
|
||||||
|
| Business use case | `Features/{Name}/{UseCase}.cs` | Command/Query + Validator + Handler only |
|
||||||
|
| API request/response contract | Command/Query record + result record | No separate DTOs, no mapping |
|
||||||
|
| Pipeline behaviors | `Features/Common/Behaviors/` | `IPipelineBehavior<TRequest, TResponse>` |
|
||||||
|
| Shared exceptions | `Features/Common/Exceptions/` | Domain exception hierarchy |
|
||||||
|
| Database context + entity configs | `Infrastructure/Persistence/` | `AppDbContext` + `IEntityTypeConfiguration<T>` |
|
||||||
|
| EF Core migrations | `Infrastructure/Persistence/Migrations/` | Auto-generated by `dotnet ef` |
|
||||||
|
| External services | `Infrastructure/Services/` | Interface + implementation |
|
||||||
|
| Infra DI registration | `Infrastructure/Extensions/InfrastructureExtensions.cs` | `AddInfrastructure()` |
|
||||||
|
| Global usings | `GlobalUsings.cs` | `global using` |
|
||||||
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 |
|
||||||
|
|
@ -17,6 +17,9 @@
|
||||||
| `project-intelligence.md` | What and why | ⭐⭐⭐⭐ | Onboarding, understanding projects |
|
| `project-intelligence.md` | What and why | ⭐⭐⭐⭐ | Onboarding, understanding projects |
|
||||||
| `project-intelligence-management.md` | How to manage | ⭐⭐⭐ | Managing intelligence files |
|
| `project-intelligence-management.md` | How to manage | ⭐⭐⭐ | Managing intelligence files |
|
||||||
| `code-analysis.md` | Analysis approaches | ⭐⭐⭐ | Analyzing code, debugging |
|
| `code-analysis.md` | Analysis approaches | ⭐⭐⭐ | Analyzing code, debugging |
|
||||||
|
| `typescript.md` | Universal TypeScript patterns | ⭐⭐⭐⭐ | Writing/reviewing TypeScript code |
|
||||||
|
| `csharp.md` | Universal C# / .NET patterns | ⭐⭐⭐⭐ | Writing/reviewing C# code |
|
||||||
|
| `csharp-project-structure.md` | ASP.NET Core project structure (Minimal APIs, CQRS, EF Core + PostgreSQL) | ⭐⭐⭐⭐ | Starting or structuring a C# API project |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -26,6 +29,18 @@
|
||||||
1. Load `code-quality.md` (critical)
|
1. Load `code-quality.md` (critical)
|
||||||
2. Load `security-patterns.md` (high)
|
2. Load `security-patterns.md` (high)
|
||||||
|
|
||||||
|
**For TypeScript code**:
|
||||||
|
1. Load `typescript.md` (critical)
|
||||||
|
2. Load `code-quality.md` (high)
|
||||||
|
|
||||||
|
**For C# / .NET code**:
|
||||||
|
1. Load `csharp.md` (critical)
|
||||||
|
2. Load `code-quality.md` (high)
|
||||||
|
|
||||||
|
**For C# API project structure**:
|
||||||
|
1. Load `csharp-project-structure.md` (critical)
|
||||||
|
2. Load `csharp.md` (high)
|
||||||
|
|
||||||
**For testing**:
|
**For testing**:
|
||||||
1. Load `test-coverage.md` (critical)
|
1. Load `test-coverage.md` (critical)
|
||||||
2. Depends on: `code-quality.md`
|
2. Depends on: `code-quality.md`
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
📝 **Editable Agents** - Full control over AI behavior
|
📝 **Editable Agents** - Full control over AI behavior
|
||||||
👥 **Team-Ready** - Everyone uses the same patterns
|
👥 **Team-Ready** - Everyone uses the same patterns
|
||||||
|
|
||||||
**Multi-language:** TypeScript • Python • Go • Rust • Any language*
|
**Multi-language:** TypeScript • Python • Go • Rust • C# • Any language*
|
||||||
**Model Agnostic:** Claude • GPT • Gemini • Local models
|
**Model Agnostic:** Claude • GPT • Gemini • Local models
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -684,7 +684,7 @@ Build complete custom AI systems tailored to your domain in minutes. Interactive
|
||||||
A: Yes! Use Git Bash (recommended) or WSL.
|
A: Yes! Use Git Bash (recommended) or WSL.
|
||||||
|
|
||||||
**Q: What languages are supported?**
|
**Q: What languages are supported?**
|
||||||
A: Agents are language-agnostic and adapt based on your project files. Primarily tested with TypeScript/Node.js. Python, Go, Rust, and other languages are supported but less battle-tested. The context system works with any language.
|
A: Agents are language-agnostic and adapt based on your project files. Primarily tested with TypeScript/Node.js. C# / .NET is now supported with dedicated context files. Python, Go, Rust, and other languages are supported but less battle-tested. The context system works with any language.
|
||||||
|
|
||||||
**Q: Do I need to add context?**
|
**Q: Do I need to add context?**
|
||||||
A: No, but it's highly recommended. Without context, agents write generic code. With context, they write YOUR code.
|
A: No, but it's highly recommended. Without context, agents write generic code. With context, they write YOUR code.
|
||||||
|
|
@ -767,7 +767,7 @@ Check out our [**Project Board**](https://github.com/darrenhinde/OpenAgentsContr
|
||||||
- **Plugin System** - npm-based plugin architecture for easy distribution
|
- **Plugin System** - npm-based plugin architecture for easy distribution
|
||||||
- **Performance Improvements** - Faster agent execution and context loading
|
- **Performance Improvements** - Faster agent execution and context loading
|
||||||
- **Enhanced Context Discovery** - Smarter pattern recognition
|
- **Enhanced Context Discovery** - Smarter pattern recognition
|
||||||
- **Multi-language Support** - Better Python, Go, Rust support
|
- **Multi-language Support** - Better Python, Go, Rust, C# / .NET support
|
||||||
- **Team Collaboration** - Shared context and team workflows
|
- **Team Collaboration** - Shared context and team workflows
|
||||||
- **Documentation** - More examples, tutorials, and guides
|
- **Documentation** - More examples, tutorials, and guides
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ OpenCoder is a **specialized development agent** focused on complex coding tasks
|
||||||
|
|
||||||
**Key Characteristics:**
|
**Key Characteristics:**
|
||||||
- 🎯 **Specialized** - Deep focus on code quality and architecture
|
- 🎯 **Specialized** - Deep focus on code quality and architecture
|
||||||
- 🔧 **Multi-language** - Adapts to TypeScript, Python, Go, Rust, and more
|
- 🔧 **Multi-language** - Adapts to TypeScript, Python, Go, Rust, C#, and more
|
||||||
- 📐 **Plan-first** - Always proposes plans before implementation
|
- 📐 **Plan-first** - Always proposes plans before implementation
|
||||||
- 🏗️ **Modular** - Emphasizes clean architecture and separation of concerns
|
- 🏗️ **Modular** - Emphasizes clean architecture and separation of concerns
|
||||||
- ✅ **Quality-focused** - Includes testing, type checking, and validation
|
- ✅ **Quality-focused** - Includes testing, type checking, and validation
|
||||||
|
|
@ -101,7 +101,7 @@ Use **openagent** for:
|
||||||
- Technical debt reduction
|
- Technical debt reduction
|
||||||
|
|
||||||
### Quality Assurance
|
### Quality Assurance
|
||||||
- Type checking (TypeScript, Python, Go, Rust)
|
- Type checking (TypeScript, Python, Go, Rust, C#)
|
||||||
- Linting (ESLint, Pylint, etc.)
|
- Linting (ESLint, Pylint, etc.)
|
||||||
- Build validation
|
- Build validation
|
||||||
- Test execution
|
- Test execution
|
||||||
|
|
@ -190,6 +190,12 @@ OpenCoder adapts to the project's language automatically:
|
||||||
- Linting: `clippy`
|
- Linting: `clippy`
|
||||||
- Testing: `cargo test`
|
- Testing: `cargo test`
|
||||||
|
|
||||||
|
### C# / .NET
|
||||||
|
- Runtime: `dotnet run`
|
||||||
|
- Type checking: built-in (Roslyn compiler)
|
||||||
|
- Linting: `dotnet format`, Roslyn analyzers
|
||||||
|
- Testing: `dotnet test` (xUnit + Shouldly + NSubstitute)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Code Standards
|
## Code Standards
|
||||||
|
|
@ -384,6 +390,7 @@ OpenCoder adapts to your language:
|
||||||
- For Python: Pythonic patterns, type hints
|
- For Python: Pythonic patterns, type hints
|
||||||
- For Go: Idiomatic Go, interfaces
|
- For Go: Idiomatic Go, interfaces
|
||||||
- For Rust: Ownership, traits, Result types
|
- For Rust: Ownership, traits, Result types
|
||||||
|
- For C#: Minimal APIs, CQRS (MediatR), records, async/await, nullable reference types
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue