509 lines
25 KiB
Markdown
509 lines
25 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`).
|
|
|
|
### No unused code
|
|
|
|
Leave nothing dead behind. Remove unused `using` directives, local variables, parameters, private fields, and private members rather than letting them accumulate.
|
|
|
|
- These already surface as compiler/analyzer signals — `CS0168` (variable declared, never used), `CS0219` (variable assigned, value never used), `CS0169` (private field never used), `IDE0005` (unnecessary `using`). The quality gate is **zero new warnings**, so treat unused code as a gate failure.
|
|
- **Delete it — don't silence it.** Do not add `#pragma warning disable`, throwaway discards, or `_ =` assignments just to quiet the analyzer.
|
|
- 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.
|
|
|
|
### Comments — explain *why*, never *what*
|
|
|
|
Code that needs a comment to be understood usually needs a better name instead. Prefer self-documenting names over prose.
|
|
|
|
- **Do not** write comments that restate what the code already says — no `// constructor`, `// loop over users`, or XML-doc that merely echoes the method name.
|
|
- **Do** add a comment only where a non-obvious decision, constraint, business rule, workaround, or trade-off is *not* evident from the code — explain the reasoning, not the mechanics.
|
|
- Keep any necessary comment tight, and delete comments that no longer match the code.
|
|
|
|
```csharp
|
|
// ❌ restates the obvious
|
|
// increment the retry counter
|
|
retryCount++;
|
|
|
|
// ✅ captures a non-obvious constraint the code can't express on its own
|
|
// Payment gateway rejects amounts above 50M IRR per call; split larger settlements upstream.
|
|
if (amount > MaxPerCallRial) ...
|
|
```
|
|
|
|
---
|
|
|
|
## 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.
|
|
|
|
> **As built (backend-phase-0):** the audit base type is `BaseEntity`/`IAuditableEntity` in
|
|
> `Baya.Domain/Common/BaseEntity.cs` (`CreatedAt`/`ModifiedAt` as `DateTimeOffset`, `CreatedById`/
|
|
> `ModifiedById` as `int?`). Stamping is done by `AuditFieldInterceptor`
|
|
> (`Baya.Infrastructure.Persistence/Interceptors/`), a `SaveChangesInterceptor` that reads time from
|
|
> `IDateTimeProvider` and the user from `ICurrentUser` — not in the `DbContext` itself.
|
|
|
|
> **As built (backend-phase-1) — reusable patterns you should follow:**
|
|
> - **Config is rows, read at compute time.** Money-critical constants (commission %, VAT, deadlines,
|
|
> EVV tolerance, cancellation tiers) live in `platform_configs`, read via `IPlatformConfig.GetConfig<T>`
|
|
> (cached, parsed by the row's `data_type`) — **never hardcode**. Changing a rate must never
|
|
> retroactively alter an already-computed amount: later phases snapshot the rate onto the
|
|
> booking/invoice at compute time; do not live-re-read a rate for an already-priced row.
|
|
> - **Append-only audit trail.** `audit_logs` is immutable — there is **no** update/delete path in app
|
|
> code. Mark a compliance-sensitive entity with `IAuditable` (`Baya.Domain/Common`) and the
|
|
> `AuditFieldInterceptor` writes an old/new diff row per change in the same transaction; annotate any
|
|
> encrypted/PII property with `[AuditRedacted]` so it is redacted (never plaintext) in the diff.
|
|
> `platform_configs` is the first `IAuditable` entity.
|
|
> - **DB-backed platform facades** (`IPlatformConfig`/`IHolidayCalendar`/`IAnalyticsSink`/`IAuditLogger`/
|
|
> `INotificationService`/`ISupportAlertService`) live in `Persistence/Services/` and are the contracts
|
|
> other domains reuse — don't re-query these tables directly. `IAnalyticsSink` is fire-and-forget
|
|
> (never fail the caller); `INotificationService`/notification endpoints are always tenant-scoped to
|
|
> `ICurrentUser`; `support_alerts` are admin-only and never appear on a user-facing route.
|
|
> - **Retention/scheduling seam.** Background jobs run behind the hosted-service seam
|
|
> (`NotificationRetentionHostedService`); real Hangfire/Quartz is deferred. The notification retention
|
|
> predicate is exactly `is_read = 1 AND age > 90d` — unread is never auto-deleted.
|
|
|
|
### Money is IRR `BIGINT` — integer-only, no floats
|
|
|
|
Every monetary value is **IRR Rials stored as `long` / `BIGINT`**. There is **no float/decimal path** on money — not in entities, DTOs, the API, or arithmetic. Toman is display-only and converts to/from Rials **only** inside a provider adapter at its boundary, never in domain or shared code. If a money value object is introduced later it must be integer-only. The three booking amounts always satisfy `gross = commission + payout`.
|
|
|
|
### Deterministic set-hash for multi-row uniqueness
|
|
|
|
When "no two rows may share the same *set* of child rows" must be enforced (e.g. a nurse can't list two
|
|
identical variants — same category + identical answered option-set), a plain composite unique index can't
|
|
express it because the set spans multiple rows. Reduce the set to a single comparable column with
|
|
**`Baya.Application.Common.OptionSetHash.Compute(pairs)`** (backend-phase-5): it sorts the `(long, long)`
|
|
pairs and SHA-256s them to a stable 64-char hex hash that is **order-independent** (identical sets always
|
|
collide). Persist it (`NVARCHAR(64)`) and back it with a **filtered unique index** (e.g.
|
|
`UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL`) as the race-safe backstop,
|
|
with a handler pre-check for the friendly `409`. Reuse this helper for any future "same set of ids" guard;
|
|
do **not** reuse `IFieldEncryptor.Hash` (that is for PII-column equality lookups).
|
|
|
|
### Guarded cross-aggregate state flip (backend-phase-6)
|
|
|
|
When one write must atomically change a header row's state **and** a derived boolean on a *different*
|
|
aggregate (e.g. `nurse_verifications.status` → `nurse_profiles.is_verified`), do it in one transaction:
|
|
load **both** as tracked entities, mutate them through a single pure domain helper
|
|
(`VerificationAggregator.Finalize`), then `CommitAsync` **once** — never flip the derived flag from a
|
|
controller, a partial write, or an out-of-band update, and never leave an in-between state. Two follow-on
|
|
rules this establishes:
|
|
|
|
- **Self-committing facades come after the atomic commit.** `ISupportAlertService.RaiseAsync`,
|
|
`INotificationDispatcher.DispatchAsync`, `IAuditLogger.WriteAsync` and `IPlatformConfig.SetConfig` each call
|
|
`SaveChanges` on the *shared scoped* `DbContext`. Calling one mid-build flushes your partial tracked changes —
|
|
invoke them only **after** `unitOfWork.CommitAsync()`. In a batch loop that commits per item, load and guard
|
|
every dependency **before** mutating tracked state, or an early `continue` leaks a dirty entity that a later
|
|
iteration's commit will flush.
|
|
- **Persist a status enum as its stable snake_case code, not the member name.** Define the C# enum, then map
|
|
it with a `HasConversion(e => e.ToCode(), s => Parse(s))` value converter (see `VerificationCodes`) so the DB
|
|
and the wire carry `in_review`, not `InReview`. Enum→code mapping in a projected read happens **in memory
|
|
after materialization** (`.ToCode()` is not LINQ-translatable); DTOs expose the code string.
|
|
|
|
### Forward-only status machine (backend-phase-8)
|
|
|
|
When an entity has a lifecycle `status` with a fixed set of allowed transitions, model the machine as a
|
|
**static allowed-edges table** and route **every** write through it — never assign `status` ad-hoc. The b8
|
|
pattern (reused by b9 for the `bookings` machine):
|
|
|
|
- **Statuses are `const string` codes** (`BookingRequestStatus`) persisted as the stable snake_case string —
|
|
no C# enum, no value converter needed. **Edges live in a static `CanTransition(from, to)`**
|
|
(`BookingRequestTransitions`) built from a `Dictionary<string, IReadOnlyCollection<string>>`; terminal
|
|
states map to an empty set.
|
|
- **The entity owns the transition.** `status` has a **private setter**; the only mutators are cohesive domain
|
|
methods (`Accept`/`Reject`/`Cancel…`) that call a private `Transition(target)` which asserts the edge is
|
|
legal (throws on an illegal edge — a programming error, since the handler pre-checks). Side-effect fields
|
|
(`payment_deadline_at`, `rejection_reason`) are set in the same method.
|
|
- **The handler pre-checks and returns a clean 409.** `if (!entity.CanTransitionTo(target)) return
|
|
OperationResult.ConflictResult(...)` — never throw for the expected "already moved / terminal" case.
|
|
- **Time-sensitive commands self-guard** against a passed deadline via `IDateTimeProvider` rather than trusting
|
|
a sweep has run; the recurring expiry `BackgroundService` is bounded/paginated/idempotent, and its
|
|
`WHERE status = …` predicate (re-queried each tick) is the concurrency guard — a row a racing action moved is
|
|
simply not reloaded.
|
|
- **Deadline columns that are compared/sorted use `DateTime` (UTC `datetime2`), not `DateTimeOffset`** — the
|
|
SQLite test provider cannot translate `DateTimeOffset` comparison/`ORDER BY`. Order lists/sweeps by `Id`, not
|
|
the timestamp, for the same reason.
|
|
|
|
---
|
|
|
|
## 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.
|