# 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>(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` per entity**, in `Persistence/Configuration/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 --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`** (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>`; 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()`** 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.