# 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.
