another step for constructing base project
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
# Server Coding Conventions
|
||||
|
||||
Rules enforced for all code in `server/`. These represent the standards expected from a **senior .NET engineer**. Read alongside [AGENTS.md](AGENTS.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.
|
||||
Reference in New Issue
Block a user