Skip to main content

Application Logic

:::info Source Sourced from services/authoring-service/03-APPLICATION_LOGIC.md in the documentation repo. :::

Companion: 02 Domain Model · 05 API Design · 08 AI Integration


1. Use Case Catalog

All use cases follow the Command/Query pattern. Commands mutate state and publish domain events via the outbox. Queries are read-only projections.

1.1 Commands

Use CaseInputGuardsOutputEvents
CreateDrafttitle, locale, tenantId, createdByUser has author role in tenantCourseDraftcourse_draft.created.v1
UpdateDraftdraftId, changes (title, locale, settings)User is collaborator, state=editingCourseDraftcourse_draft.updated.v1
DeleteDraftdraftIdUser is owner, state ∈ {editing}void(soft delete)
ForkPublishedDraftsourceDraftIdUser has author role, source state=published_idleCourseDraftcourse_draft.forked.v1
AddModuledraftId, titlestate=editing, user is collaboratorModuleDraftcourse_draft.updated.v1
AddLessondraftId, moduleId, titlestate=editing, module existsLessonDraftcourse_draft.updated.v1
AddBlockdraftId, lessonId, block datastate=editing, lesson exists, INV-1/INV-2Blockblock.added.v1
UpdateBlockdraftId, blockId, changesstate=editing, block existsBlockblock.updated.v1
DeleteBlockdraftId, blockIdstate=editing, block existsvoidblock.removed.v1
ReorderBlocksdraftId, lessonId, blockIds[]state=editing, all blocks existvoidblock.updated.v1
MoveBlockdraftId, blockId, targetLessonId, positionstate=editingvoidblock.updated.v1
AcceptAIBlockdraftId, blockIdblock.status=draft_ai, user is collaboratorBlockblock.reviewed.v1
RejectAIBlockdraftId, blockIdblock.status=draft_aivoidblock.reviewed.v1, block.removed.v1
SubmitForReviewdraftIdstate=editing, >=1 blockCourseDraftcourse_draft.updated.v1
ApproveReviewdraftIdstate=in_review, reviewer != authorCourseDraftcourse_draft.updated.v1
RejectReviewdraftId, reasonstate=in_reviewCourseDraftcourse_draft.updated.v1
PublishDraftdraftIdstate=approved, INV-4 passCourseDraftcourse_draft.published.v1 (saga step 1)
RequestAIGeneratedraftId, lessonId, intent, blockKindstate=editingAIJobblock.ai_generated.v1
RequestAIImprovedraftId, blockId, instructionstate=editing, block existsAIJobblock.ai_generated.v1
RequestAIQuizdraftId, lessonId, difficulty, countstate=editingAIJobblock.ai_generated.v1
ImportSCORMdraftId, scormZipUrlstate=editingCourseDraftcourse_draft.updated.v1

1.2 Queries

QueryInputOutput
GetDraftdraftId, tenantIdCourseDraftDTO
ListDraftstenantId, filters, cursor, pageSizePage
GetBlockdraftId, blockIdBlockDTO
GetPublishReadinessdraftIdPublishReadinessReport
GetAIJobStatusjobIdAIJobStatusDTO
GetDraftHistorydraftId, cursorPage

2. Command Handler Pattern

Every command handler follows this structure:

class CreateDraftHandler implements CommandHandler<CreateDraftCommand, CourseDraft> {
constructor(
private readonly draftRepo: CourseDraftRepository, // port
private readonly eventPublisher: EventPublisher, // port
private readonly authzService: AuthorizationService, // port
) {}

async execute(cmd: CreateDraftCommand, ctx: RequestContext): Promise<CourseDraft> {
// 1. Authorize
await this.authzService.assertCan(ctx, 'draft:create', { tenantId: cmd.tenantId });

// 2. Build domain object (invariants enforced at construction)
const draft = CourseDraft.create({
id: CourseDraftId.generate(),
tenantId: cmd.tenantId,
title: cmd.title,
defaultLocale: cmd.locale,
createdBy: ctx.userId,
});

// 3. Persist (transactional, includes outbox write)
await this.draftRepo.save(draft);

// 4. Return
return draft;
}
}

3. Course Publish Saga

The publish saga is an orchestrated saga with explicit state machine, compensation, and timeout.

3.1 Saga State Machine

┌──────────────┐
│ │
┌──────────────────────────│ timeout │
│ (15 min) │ (failed) │
│ │ │
│ └──────────────┘
│ ▲
│ │ any step timeout
│ │
┌────▼─────┐ emit published ┌────┴──────┐ wait for ┌────────────┐
│ │──────────────────►│ │ built.v1 │ │
│ approved │ │ building │◄──────────────│ content │
│ │ │ │ │ service │
└──────────┘ └─────┬─────┘ └────────────┘

built.v1 received


┌───────────┐ wait for ┌────────────┐
│ │ published.v1 │ │
│cataloging │◄────────────────│ catalog │
│ │ │ service │
└─────┬─────┘ └────────────┘

published.v1 received


┌───────────┐ wait for ┌────────────┐
│ │ bundle.v1 │ │
│ bundling │◄────────────────│ content │
│ │ │ service │
└─────┬─────┘ └────────────┘

bundle.v1 received


┌───────────┐
│ │
│ ready │──► draft → published_idle
│ │
└───────────┘

3.2 Saga Steps

