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 Case | Input | Guards | Output | Events |
|---|---|---|---|---|
CreateDraft | title, locale, tenantId, createdBy | User has author role in tenant | CourseDraft | course_draft.created.v1 |
UpdateDraft | draftId, changes (title, locale, settings) | User is collaborator, state=editing | CourseDraft | course_draft.updated.v1 |
DeleteDraft | draftId | User is owner, state ∈ {editing} | void | (soft delete) |
ForkPublishedDraft | sourceDraftId | User has author role, source state=published_idle | CourseDraft | course_draft.forked.v1 |
AddModule | draftId, title | state=editing, user is collaborator | ModuleDraft | course_draft.updated.v1 |
AddLesson | draftId, moduleId, title | state=editing, module exists | LessonDraft | course_draft.updated.v1 |
AddBlock | draftId, lessonId, block data | state=editing, lesson exists, INV-1/INV-2 | Block | block.added.v1 |
UpdateBlock | draftId, blockId, changes | state=editing, block exists | Block | block.updated.v1 |
DeleteBlock | draftId, blockId | state=editing, block exists | void | block.removed.v1 |
ReorderBlocks | draftId, lessonId, blockIds[] | state=editing, all blocks exist | void | block.updated.v1 |
MoveBlock | draftId, blockId, targetLessonId, position | state=editing | void | block.updated.v1 |
AcceptAIBlock | draftId, blockId | block.status=draft_ai, user is collaborator | Block | block.reviewed.v1 |
RejectAIBlock | draftId, blockId | block.status=draft_ai | void | block.reviewed.v1, block.removed.v1 |
SubmitForReview | draftId | state=editing, >=1 block | CourseDraft | course_draft.updated.v1 |
ApproveReview | draftId | state=in_review, reviewer != author | CourseDraft | course_draft.updated.v1 |
RejectReview | draftId, reason | state=in_review | CourseDraft | course_draft.updated.v1 |
PublishDraft | draftId | state=approved, INV-4 pass | CourseDraft | course_draft.published.v1 (saga step 1) |
RequestAIGenerate | draftId, lessonId, intent, blockKind | state=editing | AIJob | block.ai_generated.v1 |
RequestAIImprove | draftId, blockId, instruction | state=editing, block exists | AIJob | block.ai_generated.v1 |
RequestAIQuiz | draftId, lessonId, difficulty, count | state=editing | AIJob | block.ai_generated.v1 |
ImportSCORM | draftId, scormZipUrl | state=editing | CourseDraft | course_draft.updated.v1 |
1.2 Queries
| Query | Input | Output |
|---|---|---|
GetDraft | draftId, tenantId | CourseDraftDTO |
ListDrafts | tenantId, filters, cursor, pageSize | Page |
GetBlock | draftId, blockId | BlockDTO |
GetPublishReadiness | draftId | PublishReadinessReport |
GetAIJobStatus | jobId | AIJobStatusDTO |
GetDraftHistory | draftId, cursor | Page |
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
| Step | Trigger | Action | Success event | Compensation |
|---|---|---|---|---|
| 1. building | PublishDraft command | Emit authoring.course_draft.published.v1 | content.play_package.built.v1 | Discard package, draft → approved |
| 2. cataloging | Step 1 success | — (content-service builds) | catalog.course_version.published.v1 | Unregister CourseVersion |
| 3. bundling | Step 2 success | — (catalog registers) | content.play_package.bundle.published.v1 | Revoke bundle |
| 4. ready | Step 3 success | Draft → published_idle | — | — |
| 5. timeout | 15 min elapsed | Draft → editing with error block | publish_saga.failed.v1 | All 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)
| Event | Handler | Action |
|---|---|---|
content.play_package.built.v1 | PublishSagaOrchestrator | Advance saga: building → cataloging |
catalog.course_version.published.v1 | PublishSagaOrchestrator | Advance saga: cataloging → bundling |
content.play_package.bundle.published.v1 | PublishSagaOrchestrator | Advance saga: bundling → ready |
media.asset.ready.v1 | MediaReadyHandler | Mark block media refs as resolved |
ai.completion.finished.v1 | AIJobManager | Attach AI result to draft block |
gdpr.subject_request.received.v1 | GDPRHandler | Anonymize/delete user data in drafts |
7. Transaction Boundaries
Every command execution is wrapped in a single Postgres transaction that includes:
- Read current aggregate state (with
SELECT ... FOR UPDATE) - Apply domain logic (immutable — new state returned)
- Write new aggregate state
- Write domain events to outbox table
- 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).