17 KiB
Server Coding Conventions
Rules enforced for all code in server/. These represent the standards expected from a senior .NET engineer. Read alongside CLAUDE.md.
When in doubt, ask: would a senior engineer approve this diff without comment?
1. Routing
Rule: all URL segments must be snake_case
SnakeCaseParameterTransformer (Baya.WebFramework/Routing/) is registered globally via RouteTokenTransformerConvention. It converts [controller] and [action] tokens automatically.
// ✅ transformer converts MyFeature → my_feature, GetBySlug → get_by_slug
[Route("api/v{version:apiVersion}/[controller]")]
public class MyFeatureController : BaseController
{
[HttpGet("[action]")]
public Task<IActionResult> GetBySlug(...) { }
}
// ❌ bypasses transformer — hardcoded segment escapes 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.
2. C# code quality
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 |
Language features — use them
// ✅ primary constructor (C# 12)
public sealed class OrderHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<...> { }
// ✅ switch expression over if/else chains
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"];
Immutability & safety
- Mark fields
readonlyunless mutation is genuinely needed. - Prefer
IReadOnlyList<T>/IReadOnlyCollection<T>overList<T>in signatures unless the caller needs to mutate. - Never expose public setters on entities — use methods or constructors.
- Avoid
staticmutable state.
Null handling
- Enable
<Nullable>enable</Nullable>in any new project you create. - Use guard clauses at the entry point; don't scatter null checks throughout.
- Prefer returning
OperationResult.NotFoundResult(...)over returningnullfrom handlers. - Never use
null!(null-forgiving) unless you can prove the value cannot be null and the compiler cannot.
Naming
| Kind | Convention | Example |
|---|---|---|
| Class, record, interface | PascalCase | OrderHandler, IOrderRepository |
| Method | PascalCase | GetUserOrdersAsync |
| Parameter, local variable | camelCase | orderId, userEmail |
| Private field | _camelCase |
_unitOfWork |
| Constant | PascalCase | MaxRetryCount |
| Generic type param | 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).
3. Async / await
// ✅ always async all the way — no .Result or .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;
// ✅ pass CancellationToken through every async call
await _db.SaveChangesAsync(cancellationToken);
// ❌ fire and forget with no error handling
_ = DoSomethingAsync();
- Every public async method must accept
CancellationTokenand pass it downstream. - Use
ValueTask<T>for hot paths (handlers, repositories). UseTask<T>for rarely-called or always-async methods. - Never use
async void— it swallows exceptions. Useasync Taskeven for event-like callbacks. - Do not add
.ConfigureAwait(false)in this ASP.NET Core app — it's unnecessary and adds noise.
4. Controllers
Every controller must follow this skeleton:
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Display(Description = "One-line description shown in Swagger")]
[Authorize(ConstantPolicies.DynamicPermission)] // or [Authorize], or omit for public
public sealed class MyFeatureController(ISender sender) : BaseController
{
[HttpGet("[action]")]
[ProducesOkApiResponseType<MyQueryResult>]
public async Task<IActionResult> GetSomething(CancellationToken ct)
=> OperationResult(await sender.Send(new MyQuery(), ct));
[HttpPost("[action]")]
[ProducesOkApiResponseType<MyCommandResult>]
public async Task<IActionResult> CreateSomething(MyCommand command, CancellationToken ct)
=> OperationResult(await sender.Send(command, ct));
}
Rules:
sealed— controllers are not designed for inheritance beyondBaseController.- Inject
ISendervia primary constructor — notIMediator. - Never call
Ok(),BadRequest(),NotFound()directly — alwaysbase.OperationResult(result). - Keep controller methods thin: one
Send, oneOperationResult. No business logic in controllers. - Use
[Display(Description = "...")]so NSwag generates meaningful Swagger tags. - Pass
CancellationTokenfrom the action intosender.Send(...).
Authorization levels — use the narrowest that fits
| Attribute | When |
|---|---|
| (none) | Truly public (health check, metrics) |
[Authorize] |
Any authenticated user |
[Authorize(ConstantPolicies.DynamicPermission)] |
Role/claim-gated admin action |
[RequireTokenWithoutAuthorization] |
Token must be present but may be expired (e.g. refresh) |
Apply at the controller level for uniform policy; override at the action level only for exceptions.
5. CQRS — feature structure
Features/<Area>/
├── Commands/<VerbNoun>Command/
│ ├── <Name>Command.cs record Command(…) : IRequest<OperationResult<T>>
│ ├── <Name>Command.Handler.cs internal sealed class Handler : IRequestHandler<…>
│ └── <Name>Command.Validator.cs AbstractValidator<Command> (omit if no validation needed)
└── Queries/<VerbNoun>Query/
├── <Name>Query.cs record Query(…) : IRequest<OperationResult<T>>
├── <Name>Query.Handler.cs internal sealed class Handler : IRequestHandler<…>
└── <Name>Query.Result.cs record Result(…) ← the DTO returned
- Request types are
record— immutable. - Handlers are
internal sealed— they are never used outside the Application layer. - Handlers must not throw for expected failures. Use
OperationResultfactory methods:OperationResult<T>.SuccessResult(value)— happy pathOperationResult<T>.FailureResult(errors)— validation / business rule failureOperationResult<T>.NotFoundResult(message)— entity not found
- Only one handler per request type — no conditional dispatch.
- Contracts the handler depends on go in
Application/Contracts/as interfaces; implementations live in Infrastructure.
6. Persistence — EF Core rules
// ✅ project to DTO in the query — never load full entity for read operations
var dto = await _db.Orders
.AsNoTracking()
.Where(o => o.UserId == userId)
.Select(o => new OrderResult(o.Id, o.Status, o.CreatedAt))
.ToListAsync(ct);
// ❌ loads entire entity graph then maps in memory — N+1 risk
var orders = await _db.Orders.Include(o => o.Lines).ToListAsync();
var dtos = _mapper.Map<List<OrderResult>>(orders);
Rules:
- Always use
AsNoTracking()on read-only queries. - Always project with
Select()in queries — never hydrate full entities just to map them. - Never load more than you need. Pagination is mandatory for any unbounded list:
Skip/Take. - Use
Includeonly in command handlers where you need to mutate the aggregate and need navigation properties loaded. - Access the DB through
IUnitOfWorkin Application-layer handlers.ApplicationDbContextis only referenced directly inside Infrastructure. - Commit once per command at the end:
await _unitOfWork.CommitAsync(ct). - One
IEntityTypeConfiguration<T>per entity, inPersistence/Configuration/<Area>Config/. - Migrations command:
dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api
Soft delete
Every entity that supports soft delete must declare a global EF query filter in its IEntityTypeConfiguration<T>:
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasQueryFilter(o => !o.IsDeleted);
}
Without this filter, soft-deleted records appear in every query that doesn't explicitly filter them — a silent data leak. Never add Where(x => !x.IsDeleted) in individual queries; the filter makes it automatic and auditable.
Entity audit fields
When designing or extending an entity, include audit fields alongside timestamps:
| Field | Type | Set by |
|---|---|---|
CreatedAt |
DateTimeOffset |
SaveChangesAsync override (on Add) |
ModifiedAt |
DateTimeOffset |
SaveChangesAsync override (on Update) |
CreatedById |
int? |
SaveChangesAsync override via ICurrentUser |
ModifiedById |
int? |
SaveChangesAsync override via ICurrentUser |
Wire ICurrentUser (HTTP context accessor wrapped in an interface, registered Scoped) into ApplicationDbContext so the context can stamp who made the change without handlers needing to pass it explicitly. Audit fields cannot be backfilled retroactively — design them in from the start.
7. Validation
- All commands that accept user input need a
FluentValidationvalidator. TheValidateCommandBehaviorpipeline behavior runs it automatically before the handler. - Validators are registered automatically via
RegisterValidatorsAsServices()inProgram.cs. - Validate at the boundary (command/query), not deep in the domain or repositories.
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.UserId).GreaterThan(0);
RuleFor(x => x.Items).NotEmpty().WithMessage("Order must have at least one item.");
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.ProductId).GreaterThan(0);
item.RuleFor(i => i.Quantity).InclusiveBetween(1, 100);
});
}
}
8. Mapping — Mapster rules
- Use
IMapper(injected via DI) for all entity↔DTO mapping in handlers. - Register type adapter configs in
Program.csviaTypeAdapterConfig.GlobalSettings.Scan(...). Add new assemblies that contain mapping configs there. - Never write manual mapping code when Mapster can infer it — only write custom
TypeAdapterConfigwhen shapes diverge. - Mapping happens in the handler after the DB query, not in the repository.
9. Error handling & logging
// ✅ expected failure — use OperationResult, do not throw
if (user is null)
return OperationResult<T>.NotFoundResult("User not found.");
// ✅ unexpected failure — let it propagate; ExceptionHandler middleware catches it
// Log at the point you catch unexpected exceptions (ExceptionHandler logs automatically)
// ❌ swallowing exceptions
try { ... } catch { return OperationResult<T>.FailureResult(...); }
// ✅ structured logging — never interpolate sensitive data
_logger.LogInformation("Order {OrderId} created for user {UserId}", order.Id, userId);
// ❌ logs PII / secrets
_logger.LogInformation($"Token for {user.Email}: {token}");
- Log at the correct level:
Debugfor trace info,Informationfor meaningful events,Warningfor recoverable issues,Errorfor unexpected failures. - Never log passwords, tokens, secrets, or full PII (email is borderline — use
userIdin logs instead). - The global
ExceptionHandlermiddleware catches unhandled exceptions — do not add try/catch in handlers for unknown exceptions; let them propagate.
10. 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.
- Use
NSubstitutefor mocking:Substitute.For<IUnitOfWork>(). - Integration tests use
Baya.Tests.Setupwhich provides an in-memory SQLite context — prefer this over mocking the DB for persistence tests. - Name tests:
{MethodUnderTest}_{Scenario}_{ExpectedOutcome}. - One assertion concept per test. Multiple
.Should()calls are fine if they all verify the same outcome. - Do not test EF internals (entity tracking, migrations) — test behavior through the handler.
Integration tests — HTTP pipeline coverage
Handler tests verify business logic but leave the entire HTTP stack (routing, auth pipeline, middleware, OperationResult → IActionResult translation) untested. Each feature area must have at least one WebApplicationFactory<Program>-based test covering:
- Happy path — authenticated request returns 200 with correct body shape.
- Unauthenticated request 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);
}
}
Place these tests in a dedicated Baya.Test.Api project so they can run against the full Program.cs wiring.
11. Security rules
- Never hardcode secrets. Keys, connection strings, and tokens come from
appsettings.*.json/ user-secrets / environment variables, bound to typed settings classes. SecretKeyandEncryptkey(inIdentitySettings) must be set in environment-specific config, never inappsettings.jsoncommitted to the repo.- Always validate all external input with FluentValidation before processing.
- EF Core parameterizes queries automatically — never concatenate raw SQL.
- If you must use raw SQL, use
FromSqlInterpolated(parameterized), neverFromSqlRawwith user data. - Respect the principle of least privilege: grant
[Authorize(ConstantPolicies.DynamicPermission)]to admin actions, not just[Authorize]. - Auth and OTP endpoints must be rate-limited. Use ASP.NET Core's built-in
AddRateLimiter(no extra NuGet package needed). Apply at minimum to: login, OTP request, and token refresh. A fixed window or token bucket policy per IP is the baseline. Register the limiter in aServiceConfiguration/extension; addapp.UseRateLimiter()beforeapp.UseAuthentication()inProgram.cs.
12. Service registration
- Every new infrastructure service gets an extension method in the project's
ServiceConfiguration/folder. - That extension is called from
Program.cs— no inline DI registration inProgram.cs. - Register with the correct lifetime:
- Singleton — stateless, thread-safe services (e.g.
IHttpContextAccessor) - Scoped — per-request services (repositories,
DbContext, handlers) - Transient — lightweight, stateless (validators, transformers)
- Singleton — stateless, thread-safe services (e.g.
- All NuGet versions live in
Directory.Packages.props. Never addVersion=to a<PackageReference>in a.csproj.
13. Code organisation
- One type per file. File name matches the type name exactly.
- Handlers and validators go in the same feature folder — not in separate
Handlers/orValidators/root folders. - If a file exceeds ~150 lines, consider splitting it. Long files usually mean mixed concerns.
- Partial classes are only for generated code (source generators, EF scaffolding).
- Keep
Program.csas an orchestrator — extension method calls only, no logic.