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:
attemptNumberstrictly increases per(userId, enrollmentId).- Cannot close
Attemptwithoutoutcome. - 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)
- Tenant isolation: every statement, attempt, completion anchored to TenantId.
- Statements immutable (append-only).
attemptNumbermonotonic per (userId, enrollmentId).- Completion is idempotent.
- Duration ≤ wall-clock elapsed between
timestampof opening and closing statements. - 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