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).