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

22 KiB
Raw Blame History

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

// ✅ 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:

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:

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 ICurrentUsernot 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. 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.statusnurse_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 SaveChangesusers.PhoneHash. See identity.md

A duplicate returns 409 through OperationResult.ConflictResultBaseController'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 schemeGeoCache, 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.

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.