Skip to main content

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 ObjectTypeValidationNotes
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_
SagaIdBranded<string, 'SagaId'>ULID, prefix sga_
Money{ amount: number; currency: ISO4217 }amount >= 0, 2 dp for most currenciesNever mix currencies in arithmetic
ISO4217string3-letter currency code from allow-listUSD, EUR, GBP, INR, AED, KES, NGN initially
ISODatestringRFC 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_00015% default ⇒ {1500, 8500}
Discount{ kind: 'percent'|'fixed'; value: number; currency?: ISO4217 }Percent 1..100; fixed requires currency
SeatCountnumber>= 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

IDInvariantEnforcement
L-INV-1At least one active pricing plan is required to transition to submittedUse case checks
L-INV-2A listing can only be approved if its CourseVersionId has received catalog.course_version.published.v1 AND content.play_package.built.v1Read model of published/shippable versions
L-INV-3Provider must have tenant.kyc_verified=true before listing goes liveChecked on approval
L-INV-4Plans with kind=subscription must set intervalMonthsDomain constructor
L-INV-5Plans with kind=seat_pack must set seats >= 1Domain constructor
L-INV-6revenueShare.platformBps + providerBps === 10_000VO constructor
L-INV-7Cannot retire a listing with active licenses unless all have validUntil < nowRead model check at retire
L-INV-8Suspending a listing prevents new orders but does not revoke existing licensesState-machine level
L-INV-9Currency of a pricing plan is immutable once the plan has any order referencing itGuarded 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.approvedmarketplace.listing.approved.v1
  • listing.suspendedmarketplace.listing.suspended.v1
  • listing.retiredmarketplace.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

IDInvariantEnforcement
O-INV-1All lines share the same currency as Order.currencyConstructor rejects
O-INV-2subtotal = Σ lines.subtotal; totals set only after billing populates tax; totals = subtotal - discountTotal + taxTotalInvariant check before paid
O-INV-3refundDeadline set exactly once, at transition to paidState-machine level
O-INV-4status transitions are monotonic except for idempotent re-sets of the same valueState machine
O-INV-5Max lines.length = 50 per orderHard cap
O-INV-6A refunded order cannot be fulfilledState machine
O-INV-7sagaId is set at creation and never changesConstructor

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

IDInvariantEnforcement
LI-INV-1remainingSeats === seats - count(active seat allocations)Recomputed on mutation
LI-INV-2Cannot assign a seat if remainingSeats === 0Domain method
LI-INV-3Cannot assign a seat if state !== 'active'Domain method
LI-INV-4validUntil > validFrom when presentConstructor
LI-INV-5A revoked license remains revoked forever (no resurrection)State machine
LI-INV-6Scope=individual implies seats=1Constructor
LI-INV-7Refund of order revokes all seats not yet consumed; consumed seats are marked consumed_on_refund but remain allocatedRefund 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

IDInvariantEnforcement
C-INV-1code is unique per tenantScope (or globally if no scope)Unique index
C-INV-2usageCount <= usageCap when cap is setDomain method increments atomically
C-INV-3percent discount: 1 <= value <= 100VO
C-INV-4fixed discount requires currency matching order currency at redemptionUse case
C-INV-5Cannot 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

IDInvariantEnforcement
S-INV-1Exactly one saga per orderUnique index on saga.order_id
S-INV-2Transitions follow the state machine defined in SERVICE_OVERVIEW §9State-machine guard
S-INV-3Timeouts only fire while in awaiting_paymentScheduler checks state before firing
S-INV-4Compensation is idempotent; re-entry of a compensation step is a no-op if already completedStep 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

RepositoryOpsNotes
ListingRepositoryfindById, findByProvider, findPublicListings(filters), saveOptimistic locking on version
OrderRepositoryfindById, findByBuyer, findByStatus, save
LicenseRepositoryfindById, findByTenant, findByOrder, saveIncludes seat allocations
CouponRepositoryfindByCode(tenantScope), incrementUsage, saveincrementUsage uses atomic SQL UPDATE ... SET usage_count = usage_count + 1 WHERE usage_count < usage_cap
SagaRepositoryfindById, findByOrder, save, findTimingOut(now)Timing-out used by scheduler
ProviderEarningsRepositoryfindByProviderAndPeriod, accrue, save

All repositories enforce tenant RLS; platform-admin-only operations (listing approval, suspension) use a distinct role with elevated policy.

6. Domain Services

ServicePurpose
PricingCalculatorCompute subtotal, apply coupons, return Totals (pre-tax)
RefundEligibilityPolicyGiven an order + license, determine whether refund is allowed
SeatAllocatorEnforce LI-INV-1 atomically; used by AssignLicenseSeat
RevenueShareCalculatorSplit a paid order into provider earnings + platform fee
SagaCoordinatorPure domain function: decide(currentState, event) -> nextState + commands[]

7. Anti-Corruption Rules

  • Listings never store Money in a currency that differs from the pricing plan's currency.
  • Orders never store payment-method information; only a paymentIntentId opaque to marketplace.
  • Licenses never store enrollment progress; only seat allocations.
  • The saga never talks to Stripe; it talks to billing's internal API.