1
0
Fork 0
mirror of https://github.com/pnx/dotfiles synced 2026-10-14 20:26:48 +02:00

update opencode

This commit is contained in:
Henrik Hautakoski 2026-09-13 20:12:22 +02:00
parent c7cf88af98
commit 38989f191a
9 changed files with 2240 additions and 14 deletions

View file

@ -4,6 +4,7 @@ description: "Universal agent for answering queries, executing tasks, and coordi
mode: primary
temperature: 0.2
permission:
question: "allow"
bash:
"*": "ask"
"rm -rf *": "ask"

View file

@ -4,6 +4,7 @@ description: "Orchestration agent for complex coding, architecture, and multi-fi
mode: primary
temperature: 0.1
permission:
question: "allow"
bash:
"rm -rf *": "ask"
"sudo *": "deny"

View file

@ -4,28 +4,36 @@ description: Create well-formatted commits with conventional commit messages
# Commit Command
You are an AI agent that helps create well-formatted git commits with conventional commit messages, follow these instructions exactly. Always run and NEVER push the commit, you don't need to ask for confirmation unless there is a big issue or error.
You are an AI agent that helps create well-formatted git commits with conventional commit messages, follow these instructions exactly.
## Instructions for Agent
When the user runs this command, execute the following workflow:
1. **Analyze git status**:
1. **Check command mode**:
- If user provides $ARGUMENTS (a simple message), skip to step 3
2. **Run pre-commit validation**:
- Run appropriate tests
- If tests fails, ask user if they want to proceed anyway or fix issues first
3. **Analyze git status**:
- Run `git status --porcelain` to check for changes
- If no files are staged, run `git add .` to stage all modified files
- If files are already staged, proceed with only those files
2. **Analyze the changes**:
4. **Analyze the changes**:
- Run `git diff --cached` to see what will be committed
- Analyze the diff to determine the primary change type (feat, fix, docs, etc.)
- Identify the main scope and purpose of the changes
3. **Generate commit message**:
5. **Generate commit message**:
- Choose appropriate emoji and type from the reference below
- Create message following format: `<type>: <description>`
- Keep description concise, clear, and in imperative mood
- Show the proposed message to user for confirmation
4. **Execute the commit**:
6. **Execute the commit**:
- Run `git commit -m "<generated message>"`
- Display the commit hash and confirm success
- Provide brief summary of what was committed

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

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

View file

