Browse Source

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
Jani Similä 2 weeks ago
parent
commit
2fa39e97bd

+ 669 - 0
.opencode/context/core/standards/csharp-project-structure.md

@@ -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 - 0
.opencode/context/core/standards/csharp.md

@@ -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 |

+ 15 - 0
.opencode/context/core/standards/navigation.md

@@ -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`

+ 3 - 3
README.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
 
 

+ 9 - 2
docs/agents/opencoder.md

@@ -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
 
 
 ---
 ---