StepTriggerActionSuccess eventCompensation
1. buildingPublishDraft commandEmit authoring.course_draft.published.v1content.play_package.built.v1Discard package, draft → approved
2. catalogingStep 1 success— (content-service builds)catalog.course_version.published.v1Unregister CourseVersion
3. bundlingStep 2 success— (catalog registers)content.play_package.bundle.published.v1Revoke bundle
4. readyStep 3 successDraft → published_idle
5. timeout15 min elapsedDraft → editing with error blockpublish_saga.failed.v1All prior compensations

3.3 Saga Persistence

interface PublishSagaState {
sagaId: string;
draftId: CourseDraftId;
tenantId: TenantId;
currentStep: 'building' | 'cataloging' | 'bundling' | 'ready' | 'failed';
startedAt: ISODate;
completedSteps: SagaStepRecord[];
timeoutAt: ISODate; // startedAt + 15 min
failureReason?: string;
}

interface SagaStepRecord {
step: string;
completedAt: ISODate;
eventId: ULID;
}

Persisted in publish_sagas table. Polled every 30s for timeout detection.

3.4 Compensation Flow

On failure at any step, compensations execute in reverse order:

bundling failure → revoke bundle → unregister CourseVersion → discard package → draft → editing
cataloging failure → unregister CourseVersion → discard package → draft → editing
building failure → discard package → draft → editing
timeout → compensate all completed steps

Each compensation emits publish_saga.compensated.v1 with step details.

4. Ports (Interfaces)

4.1 Repository Ports

interface CourseDraftRepository {
findById(id: CourseDraftId, tenantId: TenantId): Promise<CourseDraft | null>;
findAll(tenantId: TenantId, filters: DraftFilters, cursor: Cursor): Promise<Page<CourseDraft>>;
save(draft: CourseDraft): Promise<void>;
delete(id: CourseDraftId, tenantId: TenantId): Promise<void>;
}

interface PublishSagaRepository {
findById(sagaId: string): Promise<PublishSagaState | null>;
findByDraftId(draftId: CourseDraftId): Promise<PublishSagaState | null>;
save(saga: PublishSagaState): Promise<void>;
findTimedOut(): Promise<PublishSagaState[]>;
}

4.2 External Service Ports

interface AIClient {
generateBlock(params: AIGenerateParams): Promise<AICompletionResult>;
improveBlock(params: AIImproveParams): Promise<AICompletionResult>;
generateQuiz(params: AIQuizParams): Promise<AICompletionResult>;
translateContent(params: AITranslateParams): Promise<AICompletionResult>;
}

interface MediaClient {
resolveAsset(assetId: MediaAssetId, tenantId: TenantId): Promise<MediaAssetStatus>;
}

interface EventPublisher {
publish(event: DomainEvent): Promise<void>;
}

interface SCORMParser {
parse(zipUrl: string, tenantId: TenantId): Promise<SCORMImportResult>;
}

4.3 Sync Projection Port

interface SyncProjector {
projectDraftState(draft: CourseDraft): Promise<void>;
projectBlockChange(draftId: CourseDraftId, change: BlockChangeSet): Promise<void>;
}

5. Application Services

5.1 DraftApplicationService

Orchestrates command dispatch, transaction management, and cross-cutting concerns (logging, metrics).

5.2 PublishSagaOrchestrator

Listens for saga-related events, advances the saga state machine, triggers compensations.

5.3 AIJobManager

Tracks background AI generation jobs. Receives ai.completion.finished.v1 events and attaches results to the appropriate draft blocks.

5.4 SCORMImportService

Parses SCORM packages, extracts content, maps to block model, creates draft structure.

6. Event Handlers (Inbound)

EventHandlerAction
content.play_package.built.v1PublishSagaOrchestratorAdvance saga: building → cataloging
catalog.course_version.published.v1PublishSagaOrchestratorAdvance saga: cataloging → bundling
content.play_package.bundle.published.v1PublishSagaOrchestratorAdvance saga: bundling → ready
media.asset.ready.v1MediaReadyHandlerMark block media refs as resolved
ai.completion.finished.v1AIJobManagerAttach AI result to draft block
gdpr.subject_request.received.v1GDPRHandlerAnonymize/delete user data in drafts

7. Transaction Boundaries

Every command execution is wrapped in a single Postgres transaction that includes:

  1. Read current aggregate state (with SELECT ... FOR UPDATE)
  2. Apply domain logic (immutable — new state returned)
  3. Write new aggregate state
  4. Write domain events to outbox table
  5. Commit

The outbox relay (polling-publisher) runs outside the transaction and publishes events to NATS. This guarantees at-least-once delivery.

┌─────────────────────── Single Transaction ──────────────────────┐
│ │
│ SELECT draft WHERE id = $1 FOR UPDATE │
│ ──► Domain logic (pure, returns new state + events) │
│ UPDATE draft SET ... WHERE id = $1 AND version = $expected │
│ INSERT INTO outbox (topic, envelope) VALUES (...) │
│ │
│ COMMIT │
└──────────────────────────────────────────────────────────────────┘

Async: Outbox relay → NATS JetStream publish → mark outbox row published

8. Idempotency

All write endpoints require Idempotency-Key header (ULID). The service stores the key in an idempotency_keys table with the response. Duplicate requests return the stored response without re-executing.

TTL: 24 hours. Cleanup via scheduled job.

9. Optimistic Concurrency

Every PATCH and PUT operation requires If-Match: "<draftVersion>" header. The handler compares against the current draftVersion and returns 409 Conflict on mismatch.

This is critical for multi-user editing scenarios before Yjs is available (pre-M4).