Domain model — Aggregates & Ownership
The system is organized by Domain-Driven Design: data is grouped into aggregates (entity clusters handled as one consistency boundary), and each concept has exactly one owner. Understanding these boundaries helps avoid breaking consistency when changing code.
What is an aggregate?
An aggregate has a root and a boundary: state inside it should only be changed through the root, and one transaction should normally change one aggregate. Examples in AIMY:
| Aggregate (root) | Owns | Note |
|---|---|---|
| Contact / Account Holder | Contact (TypeId = AccountHolder), Contact.ProfileImage | "Account Holder" is not an Account aggregate; Account has no avatar. |
| AccountPaymentCustomer | AccountId, BusinessUnitId, PaymentProvider, CustomerToken, StatusId | BusinessUnitId is set at creation and never changes afterward; access must filter by BU (cross-BU = security violation). |
| TermBookingOrder (booking root) | LockTypeId, Version (ROWVERSION), IsDepositPayment, deposit snapshot | One Order = one deposit transaction slot. |
| Billing | Billing.PaymentOptionId and normalization | Billing exclusively owns PaymentOptionId; cross-domain interaction only reads/emits events. |
| Enrollment (Subscription) | Runtime enrollment state | See Subscription. |
📌 These are illustrative examples from a few features (deposit, billing, dashboard), not a complete matrix.
Domain matrix by ownership
High-level ownership groups:
| Domain | Primary owner | Read-only / dependencies |
|---|---|---|
| Finance | Billing, Invoice, CreditNote, FinanceTransaction projection | Booking/Subscription line data, BusinessUnit invoice settings, Payment paid state, Xero provider config. |
| Payment | AccountPaymentCustomer, DirectPaymentBatch, DirectPayment, provider checkout result | Billing/Invoice context, BookingOrder lock/deposit snapshot, BusinessUnit payment settings. |
| Master Data | Enterprise, BusinessUnit, Org/Site, Contact, Attendee, Program/Term/TPS/Product | Runtime domains read it to determine scope, account, attendee, session, pricing/availability context. |
Principle: Finance can read Booking/Subscription to calculate documents but does not own booking/enrollment runtime; Payment can read invoice/billing to collect money but does not own invoice lifecycle; Master Data is broadly read, but each foundational entity still mutates through its own owner.
Ownership principles (very important)
- Each concept has exactly one owner aggregate; only the owner may mutate it.
- Cross-aggregate interaction may only read or emit event (append-only) — never directly mutate another aggregate's state.
- Typical recorded violation:
TermBookingOrder.LockTypeIdmutated by both service layer and controller →BOUNDARY_VIOLATION(race risk).
Example applied in the wiki: Subscription — Setup UI does not directly change Enrollment/Invoice/Booking; every runtime mutation goes through the existing confirmation service.
Service → aggregate responsibility
Each service declares which aggregate it OWNS (may change) vs READS (read-only). Example (billing preview — read-only computation):
| Service | OWNS | READS |
|---|---|---|
BillingService.PreviewBillingDifferenceAsync | BillingPreview (transient) | Billing, Invoice, settings |
DashboardService.* | DashboardQueryContext (cache) | many aggregates (read-only) |
DepositEligibilityService | EligibilityPolicy | ProgramCategory, SubsidyPolicy |
Read-only computation (Dashboard, Billing Preview) does not mutate domain state → no ownership conflict.
Contracts — contracts between parts
Some stable API contracts exist between components, for example staff–BU connection. Typical rule: one staff member cannot have more than one active Employee link for the same BusinessUnit.
Related
- Conventions & principles — core technical principles.
- Database & storage — stores & keys.