Skip to main content

Domain Model

:::info Source Sourced from services/progress-service/DOMAIN_MODEL.md in the documentation repo. :::

1. Aggregates

1.1 Attempt (root)

type AttemptId = Branded<string, 'AttemptId'>;

interface Attempt {
id: AttemptId;
tenantId: TenantId;
userId: UserId;
enrollmentId: EnrollmentId;
courseVersionId: CourseVersionId;
attemptNumber: number;
startedAt: ISODate;
endedAt?: ISODate;
outcome?: 'passed' | 'failed' | 'incomplete' | 'abandoned';
score?: number; // 0..1
durationSeconds?: number;
state: 'open' | 'closed';
}

State machine: open → closed (closing sets outcome, endedAt, durationSeconds).

Invariants:

  • attemptNumber strictly increases per (userId, enrollmentId).
  • Cannot close Attempt without outcome.
  • Reopening a closed attempt forbidden (fork a new attempt instead).

1.2 Statement (root - append-only)

type StatementId = Branded<string, 'StatementId'>;

interface Statement {
id: StatementId;
tenantId: TenantId;
actor: Actor; // xAPI actor
verb: Verb; // xAPI verb (e.g. "experienced", "passed", "completed")
object: Activity | StatementRef;
result?: Result;
context?: Context;
timestamp: ISODate; // when the learner did the thing
stored: ISODate; // when we received it
authority: Account; // system identity
cmi5?: { sessionId: string; registration: string };
attemptId: AttemptId;
}

interface Actor {
mbox?: string;
account?: { homePage: string; name: string }; // preferred over mbox
name?: string;
}

interface Verb { id: string; display: I18nString; }
interface Activity { id: string; definition?: ActivityDefinition; }
interface Result { score?: Score; success?: boolean; completion?: boolean; duration?: ISODuration; response?: string; }
interface Score { scaled?: number; raw?: number; min?: number; max?: number; }
interface Context { registration?: string; contextActivities?: ContextActivities; extensions?: Record<string, JSONValue>; }

Invariants:

  • Statements are immutable (no UPDATE allowed at DB layer).
  • Every statement anchored to attemptId (for cross-reference).
  • stored >= timestamp.

1.3 CompletionRecord

interface CompletionRecord {
id: ULID;
tenantId: TenantId;
userId: UserId;
enrollmentId: EnrollmentId;
courseVersionId: CourseVersionId;
attemptId: AttemptId;
completedAt: ISODate;
score: number;
passed: boolean;
evidenceStatementIds: StatementId[];
}

Completion is idempotent: derived from the first passing statement in an attempt. Additional passing statements do not create duplicate completion records.

2. Value Objects

  • Actor, Verb, Activity, Result, Score, Context — xAPI VOs.
  • AttemptNumber — positive integer, strictly monotonic per (user, enrollment).
  • DurationSeconds — non-negative integer.

3. Domain Events

  • progress.statement.stored.v1 — every statement.
  • progress.attempt.started.v1, .closed.v1.
  • progress.completion.recorded.v1 — first passing completion per attempt.
  • progress.score.recorded.v1 — score change.

4. State Transitions

Attempt:
[new] ─(startStatement)─▶ open
open ─(close with outcome)─▶ closed
closed ────────────────────▶ (terminal)

CompletionRecord:
[new] ─(first passing statement)─▶ recorded
recorded ────────────────────────▶ (terminal; GDPR erasure only)

5. Invariants (Summary)

  1. Tenant isolation: every statement, attempt, completion anchored to TenantId.
  2. Statements immutable (append-only).
  3. attemptNumber monotonic per (userId, enrollmentId).
  4. Completion is idempotent.
  5. Duration ≤ wall-clock elapsed between timestamp of opening and closing statements.
  6. Verbs from registered xAPI vocabulary (platform + ADL standard).

6. Diagram

Enrollment (enrollment-service)

│ (one enrollment can have many attempts)

Attempt
/ │ \
open? closed? (outcome)

│ (many statements per attempt)

Statement (append-only)

│ (first passing → completion)

CompletionRecord ──▶ certification-service
└▶ assignment-service
└▶ analytics-service