10 KiB
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
readonlyunless 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
statusgets a private setter and cohesive transition methods — see persistence.md §5. - Avoid
staticmutable 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 returningnullfrom a handler. - Never
null!unless you can prove the value cannot be null and the compiler cannot.
Use the language
// 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.
3. Routing
All URL segments are snake_case. SnakeCaseParameterTransformer (Baya.WebFramework/Routing/) is
registered globally via RouteTokenTransformerConvention and converts [controller] and [action] tokens
automatically.
// ✅ 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 §4.
4. Async / await
// ✅ 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
CancellationTokenand passes it downstream — including intoSaveChangesAsync(ct)andsender.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. Useasync Taskeven 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
// ✅ 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 §9: structured templates, no PII or secrets, correct level.
6. Validation
- Every command that accepts user input needs a FluentValidation validator.
ValidateCommandBehaviorruns it automatically before the handler, andRegisterValidatorsAsServices()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 §3.
7. Mapping — Mapster
- Use the injected
IMapperfor entity↔DTO mapping in handlers. - Register type-adapter configs in
Program.csviaTypeAdapterConfig.GlobalSettings.Scan(...); add new assemblies containing mapping configs there. - Never write manual mapping code where Mapster can infer it. Only write a custom
TypeAdapterConfigwhen 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
[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.
NSubstitutefor mocking:Substitute.For<IUnitOfWork>().- Persistence tests use the in-memory SQLite context from
Baya.Tests.Setuprather 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:
- Happy path — an authenticated request returns 200 with the right body shape.
- Unauthenticated — returns 401.
- Validation failure — returns 400 with field-level error detail.
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 fromProgram.cs. No inline DI registration inProgram.cs. - Lifetimes: Singleton for stateless, thread-safe services (
IHttpContextAccessor,IFieldEncryptor— which must be a singleton, see 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 addVersion=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/orValidators/. - 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.cspartials, which split a Development-only seeder by domain. Program.csstays 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.
Known pre-existing warnings that must not be fixed unless a task says so: shared/git-and-gates.md §5.