Files
baya-monorepo/archive/docs/rules/server/conventions.md
T
2026-08-02 20:01:31 +03:30

270 lines
10 KiB
Markdown

# 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<T>` / `IReadOnlyCollection<T>` 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
- `<Nullable>enable</Nullable>` 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<string> 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<IActionResult> 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<OperationResult<T>> Handle(MyQuery request, CancellationToken ct)
{
var entity = await _repository.GetAsync(request.Id, ct);
return OperationResult<T>.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<T>`** for hot paths (handlers, repositories); `Task<T>` 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<T>.NotFoundResult("User not found.");
// ❌ swallowing an exception into a generic failure
try { } catch { return OperationResult<T>.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<IUnitOfWork>()`.
- **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<Program>` 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<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[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
`<PackageReference>` 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.