22 KiB
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 unboundedToListAsync(). - Use
Includeonly in a command handler that needs navigation properties loaded to mutate the aggregate. - Access the DB through
IUnitOfWorkin Application handlers.ApplicationDbContextis referenced directly only inside Infrastructure. - Commit once per command, at the end:
await unitOfWork.CommitAsync(ct). - One
IEntityTypeConfiguration<T>per entity, inPersistence/Configuration/<Area>Config/. - Mapster maps in the handler after the query, never in the repository. Only write a custom
TypeAdapterConfigwhen shapes genuinely diverge; register scans inProgram.cs. - Never concatenate raw SQL. EF parameterizes automatically. If you must drop to SQL, use
FromSqlInterpolated, neverFromSqlRawwith 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 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. 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 stringcodes, persisted as the stable snake_case string — no C# enum, no value converter needed. - Edges live in a static
CanTransition(from, to)built from aDictionary<string, IReadOnlyCollection<string>>; a terminal state maps to an empty set. - The entity owns the transition.
statushas a private setter, and the only mutators are cohesive domain methods (Accept/Reject/Cancel…) calling a privateTransition(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(UTCdatetime2), notDateTimeOffset— the SQLite test provider cannot translateDateTimeOffsetcomparison orORDER BY. Order lists and sweeps byIdfor 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 |
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(viaIVariantSnapshotSerializer) and the encryptedaddress_snapshot_jsonplatform_fee_rateon the booking- The resolved cancellation policy
code+refund_percentage iban_snapshoton 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 = 1only when the nurseis_verified = 1ANDnurse_verifications.status != 'suspended'ANDis_accepting_bookings = 1AND the variantis_active = 1— recomputed on every relevant source write. An unverified, paused, suspended, or deactivated nurse or variant must never surface.district_id = NULLmeans 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;
RebuildAsyncis 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
bookingsrow exists only when the nurse accepted and payment was captured. So a payment is initiated against theaccepted_awaiting_paymentrequest, andpayment_transactions.booking_idis nullable, bound only when the confirm creates or loads the booking. - Conversion goes through the shared
BookingFactory/BookingConversionhelper — the card confirm and the BNPL settle both call it rather than re-implementing the split. - A booking request carries no money and no
bookingsrow; 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 alocation_mismatchsupport alert and notifies without blocking. GPS-denied still checks in, flagged null. SetDisputeWindowis the only payout-eligibility trigger. Completion setsdispute_window_ends_at = completed_at + config(dispute_window_hours, 72)and each completed session'spayout_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-publishedreviews 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_moderationis never public — list and aggregate filter topublishedat 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_centersthrough the single resolver, not a hardcoded platform: booking → nurse →partner_center_id, and the issuer/settlement target is the centre only when itis_merchant_of_record, elseplatform. partner_centers(the licensing sponsor) ≠organizations(the future employer, deferred).settlement_ibanis encrypted,[AuditRedacted], and masked to the last 4 in every read. The centre'scommission_rateis separate fromplatform_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+ oneAddSingleton<IRecurringJob, …>()inAddPersistenceServices. 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
draftbatch, recorded system-initiated (InitiatedByAdminIdnullable = "no human initiator"). The irreversibleprocessstep remains an explicit admin action, andAdminPayoutsControllerneutralizes any request-suppliedSystemInitiatedvalue. - Admin manual triggers are overrides, running the same idempotent commands.
- The scheduler is dormant under the
Testingenvironment, so integration tests stay deterministic. Each job and command is unit-tested directly. - A time-sensitive command self-guards against a passed deadline via
IDateTimeProviderrather than trusting that a sweep has run; a sweep's re-queriedWHERE 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 referenceHasDataseeds: 3 nurses (2 verified with variants, Tehran coverage,approvedverification, credentials and amatched_national_idbank account; 1 unverified), 2 customers with patients and addresses, 2 phone-OTP admins (asuper_adminplus a scopedfinanceoperator, 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.cspartials) — 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 anis_internalnote), notifications, patient care records, a merchant-of-record partner centre, and a mid-pipeline verification case.
Three rules they establish:
- Write through the real entities and commands — the guarded transition methods,
BookingFactory,GeneratePayoutBatch/ExecutePayoutBatch,LedgerPosting,OpenTicketCommand. Business timestamps are backdated explicitly. (Application grantsInternalsVisibleToto Persistence for this.) - Drive the search projection through
ISearchIndexMaintainer.RebuildAsync— never hand-insert index rows. - Never guard idempotency on a Persian string. The
ApplicationDbContextsave 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.