Skip to main content

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

TermDefinitionNot to be confused with
CourseLong-lived, discoverable unit of learning with a stable slug and id. Metadata may evolve; ownership is a single tenant.CourseDraft (authoring) — mutable WIP
CourseVersionImmutable snapshot published at a point in time; references one PlayPackage. Uniquely identified by (courseId, versionLabel).DraftRevision (authoring)
PlayPackageRefPointer into content-service identifying the runnable package for a version.The package itself (lives in content-service)
TaxonomyNamed hierarchical tree of nodes used for browsing. Scope is either tenant-specific or global (ghasi:*).Tag (flat label)
VisibilityWho may discover this course; orthogonal to who may access it.Entitlement / License (marketplace)
Locale availabilityThe set of BCP-47 locales a specific CourseVersion is playable in.UI locale preference
WithdrawnVersion is no longer viewable by new learners; existing learners see read-only history.Archived (Course-level, affects metadata)
DeprecatedVersion 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:

  • slug is unique within tenantId (PK (tenantId, slug)).
  • latestVersionId, if present, MUST point at a version with status = '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.
  • versionLabel is unique per (courseId) and monotonically non-decreasing by SemVer on publish.
  • playPackageRef.sha256 MUST match the SHA-256 in the originating content.play_package.built.v1 event.

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:

  • namespace unique per tenantId (including null).
  • tenantId = null is writable only by platform-admins (RBAC catalog.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)

NameTriggerPayload shape
CourseRegisteredFirst draft published{ courseId, tenantId, slug, defaultLocale, visibility }
CourseMetadataUpdatedMetadata PATCH{ courseId, changedFields, previous, next }
CourseVisibilityChangedVisibility PATCH{ courseId, from, to, reason? }
CourseVersionPublishedPlay package built{ courseVersionId, courseId, versionLabel, durationMinutes, locales, playPackageRef }
CourseVersionDeprecatedDeprecate action{ courseVersionId, reason? }
CourseVersionWithdrawnWithdraw action{ courseVersionId, reason }
CourseArchivedArchive action{ courseId }
TaxonomyUpdatedTree 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 → published is forbidden. Rationale: authoring-service must publish a new version.
  • withdrawn is terminal.

8. Cross-Context Translations

Upstream eventTranslation rule
authoring.course_draft.published.v1If 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.v1Create CourseVersion with playPackageRef = { playPackageId, sha256, format }. Attach to Course.latestVersionId if SemVer > current.
gdpr.subject_request.received.v1If 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 has feature.marketplace_publish = true.
  • taxonomy[].nodePath MUST exist in the referenced taxonomy at the time of write (soft check — cached).

10. Glossary of IDs

PrefixTypeExample
crs_CourseIdcrs_01HXYZ…
crv_CourseVersionIdcrv_01HXYZ…
tax_TaxonomyIdtax_01HXYZ…
pkg_PlayPackageId (external)pkg_01HXYZ…