@ -0,0 +1,624 @@
<!-- Context: core/standards | Priority: critical | Version: 1.0 | Updated: 2026-02-16 -->
# Universal TypeScript Standards
**Purpose**: Universal TypeScript patterns applicable to any TypeScript project
**Scope**: Language-level patterns, not framework-specific
**Last Updated**: 2026-02-16
---
## Table of Contents
1. [Function Patterns](#1-function-patterns)
2. [Type Safety](#2-type-safety)
3. [Array Operations](#3-array-operations)
4. [Async Patterns](#4-async-patterns)
5. [Control Flow](#5-control-flow)
6. [Code Organization](#6-code-organization)
7. [Testing Principles](#7-testing-principles)
8. [Variable Naming](#8-variable-naming)
---
## 1. Function Patterns
### 1.1 Naming Convention
**Rule: Prefer single-word function names**
```typescript
// ✅ GOOD - Single-word names
export function create() {...}
export function fork() {...}
export function touch() {...}
export function get() {...}
export async function stream(input: StreamInput) {...}
// ✅ ACCEPTABLE - Multi-word only when necessary
export function isDefaultTitle(title: string) {...} // Boolean predicate
export function assertNotBusy(sessionID: string) {...} // Assertion pattern
export async function createNext(input) {...} // Version disambiguation
export async function resolvePromptParts(template) {...} // Complex operation needs clarity
// ❌ AVOID - Unnecessary multi-word names
function prepareJournal(dir: string) {} // Use: journal()
function getUserData(id: string) {} // Use: user()
function processFileContent(path) {} // Use: process()
```
### 1.2 Pure Functions
**Rule: Prefer pure functions when possible**
```typescript
// ✅ GOOD - Pure function
function calculateTotal(items: Item[]): number {
return items.reduce((sum, item) => sum + item.price, 0)
}
// ❌ AVOID - Side effects
let total = 0
function addToTotal(item: Item) {
total += item.price // Mutates external state
}
```
### 1.3 Function Composition
```typescript
// ✅ GOOD - Functional composition with pipes
const filtered = agents
.filter((a) => a.mode !== "primary")
.filter((a) => hasPermission(a, caller))
.map((a) => a.name)
// ✅ GOOD - Higher-order functions
export function withRetry<T>(fn: () => Promise<T>, maxRetries: number): Promise<T> {
return fn().catch((error) => {
if (maxRetries > 0) {
return withRetry(fn, maxRetries - 1)
}
throw error
})
}
```
---
## 2. Type Safety
### 2.1 TypeScript Types
**Rule: Use TypeScript's type system, avoid `any`**
```typescript
// ✅ GOOD - Explicit types
interface User {
id: string
name: string
email: string
}
function getUser(id: string): User {
// Implementation
}
// ❌ AVOID - any type
function getUser(id: any): any {
// Loses all type safety
}
```
### 2.2 Type Inference
**Rule: Let TypeScript infer when obvious**
```typescript
// ✅ GOOD - Inference works
const count = 42 // TypeScript knows this is number
const users = await fetchUsers() // Type inferred from return type
// ❌ AVOID - Redundant annotations
const count: number = 42
const users: User[] = await fetchUsers()
```
### 2.3 Type Guards
**Rule: Use type guards for runtime type checking**
```typescript
// ✅ GOOD - Type guard
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value
)
}
// Usage
if (isUser(data)) {
console.log(data.name) // TypeScript knows data is User
}
```
### 2.4 Avoid Any
**Rule: Use `unknown` instead of `any` when type is truly unknown**
```typescript
// ✅ GOOD - unknown requires type checking
function processData(data: unknown) {
if (typeof data === "string") {
return data.toUpperCase()
}
throw new Error("Invalid data")
}
// ❌ AVOID - any bypasses type checking
function processData(data: any) {
return data.toUpperCase() // No compile-time safety
}
```
---
## 3. Array Operations
### 3.1 Functional Methods (Preferred)
**Rule: Prefer map/filter/reduce over for-loops**
```typescript
// ✅ GOOD - Functional chain with type inference
const files = messages
.flatMap((x) => x.parts)
.filter((x): x is Patch => x.type === "patch")
.flatMap((x) => x.files)
.map((x) => path.relative(worktree, x))
// ✅ GOOD - Parallel async operations
const results = await Promise.all(
toolCalls.map(async (call) => {
return executeCall(call)
}),
)
// ✅ GOOD - Reduce for aggregation
const totalAdditions = diffs.reduce((sum, x) => sum + x.additions, 0)
// ✅ GOOD - Unique values
const uniqueNames = Array.from(new Set(items.map((x) => x.name)))
// ✅ GOOD - Sorting
const sorted = items.toSorted((a, b) => a.timestamp - b.timestamp)
```
### 3.2 For-Loops (When Necessary)
**Rule: Use for-loops only for:**
1. Algorithm complexity (DP, graph traversal)
2. Early exit requirements
3. Sequential side effects
4. Performance-critical iteration
```typescript
// ✅ GOOD - Early exit
const patches = []
for (const msg of all) {
if (msg.info.id === targetID) break
for (const part of msg.parts) {
if (part.type === "patch") {
patches.push(part)
}
}
}
// ✅ GOOD - Sequential mutations
for (const key of Object.keys(tools)) {
if (disabled.has(key)) {
delete tools[key]
}
}
```
### 3.3 Type Guards on Filter
**Rule: Use type guards to maintain type inference downstream**
```typescript
// ✅ GOOD - Type guard preserves type information
const patches = messages
.flatMap((msg) => msg.parts)
.filter((part): part is PatchPart => part.type === "patch")
// patches is now PatchPart[], not Part[]
// ❌ BAD - Loses type information
const patches = messages
.flatMap((msg) => msg.parts)
.filter((part) => part.type === "patch")
// patches is still Part[], requires casting later
```
---
## 4. Async Patterns
### 4.1 Parallel Execution (Default Pattern)
**Rule: Use `Promise.all` for independent operations**
```typescript
// ✅ GOOD - Parallel independent operations
const [language, cfg, provider, auth] = await Promise.all([
getLanguage(model),
getConfig(),
getProvider(model.providerID),
getAuth(model.providerID),
])
// ✅ GOOD - Parallel array processing
const results = await Promise.all(
items.map(async (item) => {
return processItem(item)
}),
)
// ❌ BAD - Sequential when independent
const language = await getLanguage(model)
const cfg = await getConfig() // Could run in parallel!
const provider = await getProvider(model.providerID)
```
### 4.2 Sequential Operations
**Rule: Chain when operations depend on previous results**
```typescript
// ✅ GOOD - Sequential dependency chain
const session = await createSession({ title: "New" })
const message = await addMessage(session.id, { content: "Hello" })
const response = await processMessage(message.id)
// ✅ GOOD - Promise chain for clarity
const result = await createSession({ title: "New" })
.then((session) => addMessage(session.id, { content: "Hello" }))
.then((message) => processMessage(message.id))
```
### 4.3 Error Handling in Async
**Rule: Prefer `.catch()` over try/catch when possible**
```typescript
// ✅ GOOD - Catch at call site
const result = await operation().catch((error) => {
console.error("Operation failed", error)
return defaultValue
})
// ✅ GOOD - Promise.all with error handling
const results = await Promise.all(
items.map(async (item) => {
return processItem(item).catch((error) => {
console.error("Item failed", { item, error })
return null
})
}),
)
// ✅ ACCEPTABLE - try/catch for multiple operations
try {
const session = await createSession(input)
await addMessage(session.id, message)
await publishEvent({ session })
return session
} catch (error) {
console.error("Session creation failed", error)
throw error
}
// ❌ AVOID - try/catch for single operation
try {
const result = await operation()
return result
} catch (error) {
console.error(error)
throw error
}
// Better:
const result = await operation().catch((error) => {
console.error(error)
throw error
})
```
---
## 5. Control Flow
### 5.1 Early Returns
**Rule: Avoid `else` statements, use early returns**
```typescript
// ✅ GOOD - Early returns
function getStatus(session: Session) {
if (!session) return "not_found"
if (session.busy) return "busy"
if (session.error) return "error"
return "ready"
}
async function process(id: string) {
const session = await getSession(id)
if (!session) return { error: "Not found" }
const result = await execute(session)
if (!result.success) return { error: result.message }
return { data: result.data }
}
// ❌ BAD - Else statements
function getStatus(session: Session) {
if (!session) {
return "not_found"
} else {
if (session.busy) {
return "busy"
} else {
if (session.error) {
return "error"
} else {
return "ready"
}
}
}
}
```
### 5.2 Guard Clauses
```typescript
// ✅ GOOD - Guard clauses at function start
async function updateSession(id: string, data: UpdateData) {
if (!id) throw new Error("ID required")
if (!data) throw new Error("Data required")
if (data.title && data.title.length > 100) throw new Error("Title too long")
// Main logic here
const session = await getSession(id)
await update(id, data)
return session
}
```
### 5.3 Switch Statements
**Rule: Use exhaustive switch with default case**
```typescript
// ✅ GOOD - Exhaustive switch
function handleEvent(event: Event) {
switch (event.type) {
case "start":
return handleStart(event)
case "update":
return handleUpdate(event)
case "complete":
return handleComplete(event)
default:
const _exhaustive: never = event
throw new Error(`Unhandled event type: ${(event as any).type}`)
}
}
```
---
## 6. Code Organization
### 6.1 Import Order
**Rule: Organize imports by source**
```typescript
// ✅ GOOD - Organized imports
// 1. Node built-ins
import path from "path"
import fs from "fs/promises"
// 2. External packages
import { z } from "zod"
import express from "express"
// 3. Internal modules
import { User } from "./types"
import { getConfig } from "./config"
```
### 6.2 Naming Conventions
```typescript
// ✅ GOOD - Clear naming
const session = await getSession(id)
const user = await getCurrentUser()
const messages = await getMessages({ sessionID })
// ❌ BAD - Unnecessary verbosity
const currentSession = await getSession(id)
const currentlyAuthenticatedUser = await getCurrentUser()
const sessionMessagesList = await getMessages({ sessionID })
// ✅ GOOD - Multi-word when single word is ambiguous
const sessionID = params.id
const userAgent = req.headers["user-agent"]
const maxRetries = config.retries
```
### 6.3 File Structure
**Rule: One primary export per file**
```typescript
// user.ts
export interface User {
id: string
name: string
}
export async function getUser(id: string): Promise<User> {
// Implementation
}
export async function createUser(data: CreateUserInput): Promise<User> {
// Implementation
}
```
---
## 7. Testing Principles
### 7.1 Test Structure
**Rule: Follow Arrange-Act-Assert pattern**
```typescript
// ✅ GOOD - AAA pattern
test("creates user with valid data", async () => {
// Arrange
const userData = { name: "Alice", email: "alice@example.com" }
// Act
const user = await createUser(userData)
// Assert
expect(user.name).toBe("Alice")
expect(user.email).toBe("alice@example.com")
})
```
### 7.2 Coverage Goals
**Rule: Test both success and failure cases**
```typescript
// ✅ GOOD - Both positive and negative tests
describe("createUser", () => {
test("creates user with valid data", async () => {
const user = await createUser({ name: "Alice", email: "alice@example.com" })
expect(user).toBeDefined()
})
test("throws error with invalid email", async () => {
await expect(
createUser({ name: "Alice", email: "invalid" })
).rejects.toThrow("Invalid email")
})
})
```
### 7.3 Mock External Dependencies
**Rule: Mock all external dependencies**
```typescript
// ✅ GOOD - Mocked dependencies
test("fetches user data", async () => {
const mockFetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve({ id: "1", name: "Alice" })
})
global.fetch = mockFetch
const user = await fetchUser("1")
expect(user.name).toBe("Alice")
})
```
---
## 8. Variable Naming
### 8.1 Variable Declaration
**Rule: Prefer `const` over `let`**
```typescript
// ✅ GOOD - Immutable with ternary
const foo = condition ? 1 : 2
const result = await (isValid ? processValid() : processInvalid())
// ❌ BAD - Reassignment
let foo
if (condition) {
foo = 1
} else {
foo = 2
}
// ✅ GOOD - Early return instead of reassignment
function getValue(condition: boolean) {
if (condition) return 1
return 2
}
// ✅ ACCEPTABLE - let when mutation is necessary
let accumulator = 0
for (const item of items) {
accumulator += item.value
}
```
### 8.2 Destructuring
**Rule: Avoid unnecessary destructuring, preserve context with dot notation**
```typescript
// ✅ GOOD - Preserve context
function process(session: Session) {
console.log("processing", { id: session.id, title: session.title })
return {
id: session.id,
status: session.status,
owner: session.owner
}
}
// ❌ BAD - Loses context, harder to read
function process(session: Session) {
const { id, title, status, owner } = session
console.log("processing", { id, title })
return { id, status, owner }
}
// ✅ ACCEPTABLE - Destructuring when improving readability
function renderUser({ name, email, avatar }: User) {
return `<div>${name} (${email})</div>`
}
// ✅ ACCEPTABLE - Destructuring array returns
const [language, cfg, provider] = await Promise.all([...])
```
---
## Related Standards
- **OpenCode TypeScript**: `/home/pnx/.config/opencode/context/openagents-repo/standards/opencode-typescript.md` (framework-specific patterns)
- **Code Quality**: `/home/pnx/.config/opencode/context/core/standards/code-quality.md` (general quality standards)
- **Test Coverage**: `/home/pnx/.config/opencode/context/core/standards/test-coverage.md` (testing standards)
---
**Version**: 1.0.0
**Last Updated**: 2026-02-16
**Maintainer**: OpenAgents Control Team

View file

@ -15,11 +15,30 @@
"color": "secondary",
},
},
"mcp": {
"linear": {
"type": "remote",
"url": "https://mcp.linear.app/mcp",
"enabled": true
"mcp": { },
"permission": {
"bash": {
"*": "ask",
"php": "allow",
"php *": "allow",
"cat": "allow",
"cat *": "allow",
"ls": "allow",
"ls *": "allow",
"git": "allow",
"git *": "allow",
"git push": "deny",
"git push *": "deny"
},
"read": {
"*": "ask",
"~/.config/opencode": "allow",
"~/.config/opencode/**": "allow"
},
"external_directory": {
"*": "ask",
"~/.config/opencode": "allow",
"~/.config/opencode/**": "allow"
}
}
}

View file

@ -72,14 +72,14 @@ find_project_root() {
local dir
dir="$(pwd)"
while [ "$dir" != "/" ]; do
if [ -d "$dir/.git" ] || [ -f "$dir/package.json" ]; then
if [ -e "$dir/.git" ] || [ -f "$dir/package.json" ]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
pwd
return 1
return 0
}
# Handle help

View file

@ -1,4 +1,5 @@
#!/usr/bin/env npx ts-node
/// <reference types="node" />
/**
* Task Management CLI
*
@ -19,8 +20,8 @@
* .tmp/tasks/completed/{feature-slug}/
*/
const fs = require('fs');
const path = require('path');
import * as fs from "node:fs";
import * as path from "node:path";
// Find project root (look for .git or package.json)
function findProjectRoot(): string {