Files
baya-monorepo/archive/docs/rules/server/persistence.md
T
2026-08-02 20:01:31 +03:30

383 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Server persistence
EF Core rules, money, state machines, snapshots, the scheduler, and the domain invariants that live in the
database.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. EF Core basics
```csharp
// ✅ project to a DTO in the query
var dto = await _db.Orders
.AsNoTracking()
.Where(o => o.UserId == userId)
.Select(o => new OrderResult(o.Id, o.Status, o.CreatedAt))
.ToListAsync(ct);
// ❌ loads the 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);
```
- **Always `AsNoTracking()`** on a read-only query.
- **Always project with `.Select()`** in a query — never hydrate full entities just to map them, and **never
return an entity from a handler**.
- **Pagination is mandatory** on any unbounded list (`Skip`/`Take`). No unbounded `ToListAsync()`.
- Use `Include` **only** in a command handler that needs navigation properties loaded to mutate the aggregate.
- **Access the DB through `IUnitOfWork`** in Application handlers. `ApplicationDbContext` is referenced
directly only inside Infrastructure.
- **Commit once per command**, at the end: `await unitOfWork.CommitAsync(ct)`.
- **One `IEntityTypeConfiguration<T>` per entity**, in `Persistence/Configuration/<Area>Config/`.
- **Mapster maps in the handler after the query**, never in the repository. Only write a custom
`TypeAdapterConfig` when shapes genuinely diverge; register scans in `Program.cs`.
- **Never concatenate raw SQL.** EF parameterizes automatically. If you must drop to SQL, use
`FromSqlInterpolated`, never `FromSqlRaw` with user data.
**Migrations:**
```bash
dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api
dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api
```
### Migrations are split from boot
`dotnet run -- migrate` (the deploy-time one-shot, or a CI `dotnet ef database update`) applies migrations
plus the idempotent seeders, then exits — so multi-instance boots never race on DDL and the runtime login
needs no permanent DDL rights.
| Environment | What boot does |
| --- | --- |
| Development | Migrates + seeds (roles always; a bootstrap admin **only if** `Seed:AdminUsername`/`Seed:AdminPassword` are configured), plus the Development-only gateway, demo-world and demo-lifecycle seeders |
| Deployed | Only **checks** the schema is current (`EnsureSchemaUpToDateAsync` — fail fast on a pending migration) and seeds roles / the break-glass admin, idempotently |
A reachable SQL Server is required to start.
### Soft delete
Every soft-deletable entity **must** declare a global query filter in its configuration:
```csharp
builder.HasQueryFilter(o => !o.IsDeleted);
```
Without it, soft-deleted rows appear in every query that doesn't explicitly exclude them — a silent data
leak. **Never add `Where(x => !x.IsDeleted)` per query**; the filter makes it automatic and auditable.
**Deactivate, never hard-delete.** `user_roles.revoked_at` has the same treatment, so a revoked grant
disappears from every role read automatically.
---
## 2. Audit
| Field | Type | Set by |
| --- | --- | --- |
| `CreatedAt` / `ModifiedAt` | `DateTimeOffset` | `AuditFieldInterceptor` |
| `CreatedById` / `ModifiedById` | `int?` | `AuditFieldInterceptor`, via `ICurrentUser` |
The base type is `BaseEntity` / `IAuditableEntity` (`Baya.Domain/Common/`). Stamping happens in
`AuditFieldInterceptor` (a `SaveChangesInterceptor` in `Persistence/Interceptors/`) which reads time from
`IDateTimeProvider` and the user from `ICurrentUser`**not** in the `DbContext`, and **not** in a handler.
Audit fields cannot be backfilled retroactively, so design them in from the start.
### The append-only audit trail
Mark a compliance-sensitive entity with **`IAuditable`** and the interceptor writes an old/new diff row into
`ops.AuditLogs` **in the same transaction as the change**. Annotate any encrypted or PII property with
**`[AuditRedacted]`** so the diff records a marker, never plaintext.
`audit_logs` is **immutable — there is no update or delete path in app code.** Current `IAuditable` entities:
`PlatformConfig`, `PartnerCenter`, `Review`, and the admin-decided money and trust entities `Refund`,
`NurseClawback`, `NursePayout`, `NursePayoutBatch`, `NurseVerification`.
Retention is a two-tier sweep via `IAuditLogger.PurgeExpiredAsync`: financial and verification entity types
keep `audit_retention_financial_days` (default 2555 ≈ 7 years), everyday rows `audit_retention_general_days`
(default 730 ≈ 2 years). Oldest-first, capped, id-keyed delete, idempotent.
---
## 3. Config is rows, read at compute time
Money-critical constants — commission percentage, VAT, deadlines, EVV tolerance, cancellation tiers, job
cadences — live in `ops.PlatformConfigs` and are read via **`IPlatformConfig.GetConfig<T>`** (cached, parsed by
the row's `data_type`). **Never hardcode one.**
And the corollary, which is the part that actually matters:
> **Changing a rate must never retroactively alter an already-computed amount.** A rate is **snapshotted onto
> the booking or invoice at compute time**. Do not live-re-read a rate for an already-priced row.
The 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 those tables directly.** `IAnalyticsSink` is fire-and-forget and never fails
the caller; `INotificationService` is always tenant-scoped to `ICurrentUser`; `support_alerts` are admin-only
and must never appear on a user-facing route.
### 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.
---
## 4. Money
**Money has its own file: [money.md](money.md).** IRR `BIGINT` integers, the append-only balanced ledger, the
three-amount reconciliation, webhook idempotency, and the refund / BNPL / payout / invoice invariants all live
there. Read it before touching anything under `Features/{Payments,Refunds,Invoices,Bnpl,Payouts}`.
The one line to carry in your head meanwhile: **money is an integer number of IRR Rials, and there is no float
path on it anywhere.**
---
## 5. Forward-only status machines
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.
- **Statuses are `const string` codes**, persisted as the stable snake_case string — no C# enum, no value
converter needed.
- **Edges live in a static `CanTransition(from, to)`** built from a
`Dictionary<string, IReadOnlyCollection<string>>`; a terminal state maps to an empty set.
- **The entity owns the transition.** `status` has a **private setter**, and the only mutators are cohesive
domain methods (`Accept`/`Reject`/`Cancel…`) calling a private `Transition(target)` that asserts the edge is
legal — throwing on an illegal edge, because that is a programming error, since the handler pre-checks.
Side-effect fields 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.
- **A replayed transition that is already complete is an idempotent no-op**, not a failure.
Machines in the codebase: `BookingRequestTransitions`, the `bookings` machine, `BnplTransitions`,
`PayoutBatchStatus`/`PayoutStatus` transitions, `VerificationStatus`, `ReviewModerationStatus`.
### When the enum is a C# enum
Persist it as its **stable snake_case code** via `HasConversion(e => e.ToCode(), s => Parse(s))` (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.
### Guarded cross-aggregate flips
When one write must atomically change a header row's state **and** a derived boolean on a *different*
aggregate (`nurse_verifications.status``nurse_profiles.is_verified`): 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.
`NurseProfile.is_verified` has **no public setter** for this reason.
### Two SQL Server / SQLite portability rules
- **A deadline column that is compared or sorted uses `DateTime` (UTC `datetime2`), not `DateTimeOffset`** —
the SQLite test provider cannot translate `DateTimeOffset` comparison or `ORDER BY`. Order lists and sweeps
by `Id` for the same reason.
- **Sequential numbers come from a counter row, not a DB sequence** (`InvoiceNumberSequences`), locked and
committed with the row it numbers, so it is portable and gap-free.
---
## 6. Uniqueness patterns
| Need | Pattern |
| --- | --- |
| A nullable column must participate in uniqueness | **The filtered-index pair.** SQL Server treats NULLs as distinct, so `district_id = NULL` needs `UNIQUE(nurse_id, city_id) WHERE district_id IS NULL` **plus** `UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL`, both `AND deleted_at IS NULL` |
| "No two rows may share the same *set* of child rows" | **A deterministic set-hash.** `Baya.Application.Common.OptionSetHash.Compute(pairs)` sorts the `(long, long)` pairs and SHA-256s them into a stable, **order-independent** 64-char hex hash. Persist `NVARCHAR(64)` and back it with a filtered unique index as the race-safe backstop, plus a handler pre-check for a friendly 409. **Do not reuse `IFieldEncryptor.Hash`** — that is for PII equality lookups |
| One-per-parent, forever | An **unconditional** UNIQUE, not filtered on soft-delete — `nurse_payout_booking_links.booking_id` |
| One flagged row per parent | A filtered UNIQUE plus clear-then-set in one transaction — `UNIQUE(customer_id) WHERE is_primary=1 AND deleted_at IS NULL` |
| PII equality lookup | A deterministic hash column, UNIQUE, synced on `SaveChanges``users.PhoneHash`. See [identity.md](identity.md) |
A duplicate returns **409** through `OperationResult.ConflictResult``BaseController`'s 409 mapping.
---
## 7. Snapshots freeze history
A row that represents a past agreement must not change when its sources are edited later. Frozen at their
moment, and never mutated afterwards:
- `variant_snapshot_json` (via `IVariantSnapshotSerializer`) and the **encrypted** `address_snapshot_json`
- `platform_fee_rate` on the booking
- The resolved cancellation policy `code` + `refund_percentage`
- `iban_snapshot` on a payout (**encrypted**, `[AuditRedacted]`), frozen from the verified primary account
- Deadlines: `nurse_response_deadline_at` = `now + config`, `payment_deadline_at` = `now + config` — both
stored as **absolute UTC**, so a later config change cannot move them
A later edit to the source variant, address, or policy **never** mutates an existing booking.
---
## 8. The search projection
`search.NurseSearchIndices` is **one flat row per (bookable variant × covered service area)** — a fan-out
denormalization carrying the variant's category/price/unit, the covered `city_id`/`district_id`, the nurse's
gender and rating aggregates, and one visibility gate. It is a **read-only projection**, written only by
`ISearchIndexMaintainer`.
Three invariants:
- **`is_searchable = 1` only when** the nurse `is_verified = 1` **AND** `nurse_verifications.status !=
'suspended'` **AND** `is_accepting_bookings = 1` **AND** the variant `is_active = 1` — recomputed on **every**
relevant source write. An unverified, paused, suspended, or deactivated nurse or variant must **never**
surface.
- **`district_id = NULL` means whole city, both directions.** A city search matches every row in the city; a
district search matches that district's rows **plus** the NULL-district rows.
- **Incremental maintenance and a full rebuild must converge.** The index is fully re-derivable from source;
`RebuildAsync` is idempotent.
The maintainer keeps the index consistent **inline, inside the source write's own unit of work** — it shares
the request-scoped `DbContext`, so it only *stages* changes and the handler's single `CommitAsync` flushes
source and projection atomically. It reads the facts a trigger does not change from the DB, and takes the
facts it *does* change as **tracked arguments**, so it never reads a stale pre-commit value. It resurrects a
soft-deleted row on re-upsert, so each (variant × area) has exactly one live row.
`INurseSearch` (read) reads **only `is_searchable = 1`** rows. Callers depend on the interface, so a later
Elasticsearch backend is a config-selected drop-in.
**Coverage is named districts, not GPS radii.** Address lat/lng exists only for the EVV distance check; it is
never used for coverage matching.
---
## 9. Reference-data caching
Public and reference reads are cached through `ICacheService` behind a **generation-token key scheme** —
`GeoCache`, `CatalogCache`, `ReviewCache`. Any admin write to that area **bumps the token**, which invalidates
the whole namespace at once rather than enumerating keys.
The catalog is **EAV/data, not code**: an admin adds a category or a pricing dimension as *rows*, never a
migration. The only closed code enum in the area is `PriceUnits`. A `ServiceOptionGroups.ServiceCategoryId =
NULL` marks a **cross-category** dimension that applies to every category — and "applicable groups" means the
category's own groups **plus** every NULL group, everywhere: public browse, required-group validation, and the
duplicate guard. All required groups must be answered; one value per dimension.
**The bookable unit is the variant, not the nurse.** Keep it a clean projectable source. The engagement total
is `price` + `price_unit` + `session_count` — never `price` alone.
---
## 10. Domain invariants that live here
The rules a change in these areas must not break. Each is enforced in code *and* by a constraint.
**Bookings and EVV**
- A `bookings` row exists **only** when the nurse accepted **and** payment was captured. So a payment is
initiated against the `accepted_awaiting_payment` *request*, and `payment_transactions.booking_id` is
**nullable**, bound only when the confirm creates or loads the booking.
- Conversion goes through the shared `BookingFactory` / `BookingConversion` helper — the card confirm and the
BNPL settle both call it rather than re-implementing the split.
- A booking request carries **no money and no `bookings` row**; accept only opens the payment window.
- **EVV is per session, and a mismatch is advisory.** Check-in computes the distance to the *frozen* booking
address against `evv_location_tolerance_meters`; a mismatch raises a `location_mismatch` support alert and
notifies **without blocking**. GPS-denied still checks in, flagged null.
- **`SetDisputeWindow` is the only payout-eligibility trigger.** Completion sets
`dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)` and each completed session's
`payout_eligible_at`.
- **Cancellation refunds only un-started sessions**; the applicable policy tier is resolved by
`(actor, lead-time bucket)` and frozen onto the booking.
**Refunds, clawbacks, invoices, BNPL, payouts** → all in [money.md](money.md).
**Reviews**
- Reviews are for **completed/closed bookings only, owned by the caller, 1:1** (`UNIQUE(booking_id)` is the
backstop; the handler pre-checks for a clean 409). A cross-tenant booking is a **404**, never a leak.
- **Recompute the nurse aggregate from source on EVERY transition — not a delta.** Read
`COUNT`/`SUM(rating)` over the nurse's currently-`published` reviews *excluding* the transitioning review,
fold that review's *new* status in memory, set the guarded aggregates, and stage the reindex — all in the
**same transaction** as the status change. The exclude-and-fold avoids a stale pre-commit re-query. This is
the fix for inflated-rating-after-hide drift.
- **`pending_moderation` is never public** — list and aggregate filter to `published` at the query layer.
- `rating <= min_rating_for_support_alert` (config, default 2) raises a support alert **reliably** — after the
main commit, never silently swallowed.
**Partner centres**
- Merchant-of-record resolution follows `partner_centers` through the single resolver, **not a hardcoded
platform**: booking → nurse → `partner_center_id`, and the issuer/settlement target is the centre **only**
when it `is_merchant_of_record`, else `platform`.
- `partner_centers` (the licensing *sponsor*) **≠** `organizations` (the future *employer*, deferred).
`settlement_iban` is encrypted, `[AuditRedacted]`, and **masked to the last 4 in every read**. The centre's
`commission_rate` is separate from `platform_fee_rate`.
**Deferred by design — do not create these tables:** `bnpl_settlement_entries`, `organizations`,
`organization_nurses`, `fraud_flags`, `recurring_booking_schedules`.
---
## 11. The recurring-job scheduler
A single in-process scheduler, `Persistence/Services/Scheduling/RecurringJobSchedulerHostedService`, drives
every registered `IRecurringJob` on its own cadence — using **no new infrastructure**, so SQL Server stays the
only external dependency.
| Job | Cadence |
| --- | --- |
| `booking_request_expiry` | 1 min (const) |
| `notification_retention` | 24 h (const) — the predicate is exactly `is_read = 1 AND age > 90d`; **unread is never auto-deleted** |
| `verification_expiry_scan` | `verification_expiry_scan_cadence_hours` |
| `no_show_sweep` | `no_show_scan_cadence_hours` |
| `weekly_payout_generation` | `nurse_payout_interval_days` |
| `MoadianReconciliationJob` | 6 h |
| `audit_log_retention` | `audit_retention_scan_cadence_hours` |
- **Adding a cron = implement `IRecurringJob` + one `AddSingleton<IRecurringJob, …>()`** in
`AddPersistenceServices`. The scheduler owns the per-tick DI scope, error isolation (a throwing tick never
kills the loop), and the lock. A job says only *how often* and *what one idempotent run does*.
- **Jobs must be idempotent.** A retry — or a second instance, once the lock is Redis-backed — must never
double-pay or double-post. The DB uniques and state machines are the backstop. Each tick runs under
`IDistributedLock("scheduler:{name}")`, which is in-process today and is **the >1-instance scale-out gate**:
swap the seam to Redis to serialize ticks across nodes. A single-instance MVP needs neither Redis nor
Hangfire/Quartz.
- **Money movement stays human-approved.** The payout job schedules *generation* only — a `draft` batch,
recorded system-initiated (`InitiatedByAdminId` nullable = "no human initiator"). The irreversible `process`
step remains an explicit admin action, and `AdminPayoutsController` **neutralizes any request-supplied
`SystemInitiated` value**.
- **Admin manual triggers are overrides**, running the same idempotent commands.
- **The scheduler is dormant under the `Testing` environment**, so integration tests stay deterministic. Each
job and command is unit-tested directly.
- A time-sensitive command **self-guards** against a passed deadline via `IDateTimeProvider` rather than
trusting that a sweep has run; a sweep's re-queried `WHERE status = …` predicate **is** the concurrency guard
— a row a racing action moved is simply not reloaded.
---
## 12. Development seeders
Both are **Development-only** and idempotent.
- **`DemoWorldSeeder`** — a coherent demo marketplace on top of the reference `HasData` seeds: 3 nurses (2
verified with variants, Tehran coverage, `approved` verification, credentials and a `matched_national_id`
bank account; 1 unverified), 2 customers with patients and addresses, **2 phone-OTP admins** (a `super_admin`
plus a scoped `finance` operator, so the console is reachable through the normal login and capability gating
is demonstrable), and one cross-category required option group.
- **`DemoLifecycleSeeder`** (+ `.Money.cs` / `.Social.cs` partials) — a full lifecycle world layered on those
personas so every flow is manually testable: booking requests in every status, 8 bookings across every
reachable state, the balanced payment ledger behind each, refunds on all three forks, a paid and a draft
payout batch, moderated reviews with recomputed aggregates, tickets (including an `is_internal` note),
notifications, patient care records, a merchant-of-record partner centre, and a mid-pipeline verification
case.
Three rules they establish:
1. **Write through the real entities and commands** — the guarded transition methods, `BookingFactory`,
`GeneratePayoutBatch`/`ExecutePayoutBatch`, `LedgerPosting`, `OpenTicketCommand`. Business timestamps are
backdated explicitly. (Application grants `InternalsVisibleTo` to Persistence for this.)
2. **Drive the search projection through `ISearchIndexMaintainer.RebuildAsync`** — never hand-insert index
rows.
3. **Never guard idempotency on a Persian string.** The `ApplicationDbContext` save hook normalizes Persian
digits and ZWNJ in every stored string, so a Persian literal **never round-trips equal**. Guard on a phone
number, a code, or another natural key.