270 lines
10 KiB
Markdown
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.
|