Domain Model
:::info Source
Sourced from services/catalog-service/DOMAIN_MODEL.md in the documentation repo.
:::
Companion: SERVICE_OVERVIEW · ../../docs/02-ddd-bounded-contexts.md · ../../docs/12-data-models.md
1. Ubiquitous Language
| Term | Definition | Not to be confused with |
|---|---|---|
| Course | Long-lived, discoverable unit of learning with a stable slug and id. Metadata may evolve; ownership is a single tenant. | CourseDraft (authoring) — mutable WIP |
| CourseVersion | Immutable snapshot published at a point in time; references one PlayPackage. Uniquely identified by (courseId, versionLabel). | DraftRevision (authoring) |
| PlayPackageRef | Pointer into content-service identifying the runnable package for a version. | The package itself (lives in content-service) |
| Taxonomy | Named hierarchical tree of nodes used for browsing. Scope is either tenant-specific or global (ghasi:*). | Tag (flat label) |
| Visibility | Who may discover this course; orthogonal to who may access it. | Entitlement / License (marketplace) |
| Locale availability | The set of BCP-47 locales a specific CourseVersion is playable in. | UI locale preference |
| Withdrawn | Version is no longer viewable by new learners; existing learners see read-only history. | Archived (Course-level, affects metadata) |
| Deprecated | Version is still playable, but authoring suggests a newer one. | Withdrawn |
2. Aggregates
2.1 Course (root)
type CourseId = Branded<string, 'CourseId'>;
type CourseVersionId = Branded<string, 'CourseVersionId'>;
interface Course {
id: CourseId; // ULID, assigned on registration
tenantId: TenantId; // owning provider/org
slug: string; // unique per tenant, URL-safe
title: I18nString; // Record<Locale, string>
description: I18nString;
authors: AuthorRef[]; // { userId, displayName, role }
cover?: MediaRef; // { assetId, variant }
defaultLocale: Locale; // BCP-47
visibility: 'private' | 'org' | 'marketplace' | 'public';
taxonomy: TaxonomyRef[]; // { taxonomyId, nodePath }
tags: string[]; // free-form, normalised lower-case
status: 'active' | 'archived';
createdAt: ISODate;
latestVersionId?: CourseVersionId; // null until first version published
versionCount: number; // cached
etag: string; // server-side change token
}
Invariants:
slugis unique withintenantId(PK(tenantId, slug)).latestVersionId, if present, MUST point at a version withstatus = 'published'.visibility = 'marketplace'requires the owning tenant to have a marketplace listing (enforced via event check, not DB FK).status = 'archived'⇒ no new versions may be published.defaultLocale∈ union of locales on the latest version.
2.2 CourseVersion (root, immutable except for status)
interface CourseVersion {
id: CourseVersionId; // ULID
courseId: CourseId;
tenantId: TenantId; // denormalised for RLS
versionLabel: SemVer; // "1.0.0", "1.1.0"
publishedAt: ISODate;
publishedBy: UserId;
changelog?: I18nString;
durationMinutes: number;
estimatedReadingMinutes?: number;
moduleSummaries: ModuleSummary[]; // { id, title, lessonCount, durationMinutes }
locales: Locale[]; // available playback locales
playPackageRef: {
playPackageId: PlayPackageId;
sha256: SHA256; // content hash for integrity
format: 'v1' | 'v2';
};
status: 'published' | 'deprecated' | 'withdrawn';
deprecatedAt?: ISODate;
withdrawnAt?: ISODate;
withdrawnReason?: string;
}
interface ModuleSummary {
id: string;
title: I18nString;
lessonCount: number;
durationMinutes: number;
hasAssessments: boolean;
}
Invariants:
- Immutable on all fields except
status,deprecatedAt,withdrawnAt,withdrawnReason. - Transitions:
published → deprecated → withdrawn. No other transitions permitted. versionLabelis unique per(courseId)and monotonically non-decreasing by SemVer on publish.playPackageRef.sha256MUST match the SHA-256 in the originatingcontent.play_package.built.v1event.
2.3 Taxonomy (root)
interface Taxonomy {
id: ULID;
tenantId?: TenantId; // null = global platform taxonomy
namespace: string; // 'ghasi:subjects', 'acme:role_ladder'
title: I18nString;
tree: TaxonomyNode[]; // materialised path
version: number; // monotonic counter (for ETag)
updatedAt: ISODate;
updatedBy: UserId;
}
interface TaxonomyNode {
path: string; // '/science/physics/quantum'
label: I18nString;
description?: I18nString;
aliases?: string[];
childPaths: string[];
}
Invariants:
namespaceunique pertenantId(includingnull).tenantId = nullis writable only by platform-admins (RBACcatalog.taxonomy.admin).- Path strings MUST match
^(/[a-z0-9_\-]+)+$.
3. Entity Relationships
Tenant (1) ────── (0..*) Course
│
│ (1)
▼
(1..*) CourseVersion ──── (1) PlayPackageRef
│
│ (0..*)
▼
(0..*) TaxonomyRef ──── (0..*) Taxonomy
4. Value Objects
interface AuthorRef { userId: UserId; displayName: string; role: 'author'|'co_author'|'reviewer'; }
interface MediaRef { assetId: MediaAssetId; variant?: string; }
interface TaxonomyRef { taxonomyId: ULID; nodePath: string; }
interface PlayPackageRef { playPackageId: PlayPackageId; sha256: SHA256; format: 'v1'|'v2'; }
Value objects are immutable and compared structurally.
5. Domain Events (internal, before envelope)
| Name | Trigger | Payload shape |
|---|---|---|
CourseRegistered | First draft published | { courseId, tenantId, slug, defaultLocale, visibility } |
CourseMetadataUpdated | Metadata PATCH | { courseId, changedFields, previous, next } |
CourseVisibilityChanged | Visibility PATCH | { courseId, from, to, reason? } |
CourseVersionPublished | Play package built | { courseVersionId, courseId, versionLabel, durationMinutes, locales, playPackageRef } |
CourseVersionDeprecated | Deprecate action | { courseVersionId, reason? } |
CourseVersionWithdrawn | Withdraw action | { courseVersionId, reason } |
CourseArchived | Archive action | { courseId } |
TaxonomyUpdated | Tree mutation | { taxonomyId, namespace, version, diff } |
See EVENT_SCHEMAS for wire format.
6. Aggregate Boundaries & Consistency
- Course and CourseVersion are separate aggregates. A publish is two transactions: (a) upsert Course, (b) insert Version. Outbox entries are written in the same tx as each aggregate write; the saga is coordinated by event order (content package built → catalog projects).
- Taxonomy is a single aggregate per namespace; full-tree updates (optimistic lock via
version). - No cross-aggregate ACID; eventual consistency is acceptable (≤ 2 s target from publish to browse visibility).
7. Lifecycle State Machines
7.1 Course.status
(register) ──▶ active ──▶ archived
│ ▲
└──┘ (no transitions out of archived)
7.2 CourseVersion.status
(publish) ──▶ published ──▶ deprecated ──▶ withdrawn
│
└─────────────▶ withdrawn
deprecated → publishedis forbidden. Rationale: authoring-service must publish a new version.withdrawnis terminal.
8. Cross-Context Translations
| Upstream event | Translation rule |
|---|---|
authoring.course_draft.published.v1 | If no Course for (tenantId, slug), create it (status active). Else update metadata from draft manifest. Do NOT yet create a CourseVersion. |
content.play_package.built.v1 | Create CourseVersion with playPackageRef = { playPackageId, sha256, format }. Attach to Course.latestVersionId if SemVer > current. |
gdpr.subject_request.received.v1 | If subject is an author, redact PII in authors[].displayName, keep userId for referential integrity. |
9. Validation Rules (summary)
slug:/^[a-z0-9][a-z0-9\-]{1,98}[a-z0-9]$/.versionLabel: SemVer regex;major.minor.patch.locales[]: non-empty, each BCP-47 valid.visibility = 'marketplace' | 'public'only if tenant hasfeature.marketplace_publish = true.taxonomy[].nodePathMUST exist in the referenced taxonomy at the time of write (soft check — cached).
10. Glossary of IDs
| Prefix | Type | Example |
|---|---|---|
crs_ | CourseId | crs_01HXYZ… |
crv_ | CourseVersionId | crv_01HXYZ… |
tax_ | TaxonomyId | tax_01HXYZ… |
pkg_ | PlayPackageId (external) | pkg_01HXYZ… |