397 lines
17 KiB
Markdown
397 lines
17 KiB
Markdown
# Server Coding Conventions
|
|
|
|
Rules enforced for all code in `server/`. These represent the standards expected from a **senior .NET engineer**. Read alongside [CLAUDE.md](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.
|
|
|
|
```csharp
|
|
// ✅ 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
|
|
|
|
```csharp
|
|
// ✅ 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 `readonly` unless mutation is genuinely needed.
|
|
- Prefer `IReadOnlyList<T>` / `IReadOnlyCollection<T>` over `List<T>` in signatures unless the caller needs to mutate.
|
|
- Never expose public setters on entities — use methods or constructors.
|
|
- Avoid `static` mutable 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 returning `null` from 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
|
|
|
|
```csharp
|
|
// ✅ 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 `CancellationToken` and pass it downstream.
|
|
- Use `ValueTask<T>` for hot paths (handlers, repositories). Use `Task<T>` for rarely-called or always-async methods.
|
|
- Never use `async void` — it swallows exceptions. Use `async Task` even 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:
|
|
|
|
```csharp
|
|
[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 beyond `BaseController`.
|
|
- Inject `ISender` via primary constructor — not `IMediator`.
|
|
- **Never call `Ok()`, `BadRequest()`, `NotFound()` directly** — always `base.OperationResult(result)`.
|
|
- Keep controller methods thin: one `Send`, one `OperationResult`. No business logic in controllers.
|
|
- Use `[Display(Description = "...")]` so NSwag generates meaningful Swagger tags.
|
|
- Pass `CancellationToken` from the action into `sender.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 `OperationResult` factory methods:
|
|
- `OperationResult<T>.SuccessResult(value)` — happy path
|
|
- `OperationResult<T>.FailureResult(errors)` — validation / business rule failure
|
|
- `OperationResult<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
|
|
|
|
```csharp
|
|
// ✅ 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 `Include` only in command handlers where you need to mutate the aggregate and need navigation properties loaded.
|
|
- Access the DB through `IUnitOfWork` in Application-layer handlers. `ApplicationDbContext` is only referenced directly inside Infrastructure.
|
|
- Commit once per command at the end: `await _unitOfWork.CommitAsync(ct)`.
|
|
- One `IEntityTypeConfiguration<T>` per entity, in `Persistence/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>`:
|
|
|
|
```csharp
|
|
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 `FluentValidation` validator. The `ValidateCommandBehavior` pipeline behavior runs it automatically before the handler.
|
|
- Validators are registered automatically via `RegisterValidatorsAsServices()` in `Program.cs`.
|
|
- Validate at the boundary (command/query), not deep in the domain or repositories.
|
|
|
|
```csharp
|
|
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.cs` via `TypeAdapterConfig.GlobalSettings.Scan(...)`. Add new assemblies that contain mapping configs there.
|
|
- Never write manual mapping code when Mapster can infer it — only write custom `TypeAdapterConfig` when shapes diverge.
|
|
- Mapping happens **in the handler after the DB query**, not in the repository.
|
|
|
|
---
|
|
|
|
## 9. Error handling & logging
|
|
|
|
```csharp
|
|
// ✅ 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: `Debug` for trace info, `Information` for meaningful events, `Warning` for recoverable issues, `Error` for unexpected failures.
|
|
- Never log passwords, tokens, secrets, or full PII (email is borderline — use `userId` in logs instead).
|
|
- The global `ExceptionHandler` middleware catches unhandled exceptions — do not add try/catch in handlers for unknown exceptions; let them propagate.
|
|
|
|
---
|
|
|
|
## 10. 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.
|
|
- Use `NSubstitute` for mocking: `Substitute.For<IUnitOfWork>()`.
|
|
- Integration tests use `Baya.Tests.Setup` which 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:
|
|
|
|
1. Happy path — authenticated request returns 200 with correct body shape.
|
|
2. Unauthenticated request 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);
|
|
}
|
|
}
|
|
```
|
|
|
|
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.
|
|
- `SecretKey` and `Encryptkey` (in `IdentitySettings`) must be set in environment-specific config, never in `appsettings.json` committed 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), never `FromSqlRaw` with 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 a `ServiceConfiguration/` extension; add `app.UseRateLimiter()` before `app.UseAuthentication()` in `Program.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 in `Program.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)
|
|
- All NuGet versions live in `Directory.Packages.props`. Never add `Version=` 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/` or `Validators/` 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.cs` as an orchestrator — extension method calls only, no logic.
|