Domain Model
:::info Source
Sourced from services/marketplace-service/DOMAIN_MODEL.md in the documentation repo.
:::
Companion: 02 DDD · 12 Data Models · SERVICE_OVERVIEW
1. Aggregate Map
┌──────────────────────────────────────────────────────────────────────┐
│ Marketplace Bounded Context │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ <<Aggregate Root>> Listing │ │
│ │ ├── PricingPlan (entity, 1..*) │ │
│ │ ├── ListingMarketing (VO) │ │
│ │ └── RefundPolicy (VO) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ <<Aggregate Root>> Order │ │
│ │ ├── OrderLine (entity, 1..*) │ │
│ │ ├── Totals (VO) │ │
│ │ └── AppliedCoupons (VO list) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ <<Aggregate Root>> License │ │
│ │ ├── SeatAllocation (entity, 0..*) │ │
│ │ └── ValidityWindow (VO) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ <<Aggregate Root>> │ │ <<Aggregate Root>> │ │
│ │ Coupon │ │ PurchaseSaga │ │
│ └──────────────────────┘ └──────────────────────┘ │
│ │
│ ┌──────────────────────┐ │
│ │ <<Aggregate Root>> │ │
│ │ ProviderEarnings │ │
│ └──────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
Aggregate roots: Listing, Order, License, Coupon, PurchaseSaga, ProviderEarnings.
Rule: one aggregate modified per transaction. Cross-aggregate consistency is achieved via domain events + saga, never via multi-aggregate transactions.
2. Value Objects
All value objects are immutable and validated at construction. Those marked with (F03) are frozen at end of M1.
| Value Object | Type | Validation | Notes |
|---|---|---|---|
ListingId (F03) | Branded<string, 'ListingId'> | ULID, prefix lst_ | Issued per listing |
PricingPlanId (F03) | Branded<string, 'PricingPlanId'> | ULID, prefix pln_ | Immutable once any order references it |
OrderId (F03) | Branded<string, 'OrderId'> | ULID, prefix ord_ | |
LicenseId (F03) | Branded<string, 'LicenseId'> | ULID, prefix lic_ | |
CouponId (F03) | Branded<string, 'CouponId'> | ULID, prefix cpn_ | |
SagaId | Branded<string, 'SagaId'> | ULID, prefix sga_ | |
Money | { amount: number; currency: ISO4217 } | amount >= 0, 2 dp for most currencies | Never mix currencies in arithmetic |
ISO4217 | string | 3-letter currency code from allow-list | USD, EUR, GBP, INR, AED, KES, NGN initially |
ISODate | string | RFC 3339 UTC | |
RefundPolicy | { refundDays: number; attemptsCap?: number } | 0 <= refundDays <= 90 | |
ListingMarketing | { tagline; description; hero; screenshots[]; trailerUrl? } | Lengths bounded; URLs validated | |
RevenueShare | { platformBps: number; providerBps: number } | platformBps + providerBps === 10_000 | 15% default ⇒ {1500, 8500} |
Discount | { kind: 'percent'|'fixed'; value: number; currency?: ISO4217 } | Percent 1..100; fixed requires currency | |
SeatCount | number | >= 1, integer |
3. Aggregates — Detailed Specification
3.1 Listing Aggregate (Root)
type ListingState = 'draft' | 'submitted' | 'approved' | 'live' | 'suspended' | 'retired';
interface Listing {
id: ListingId;
providerTenantId: TenantId;
courseId: CourseId;
courseVersionId: CourseVersionId; // exact version shipped at purchase
visibility: 'unlisted' | 'public';
pricingPlans: PricingPlan[];
marketing: ListingMarketing;
refundPolicy: RefundPolicy;
revenueShare: RevenueShare;
state: ListingState;
submittedAt?: ISODate;
approvedAt?: ISODate;
suspendedAt?: ISODate;
suspensionReason?: string;
retiredAt?: ISODate;
createdAt: ISODate;
updatedAt: ISODate;
version: number;
}
interface PricingPlan {
id: PricingPlanId;
kind: 'one_time' | 'subscription' | 'seat_pack' | 'site_license';
currency: ISO4217;
price: Money;
seats?: number; // required for seat_pack; defaults to unlimited for site_license
intervalMonths?: number; // required for subscription
perpetualOfflineAccess: boolean;
active: boolean; // soft-disable; existing subscriptions continue
}
Invariants
| ID | Invariant | Enforcement |
|---|---|---|
| L-INV-1 | At least one active pricing plan is required to transition to submitted | Use case checks |
| L-INV-2 | A listing can only be approved if its CourseVersionId has received catalog.course_version.published.v1 AND content.play_package.built.v1 | Read model of published/shippable versions |
| L-INV-3 | Provider must have tenant.kyc_verified=true before listing goes live | Checked on approval |
| L-INV-4 | Plans with kind=subscription must set intervalMonths | Domain constructor |
| L-INV-5 | Plans with kind=seat_pack must set seats >= 1 | Domain constructor |
| L-INV-6 | revenueShare.platformBps + providerBps === 10_000 | VO constructor |
| L-INV-7 | Cannot retire a listing with active licenses unless all have validUntil < now | Read model check at retire |
| L-INV-8 | Suspending a listing prevents new orders but does not revoke existing licenses | State-machine level |
| L-INV-9 | Currency of a pricing plan is immutable once the plan has any order referencing it | Guarded via version bump + new plan |
State Transitions
┌──────┐
│ draft│◄──────────── edit (allowed in draft/submitted)
└──┬───┘
submit() │
▼
┌──────────┐
│submitted │──── withdraw() ────► draft
└──┬───────┘
approve() │ reject() → draft
▼
┌─────────┐
│approved │──── golive(prereq ok) ───► live
└─────────┘ │
│ suspend()
▼
┌──────────────┐
│ suspended │
└──────┬───────┘
reinstate()
│
▼ live
retire()
│
▼
┌───────┐
│retired│
└───────┘
Domain Events
listing.submitted(internal → outbox →marketplace.listing.submitted.v1)listing.approved→marketplace.listing.approved.v1listing.suspended→marketplace.listing.suspended.v1listing.retired→marketplace.listing.retired.v1
3.2 Order Aggregate (Root)
type OrderStatus =
| 'created'
| 'pending_payment'
| 'paid'
| 'fulfilled'
| 'refunded'
| 'failed';
interface Order {
id: OrderId;
buyerTenantId: TenantId;
buyerUserId: UserId;
currency: ISO4217;
lines: OrderLine[];
subtotal: Money;
discountTotal: Money;
totals: Money; // final; tax computed by billing and stored here read-only
appliedCoupons: CouponId[];
paymentIntentId?: string;
status: OrderStatus;
failureReason?: string;
placedAt: ISODate;
paidAt?: ISODate;
fulfilledAt?: ISODate;
refundedAt?: ISODate;
refundDeadline: ISODate; // = paidAt + listing.refundPolicy.refundDays
sagaId: SagaId;
version: number;
}
interface OrderLine {
id: string;
listingId: ListingId;
pricingPlanId: PricingPlanId;
quantity: SeatCount; // seats for seat_pack; 1 for one_time/subscription
unitPrice: Money;
subtotal: Money;
courseId: CourseId; // denormalized for fulfillment
courseVersionId: CourseVersionId;
}
Invariants
| ID | Invariant | Enforcement |
|---|---|---|
| O-INV-1 | All lines share the same currency as Order.currency | Constructor rejects |
| O-INV-2 | subtotal = Σ lines.subtotal; totals set only after billing populates tax; totals = subtotal - discountTotal + taxTotal | Invariant check before paid |
| O-INV-3 | refundDeadline set exactly once, at transition to paid | State-machine level |
| O-INV-4 | status transitions are monotonic except for idempotent re-sets of the same value | State machine |
| O-INV-5 | Max lines.length = 50 per order | Hard cap |
| O-INV-6 | A refunded order cannot be fulfilled | State machine |
| O-INV-7 | sagaId is set at creation and never changes | Constructor |
State Transitions
created ──► pending_payment ──► paid ──► fulfilled
│ │ │ │
│ │ ▼ ▼
│ └──► failed ◄── failed refunded (within deadline)
└──► failed (validation error)
3.3 License Aggregate (Root)
type LicenseState = 'active' | 'expired' | 'revoked';
interface License {
id: LicenseId;
tenantId: TenantId; // licensee tenant (buyer)
providerTenantId: TenantId; // seller tenant (for reporting/payout)
listingId: ListingId;
courseId: CourseId;
courseVersionId: CourseVersionId;
pricingPlanKind: 'one_time' | 'subscription' | 'seat_pack' | 'site_license';
scope: 'individual' | 'org';
seats: SeatCount;
remainingSeats: SeatCount; // seats - seatAllocations.active.length
seatAllocations: SeatAllocation[];
validFrom: ISODate;
validUntil?: ISODate; // undefined for perpetual
state: LicenseState;
source: 'purchase' | 'gift' | 'manual';
orderId?: OrderId;
refundDeadline?: ISODate;
perpetualOfflineAccess: boolean;
createdAt: ISODate;
updatedAt: ISODate;
version: number;
}
interface SeatAllocation {
id: string;
userId: UserId;
assignedAt: ISODate;
releasedAt?: ISODate;
status: 'active' | 'released' | 'consumed_on_refund';
}
Invariants
| ID | Invariant | Enforcement |
|---|---|---|
| LI-INV-1 | remainingSeats === seats - count(active seat allocations) | Recomputed on mutation |
| LI-INV-2 | Cannot assign a seat if remainingSeats === 0 | Domain method |
| LI-INV-3 | Cannot assign a seat if state !== 'active' | Domain method |
| LI-INV-4 | validUntil > validFrom when present | Constructor |
| LI-INV-5 | A revoked license remains revoked forever (no resurrection) | State machine |
| LI-INV-6 | Scope=individual implies seats=1 | Constructor |
| LI-INV-7 | Refund of order revokes all seats not yet consumed; consumed seats are marked consumed_on_refund but remain allocated | Refund use case |
State Transitions
active ──► expired (by scheduler when validUntil ≤ now)
│
└────► revoked (by refund, admin, or dispute)
3.4 Coupon Aggregate (Root)
interface Coupon {
id: CouponId;
code: string; // human-readable, uppercase, unique per tenant scope
tenantScope?: TenantId; // if set, only usable by this tenant; else platform-wide
providerScope?: TenantId; // if set, applies only to listings owned by this provider
discount: Discount;
usageCap?: number; // total redemptions; undefined = unlimited
usageCount: number;
perUserCap?: number;
validFrom: ISODate;
validUntil?: ISODate;
active: boolean;
createdAt: ISODate;
version: number;
}
Invariants
| ID | Invariant | Enforcement |
|---|---|---|
| C-INV-1 | code is unique per tenantScope (or globally if no scope) | Unique index |
| C-INV-2 | usageCount <= usageCap when cap is set | Domain method increments atomically |
| C-INV-3 | percent discount: 1 <= value <= 100 | VO |
| C-INV-4 | fixed discount requires currency matching order currency at redemption | Use case |
| C-INV-5 | Cannot redeem if !active or outside [validFrom, validUntil] | Use case |
3.5 PurchaseSaga Aggregate (Root)
type SagaState =
| 'started'
| 'awaiting_payment'
| 'licensing'
| 'enrolling'
| 'fulfilled'
| 'compensating'
| 'failed';
interface PurchaseSaga {
id: SagaId;
orderId: OrderId;
state: SagaState;
correlationId: string; // = orderId for traceability
stepHistory: SagaStepRecord[];
currentStep?: string;
compensationReason?: string;
awaitingPaymentTimeoutAt?: ISODate; // started + 30min
updatedAt: ISODate;
version: number;
}
interface SagaStepRecord {
step: string;
enteredAt: ISODate;
exitedAt?: ISODate;
outcome: 'advanced' | 'compensated' | 'failed';
causationEventId?: string;
errorCode?: string;
}
Invariants
| ID | Invariant | Enforcement |
|---|---|---|
| S-INV-1 | Exactly one saga per order | Unique index on saga.order_id |
| S-INV-2 | Transitions follow the state machine defined in SERVICE_OVERVIEW §9 | State-machine guard |
| S-INV-3 | Timeouts only fire while in awaiting_payment | Scheduler checks state before firing |
| S-INV-4 | Compensation is idempotent; re-entry of a compensation step is a no-op if already completed | Step record lookup |
3.6 ProviderEarnings Aggregate (Root)
interface ProviderEarnings {
providerTenantId: TenantId;
currency: ISO4217;
periodMonth: string; // e.g. "2026-04"
grossRevenue: Money;
platformFee: Money;
refunds: Money;
taxesWithheld: Money;
netPayable: Money;
state: 'accruing' | 'ready' | 'paid';
payoutId?: string;
lines: EarningsLine[];
version: number;
}
Derived aggregate (projection over fulfilled/refunded orders). Updated by event handlers; payout triggers billing-service.
4. Aggregate Interaction
Place Order ┌────────────┐
──────────────────────► │ Listing │ (read: plan, price, KYC)
└─────┬───────┘
│ validated
▼
┌────────────┐ emits ┌──────────────┐
│ Order │─────────────────►│ PurchaseSaga │
└─────┬──────┘ └──────┬───────┘
│ │ drives
▼ ▼
order.placed.v1 license.granted.v1
│
▼
┌────────────┐ ┌──────────────┐
│ License │◄────────────────│ Saga step 3 │
└────────────┘ └──────────────┘
5. Repositories
| Repository | Ops | Notes |
|---|---|---|
ListingRepository | findById, findByProvider, findPublicListings(filters), save | Optimistic locking on version |
OrderRepository | findById, findByBuyer, findByStatus, save | |
LicenseRepository | findById, findByTenant, findByOrder, save | Includes seat allocations |
CouponRepository | findByCode(tenantScope), incrementUsage, save | incrementUsage uses atomic SQL UPDATE ... SET usage_count = usage_count + 1 WHERE usage_count < usage_cap |
SagaRepository | findById, findByOrder, save, findTimingOut(now) | Timing-out used by scheduler |
ProviderEarningsRepository | findByProviderAndPeriod, accrue, save |
All repositories enforce tenant RLS; platform-admin-only operations (listing approval, suspension) use a distinct role with elevated policy.
6. Domain Services
| Service | Purpose |
|---|---|
PricingCalculator | Compute subtotal, apply coupons, return Totals (pre-tax) |
RefundEligibilityPolicy | Given an order + license, determine whether refund is allowed |
SeatAllocator | Enforce LI-INV-1 atomically; used by AssignLicenseSeat |
RevenueShareCalculator | Split a paid order into provider earnings + platform fee |
SagaCoordinator | Pure domain function: decide(currentState, event) -> nextState + commands[] |
7. Anti-Corruption Rules
- Listings never store
Moneyin a currency that differs from the pricing plan's currency. - Orders never store payment-method information; only a
paymentIntentIdopaque to marketplace. - Licenses never store enrollment progress; only seat allocations.
- The saga never talks to Stripe; it talks to billing's internal API.