# Server C# conventions Style, types, naming, async, error handling and tests. The successor to `server/CONVENTIONS.md`. > Last verified: 2026-07-30 against commit `d3ec723`. When in doubt, ask: *would a senior engineer approve this diff without comment?* --- ## 1. Use the right type for the job | Scenario | Use | | --- | --- | | Request / response / DTO | `record` — immutable, value semantics | | Domain entity | `class` — mutable state, **encapsulated** | | Shared small value | `readonly record struct` | | Handler, service | `sealed class` | ### Immutability and safety - Mark fields `readonly` unless mutation is genuinely needed. - Prefer `IReadOnlyList` / `IReadOnlyCollection` in signatures unless the caller must mutate. - **Never expose a public setter on an entity.** Use methods or the constructor. A lifecycle `status` gets a private setter and cohesive transition methods — see [persistence.md](persistence.md) §5. - Avoid `static` mutable state. ### Null handling - `enable` in any new project. - Guard clauses at the entry point; don't scatter null checks through a method. - Prefer `OperationResult.NotFoundResult(...)` over returning `null` from a handler. - **Never `null!`** unless you can prove the value cannot be null and the compiler cannot. ### Use the language ```csharp // primary constructor (C# 12) public sealed class OrderHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<…> { } // switch expression over an if/else chain var label = status switch { OrderStatus.Pending => "Pending", OrderStatus.Shipped => "Shipped", OrderStatus.Cancelled => "Cancelled", _ => throw new ArgumentOutOfRangeException(nameof(status)), }; // pattern matching if (result is { IsSuccess: false, IsNotFound: true }) return NotFound(); // collection expressions (C# 12) List tags = ["new", "sale"]; ``` --- ## 2. Naming | Kind | Convention | Example | | --- | --- | --- | | Class, record, interface | PascalCase | `OrderHandler`, `IOrderRepository` | | Method | PascalCase | `GetUserOrdersAsync` | | Parameter, local | camelCase | `orderId`, `userEmail` | | Private field | `_camelCase` | `_unitOfWork` | | Constant | PascalCase | `MaxRetryCount` | | Generic type parameter | `T`, or descriptive `TEntity` | | | Command | `{Verb}{Noun}Command` | `CreateOrderCommand` | | Query | `{Verb}{Noun}Query` | `GetUserOrdersQuery` | | Handler | `{RequestName}Handler` | `CreateOrderCommandHandler` | | Result DTO | `{RequestName}Result` | `CreateOrderCommandResult` | No abbreviations unless universally understood (`dto`, `id`, `url`). No Hungarian notation (`strName`, `intCount`). The `Baya.*` prefix is project naming, not the brand — see [shared/naming.md](../shared/naming.md). --- ## 3. Routing All URL segments are `snake_case`. `SnakeCaseParameterTransformer` (`Baya.WebFramework/Routing/`) is registered globally via `RouteTokenTransformerConvention` and converts `[controller]` and `[action]` tokens automatically. ```csharp // ✅ the transformer converts MyFeature → my_feature, GetBySlug → get_by_slug [Route("api/v{version:apiVersion}/[controller]")] public sealed class MyFeatureController : BaseController { [HttpGet("[action]")] public Task GetBySlug(…) { } } // ❌ hardcoded segments bypass the transformer and escape snake_case enforcement [Route("api/v{version:apiVersion}/MyFeature")] [HttpGet("GetBySlug")] ``` **If a method name doesn't read cleanly as a URL, rename the method.** Don't hardcode the route string — it also breaks the dynamic-permission key, which is derived from the same route values. The controller skeleton and authorization table are in [cqrs.md](cqrs.md) §4. --- ## 4. Async / await ```csharp // ✅ async all the way — no .Result, no .Wait() public async ValueTask> Handle(MyQuery request, CancellationToken ct) { var entity = await _repository.GetAsync(request.Id, ct); return OperationResult.SuccessResult(_mapper.Map(entity)); } // ❌ blocks the thread, risks deadlock var result = _repository.GetAsync(id).Result; // ❌ fire and forget with no error handling _ = DoSomethingAsync(); ``` - **Every public async method accepts a `CancellationToken` and passes it downstream** — including into `SaveChangesAsync(ct)` and `sender.Send(command, ct)`. - Use **`ValueTask`** for hot paths (handlers, repositories); `Task` for rarely-called or always-async methods. - **Never `async void`** — it swallows exceptions. Use `async Task` even for an event-like callback. - **Do not add `.ConfigureAwait(false)`** in this ASP.NET Core app. It is unnecessary here and adds noise. --- ## 5. Error handling and logging ```csharp // ✅ expected failure — return, don't throw if (user is null) return OperationResult.NotFoundResult("User not found."); // ❌ swallowing an exception into a generic failure try { … } catch { return OperationResult.FailureResult(…); } ``` The global `ExceptionHandler` middleware catches unhandled exceptions and logs them. **Do not add a try/catch for unknown exceptions in a handler** — let them propagate. Catch only what you can actually handle. Logging rules are in [identity.md](identity.md) §9: structured templates, no PII or secrets, correct level. --- ## 6. Validation - Every command that accepts user input needs a FluentValidation validator. `ValidateCommandBehavior` runs it automatically before the handler, and `RegisterValidatorsAsServices()` registers them. - **Validate at the boundary** — the command or query — not deep in the domain or a repository. - **Never validate a route-supplied id in the body command.** See [cqrs.md](cqrs.md) §3. --- ## 7. Mapping — Mapster - Use the injected `IMapper` for entity↔DTO mapping **in handlers**. - Register type-adapter configs in `Program.cs` via `TypeAdapterConfig.GlobalSettings.Scan(...)`; add new assemblies containing mapping configs there. - Never write manual mapping code where Mapster can infer it. Only write a custom `TypeAdapterConfig` when shapes genuinely diverge. - **Mapping happens in the handler after the DB query**, never in the repository — the repository projects. --- ## 8. Testing ### Arrange — Act — Assert, always ```csharp [Fact] public async Task CreateOrder_ValidCommand_ReturnsSuccess() { // Arrange var command = new CreateOrderCommand(UserId: 1, Items: [new(ProductId: 5, Quantity: 2)]); var handler = new CreateOrderCommandHandler(_unitOfWork, _mapper); // Act var result = await handler.Handle(command, CancellationToken.None); // Assert result.IsSuccess.Should().BeTrue(); result.Result.Should().NotBeNull(); } ``` - **Test the handler directly**, not the controller — controllers are thin wrappers. - **`NSubstitute`** for mocking: `Substitute.For()`. - **Persistence tests use the in-memory SQLite context** from `Baya.Tests.Setup` rather than mocking the DB. - Name tests `{MethodUnderTest}_{Scenario}_{ExpectedOutcome}`. - One assertion *concept* per test. Multiple `.Should()` calls are fine if they verify the same outcome. - **Don't test EF internals** (tracking, migrations) — test behaviour through the handler. ### Integration tests — the HTTP pipeline Handler tests leave the whole HTTP stack untested: routing, the auth pipeline, middleware, and the `OperationResult → IActionResult` translation. **Each feature area needs at least one `WebApplicationFactory` test** in `Baya.Test.Api` (environment `Testing`, in-memory SQLite) covering: 1. **Happy path** — an authenticated request returns 200 with the right body shape. 2. **Unauthenticated** — returns 401. 3. **Validation failure** — returns 400 with field-level error detail. ```csharp public class MyFeatureApiTests(WebApplicationFactory factory) : IClassFixture> { [Fact] public async Task GetSomething_Authenticated_Returns200() { var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", TestTokens.ValidAdminToken); var response = await client.GetAsync("/api/v1/my_feature/get_something"); response.StatusCode.Should().Be(HttpStatusCode.OK); } } ``` The recurring-job scheduler is **dormant under `Testing`**, so a background tick can't make an integration test flaky. --- ## 9. Service registration - Every new infrastructure service gets an extension method in that project's `ServiceConfiguration/` folder, called from `Program.cs`. **No inline DI registration in `Program.cs`.** - Lifetimes: **Singleton** for stateless, thread-safe services (`IHttpContextAccessor`, `IFieldEncryptor` — which *must* be a singleton, see [identity.md](identity.md) §3); **Scoped** for per-request services (repositories, `DbContext`, handlers); **Transient** for lightweight stateless ones (validators, transformers). - **All NuGet versions live only in `Directory.Packages.props`.** Never add `Version=` to a `` in a `.csproj`. --- ## 10. Code organisation - **One type per file**, file name matching the type name exactly. - Handlers and validators live in the **same feature folder** — not in a root `Handlers/` or `Validators/`. - A file over **~150 lines** usually means mixed concerns. Consider splitting it. - **Partial classes are only for generated code** (source generators, EF scaffolding) — and the one deliberate exception, `DemoLifecycleSeeder`'s `.Money.cs`/`.Social.cs` partials, which split a Development-only seeder by domain. - **`Program.cs` stays an orchestrator** — extension-method calls only, no logic. --- ## 11. No unused code, and comment the *why* Both are shared rules with real teeth on this side: the gate is **zero new warnings**, and `CS0168` / `CS0219` / `CS0169` / `IDE0005` all surface dead code. **Delete it — don't `#pragma warning disable` it.** The one exception: a parameter that must exist to satisfy an interface or delegate signature but is genuinely unused. Keep it, name it conventionally, and add a one-line `// why` only if the reason isn't obvious. Full rules, with examples of a comment that earns its place: [shared/code-quality.md](../shared/code-quality.md). Known pre-existing warnings that must **not** be fixed unless a task says so: [shared/git-and-gates.md](../shared/git-and-gates.md) §5.