APPLICATION_LOGIC — theme-config-service
Sibling: DOMAIN_MODEL · API_CONTRACTS · EVENT_SCHEMAS · SECURITY_MODEL
Strategic anchors: 02 Enterprise Architecture §6 Hexagonal Layout · 04 Event-Driven Architecture · 08 AI Architecture · standards/SERVICE_TEMPLATE
This document describes how the application layer orchestrates the domain aggregates from DOMAIN_MODEL — use cases, the ports they depend on, the saga participation, and the orchestration flows that span more than one aggregate. It is framework-free; mapping to NestJS providers happens in src/infrastructure/.
1. Ports (interfaces only)
All ports live under src/application/ports/ and depend only on @ghasi/domain-primitives and the local domain types. Adapters live in src/infrastructure/adapters/.
1.1 Repository ports
export interface ThemeRepository {
findById(id: ThemeId, ctx: TenantCtx): Promise<Theme | null>;
findByTenantAndProperty(tenantId: TenantId, propertyId: PropertyId | null): Promise<Theme | null>;
list(tenantId: TenantId): Promise<Theme[]>;
save(theme: Theme, expectedVersion: number): Promise<Theme>; // OCC enforced
softDelete(id: ThemeId, by: UserId, now: ISODate): Promise<void>;
}
export interface ThemeVersionRepository {
findById(id: ThemeVersionId, ctx: TenantCtx): Promise<ThemeVersion | null>;
listByTheme(themeId: ThemeId, opts: { includeArchived?: boolean; limit: number; offset: number }): Promise<ThemeVersion[]>;
save(version: ThemeVersion, expectedVersion: number): Promise<ThemeVersion>;
bulkArchiveOthers(themeId: ThemeId, exceptVersionId: ThemeVersionId, reason: 'superseded'|'rolled_back', now: ISODate): Promise<number>;
}
export interface ThemePublicationRepository {
findActiveByTheme(themeId: ThemeId): Promise<ThemePublication | null>;
flipActive(input: {
themeId: ThemeId;
fromPublicationId: ThemePublicationId | null; // null on first publish
toVersionId: ThemeVersionId;
actor: UserId | null;
now: ISODate;
}): Promise<ThemePublication>; // single Postgres TX with outbox
history(themeId: ThemeId, limit: number): Promise<ThemePublication[]>;
}
export interface ContentBlockRepository {
findById(id: ContentBlockId, ctx: TenantCtx): Promise<ContentBlock | null>;
listByVersion(versionId: ThemeVersionId): Promise<ContentBlock[]>;
save(block: ContentBlock, expectedVersion: number): Promise<ContentBlock>;
delete(id: ContentBlockId, ctx: TenantCtx): Promise<void>;
cloneAllForVersion(srcVersionId: ThemeVersionId, dstVersionId: ThemeVersionId): Promise<ContentBlockId[]>;
}
export interface NavigationConfigRepository {
findByVersionAndSurface(versionId: ThemeVersionId, surface: NavSurface): Promise<NavigationConfig | null>;
listByVersion(versionId: ThemeVersionId): Promise<NavigationConfig[]>;
save(nav: NavigationConfig, expectedVersion: number): Promise<NavigationConfig>;
cloneAllForVersion(srcVersionId: ThemeVersionId, dstVersionId: ThemeVersionId): Promise<NavigationConfigId[]>;
}
export interface BookingFlowConfigRepository {
findByVersion(versionId: ThemeVersionId): Promise<BookingFlowConfig | null>;
save(cfg: BookingFlowConfig, expectedVersion: number): Promise<BookingFlowConfig>;
cloneForVersion(srcVersionId: ThemeVersionId, dstVersionId: ThemeVersionId): Promise<BookingFlowId>;
}
export interface EmailThemeRepository {
findByVersion(versionId: ThemeVersionId): Promise<EmailTheme | null>;
findActiveByTenant(tenantId: TenantId, propertyId: PropertyId | null): Promise<EmailTheme | null>; // hot read for notification-service
save(theme: EmailTheme, expectedVersion: number): Promise<EmailTheme>;
cloneForVersion(srcVersionId: ThemeVersionId, dstVersionId: ThemeVersionId): Promise<EmailThemeId>;
}
export interface LocalePackRepository {
findByVersionAndLocale(versionId: ThemeVersionId, locale: Locale): Promise<LocalePack | null>;
listByVersion(versionId: ThemeVersionId): Promise<LocalePack[]>;
save(pack: LocalePack, expectedVersion: number): Promise<LocalePack>;
cloneAllForVersion(srcVersionId: ThemeVersionId, dstVersionId: ThemeVersionId): Promise<void>;
}
export interface LayoutPresetRegistryRepository {
findById(id: LayoutPresetId): Promise<LayoutPreset | null>;
findByKey(key: string): Promise<LayoutPreset | null>;
list(opts: { activeOnly?: boolean }): Promise<LayoutPreset[]>;
}
export interface PreviewTokenRepository {
save(token: PreviewToken): Promise<PreviewToken>;
findByHash(tokenHash: string): Promise<PreviewToken | null>;
revoke(id: PreviewTokenId, by: UserId, now: ISODate): Promise<void>;
revokeAllForVersion(versionId: ThemeVersionId, now: ISODate): Promise<number>;
recordAccess(id: PreviewTokenId, now: ISODate): Promise<void>;
}
1.2 Infrastructure ports
export interface BundleStoragePort {
// Upload an immutable bundle object; returns the canonical gs:// URL + sha256 + byte size.
upload(input: {
tenantId: TenantId;
themeId: ThemeId;
versionOrdinal: number;
bundle: BundleArtifact; // { json: object; cssVars: string; emailTheme: object; meta: object }
cacheTag: string; // 'theme:<themeId>'
}): Promise<{ url: string; sha256: string; sizeGzippedBytes: number }>;
headObject(url: string): Promise<{ exists: boolean; sha256?: string; sizeBytes?: number }>;
}
export interface CdnInvalidationPort {
invalidateByTag(input: { cacheTag: string; reason: string; correlationId: RequestId }): Promise<{ invalidationId: string; queuedAt: ISODate }>;
}
export interface AssetIntegrityClient {
// Validates that every MediaRef URL is reachable (HEAD) and matches its expected
// contentType + size budget per file-storage-service contract.
validateMany(refs: ReadonlyArray<{ ref: MediaRef; expectedKind: 'logo'|'hero'|'video'|'photo'|'icon' }>): Promise<AssetIntegrityReport>;
}
export interface AIClient {
// HITL-gated palette suggestion (returns drafted tokens + provenance; not auto-applied)
suggestPalette(input: { tenantId: TenantId; primaryColor: HexColor; brandKeywords?: string[]; correlationId: RequestId }): Promise<{ tokens: Partial<ColorTokens>; provenance: AIProvenance }>;
// HITL-gated translation drafting for missing locale entries
draftTranslations(input: {
tenantId: TenantId;
sourceLocale: Locale;
targetLocale: Locale;
keys: ReadonlyArray<{ key: string; sourceText: string; context?: string }>;
correlationId: RequestId;
}): Promise<{ entries: ReadonlyMap<string, string>; provenance: AIProvenance }>;
// HITL-gated content draft (e.g., draft "about us" in target locale)
draftContent(input: {
tenantId: TenantId;
targetLocale: Locale;
kind: ContentBlockKind;
sourceMarkup?: I18nMarkup;
propertyContext?: { name: string; locationLabel: string; amenities: string[] };
correlationId: RequestId;
}): Promise<{ markup: MarkupEntry; provenance: AIProvenance }>;
}
export interface TenantConfigClient {
// Reads per-tenant default locale, currency, enabled hosts; cached in Memorystore.
getTenantConfig(tenantId: TenantId): Promise<{
defaultLocale: Locale;
enabledLocales: Locale[];
currency: string; // ISO-4217
hosts: ReadonlyArray<{ host: string; propertyId: PropertyId | null }>;
}>;
}
export interface EventPublisher {
// Outbox-backed; called *inside* the same Postgres TX as the aggregate save.
publish(envelope: EventEnvelope): Promise<void>;
publishMany(envelopes: ReadonlyArray<EventEnvelope>): Promise<void>;
}
export interface PublishedBundleCachePort {
set(key: string, bundleUrl: string, ttlSeconds: number): Promise<void>;
get(key: string): Promise<string | null>;
invalidate(keyPattern: string): Promise<number>;
}
export interface UnitOfWork {
withTransaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
}
export interface Clock { nowIso(): ISODate }
export interface IdGenerator { theme(): ThemeId; themeVersion(): ThemeVersionId; themePublication(): ThemePublicationId; contentBlock(): ContentBlockId; navigationConfig(): NavigationConfigId; bookingFlow(): BookingFlowId; emailTheme(): EmailThemeId; previewToken(): PreviewTokenId; secret(bytes?: number): string }
export interface Hasher { sha256Hex(input: string | Buffer): string }
export interface ContrastChecker { ratio(fg: HexColor, bg: HexColor): number; passesAA(fg: HexColor, bg: HexColor, isLargeText?: boolean): boolean }
2. Use cases (commands)
Every use case is a class implementing UseCase<Cmd, Result> and is invoked by exactly one controller method. All use cases run inside a UnitOfWork.withTransaction boundary; events are queued via the outbox-backed EventPublisher inside the same transaction.
2.1 ProvisionDefaultThemeUseCase
Trigger: Pub/Sub subscription on melmastoon.tenant.created.v1.
Effect: Create a Theme + an initial ThemeVersion cloned from the platform melmastoon-default scaffold, then publish it atomically so the booking flow is live day-zero.
input = { tenantId, primaryLocale, currency, hostHints }
output = { themeId, publishedVersionId }
Steps:
- Idempotency: if a
Themealready exists for(tenantId, propertyId=null), exit (already provisioned). - Resolve the platform scaffold (a hard-coded constant
MELMASTOON_DEFAULT_SCAFFOLDinapplication/scaffolds/). - Construct
ThemeAggregate.create({ tenantId, defaultLocale: primaryLocale, enabledLocales: [primaryLocale, 'en-US'], fallbackChain: [primaryLocale, 'en-US'] }). - Construct an initial
ThemeVersionfrom the scaffold (tokens, layout selections, default content blocks forhomeanddetailsurfaces, default navigation, default booking flow withallowCashOnArrival: truefor AF/PK markets, default email theme). - Persist Theme + Version + child aggregates.
- Run
PublishThemeVersionUseCasesynchronously (skip the HITL gate because no AI was used). - Emit
theme.draft_created.v1,theme.published.v1,theme.cdn_cache_invalidated.v1.
2.2 CreateThemeVersionUseCase
Trigger: POST /api/v1/themes/:id/versions.
Effect: Create a new draft version, cloned from the active version (default) or blank.
input = { themeId, source: 'clone_active' | 'blank', actor }
output = { versionId, ordinal, version }
Steps:
- Load
Theme(RLS-scoped totenantId). - Determine
ordinal = max(version.ordinal) + 1fromThemeVersionRepository.listByTheme. - If
source === 'clone_active'andtheme.activePublicationId !== null:- Load the active version + all child aggregates (content blocks, nav configs, booking flow, email theme, locale packs).
- Construct a new
ThemeVersionwith cloned tokens / selections, then have the child repositoriescloneAll*rows under the new versionId.
- Else: scaffold from the platform
MELMASTOON_DEFAULT_SCAFFOLD. - Persist; emit
theme.draft_created.v1.
2.3 PatchThemeVersionUseCase
Trigger: PATCH /api/v1/theme-versions/:id.
Effect: Apply a partial mutation to a draft version (tokens, layout selections, etc.).
input = { versionId, ifMatchVersion, patch, actor }
output = { version, validationWarnings }
Steps:
- Load version; assert
status ∈ {draft, preview_ready}(elseVersionImmutableError). - Apply patch via
ThemeVersionAggregate.applyPatch(patch); this re-validates token schema, contrast (warning at draft, hard at publish), and asset budgets per DOMAIN_MODEL §6. - If
status === 'preview_ready', transition todraftandpreviewTokens.revokeAllForVersion(versionId); emittheme.preview_revoked.v1for each. - Persist with OCC
expectedVersion = ifMatchVersion. - Emit
theme.draft_updated.v1(andtheme.layout_preset_changed.v1if layout selection changed).
2.4 MintPreviewTokenUseCase
Trigger: POST /api/v1/theme-versions/:id/preview.
Effect: Mint a signed preview token; transition the version to preview_ready.
input = { versionId, ttlHours, note, actor }
output = { previewUrl, tokenId, expiresAt }
Steps:
- Load version; assert
status === 'draft' || 'preview_ready'. - Validate
ttlHours ≤ 168. - Generate secret =
IdGenerator.secret(32); hash =Hasher.sha256Hex(secret). - Build
PreviewTokenwithtokenHash = hash,expiresAt = now + ttlHours. - Build the preview bundle once and cache (
PublishedBundleCachePort.set('theme:preview:<tokenHash>', bundleUrl, ttl)). - Persist
PreviewToken; transition version topreview_ready(idempotent if already there). - Emit
theme.preview_generated.v1. - Return
previewUrl = https://preview.melmastoon.app/<base64url(tokenId + ':' + secret)>.
The secret never persists; only the SHA-256 hash. Validation in GetPreviewBundleUseCase recomputes the hash and compares.
2.5 PublishThemeVersionUseCase
Trigger: POST /api/v1/theme-versions/:id/publish.
Effect: Atomic publish flip with full validation, GCS upload, cache swap, CDN invalidation.
input = { versionId, ifMatchVersion, actor, allowBrokenAssets?: boolean (admin-only) }
output = { publicationId, bundleUrl, version }
Steps (the most important orchestration in this service):
- Load version + parent theme + all child aggregates (content blocks, nav, booking-flow, email-theme, locale packs).
- Final validation pass:
- Token contrast (WCAG AA on every documented foreground/background pair) — hard block.
- RTL parity (every spacing token has logical-property derivative) — hard block.
- Asset integrity (
AssetIntegrityClient.validateMany(allRefs)) — hard block unlessallowBrokenAssets. - Layout preset existence + RTL support for any RTL-enabled locale.
- Booking-flow consistency (
BookingFlowConfiginvariants). - LocalePack completeness — warning, not block.
- HITL: if any aggregate carries
aiProvenance, requireactorto be approver-eligible (RBACtheme:publish:ai_drafted); if missing, return403 MELMASTOON.AI.HITL_REQUIRED.
- Build bundle — pure function
buildBundle(version, children, tenantConfig)produces{ json, cssVars, emailTheme, meta }. Bundle is canonicalised (sorted keys) so SHA-256 is reproducible. - Upload to GCS via
BundleStoragePort.upload({...cacheTag: 'theme:<themeId>'}). Returns{ url, sha256, sizeGzippedBytes }. - Open transaction:
themeVersionRepository.save({ ...version, status: 'published', publishedAt: now, publishedBy: actor, publishedBundleUrl: url, publishedBundleSha256: sha256, publishedBundleSize: sizeGzippedBytes }, expectedVersion: ifMatchVersion).themeVersionRepository.bulkArchiveOthers(themeId, versionId, 'superseded', now)— flips the previously-published version (if any) toarchived.themePublicationRepository.flipActive({ themeId, fromPublicationId: theme.activePublicationId, toVersionId: versionId, actor, now })— inserts a newThemePublicationrow, flips a partial unique-index swap.- Update
theme.activePublicationId. eventPublisher.publishMany([theme.published.v1, theme.tokens_changed.v1 (with diff vs previous), theme.cdn_cache_invalidated.v1, theme.version_archived.v1 (for the superseded one)])— all into the outbox in this same TX.
- Post-commit (best-effort, retried):
PublishedBundleCachePort.set('theme:<themeId>:published', url, 3600).PublishedBundleCachePort.set('theme:<themeId>:email', emailThemeJsonUrl, 600).cdnInvalidationPort.invalidateByTag({ cacheTag: 'theme:<themeId>', reason: 'publish', correlationId }).previewTokenRepository.revokeAllForVersion(versionId, now)— this version is now public; preview tokens for it are no longer needed.
- On any pre-commit failure: transaction rolls back; bundle uploaded to GCS becomes an orphan (cleaned by the GCS lifecycle rule); version remains in its prior state; emit
theme.publish_rejected.v1.
Idempotency: Idempotency-Key deduped at the controller; if the request retries after a successful publish, return 200 OK with the same publicationId.
2.6 RollbackThemeUseCase
Trigger: POST /api/v1/themes/:id/rollback.
input = { themeId, toVersionId, actor }
output = { publicationId, bundleUrl }
Steps:
- Load theme + target version; assert target is one of the previous published-then-archived versions for this theme (no rolling back to a draft from another theme).
- Reuse the bundle URL already on the target version (
publishedBundleUrl); if missing (extremely unlikely on a previously-published version), rebuild from snapshot — pure function, deterministic. - Inside one transaction: archive the currently-active version with
archivedReason='rolled_back', flipThemePublicationto a new row pointing at the target, transition the target back topublished. Updatetheme.activePublicationId. - Emit
theme.rolled_back.v1(carriesfromVersionId,toVersionId),theme.tokens_changed.v1(diff vs the rolled-back version),theme.cdn_cache_invalidated.v1,theme.version_archived.v1(for the rolled-back version). - Post-commit: cache swap + CDN invalidation as in
PublishThemeVersionUseCase.
2.7 AddLocaleUseCase / RemoveLocaleUseCase
addLocale: input = { themeId, ifMatchVersion, locale, actor } → { theme }
removeLocale: input = { themeId, ifMatchVersion, locale, actor } → { theme }
AddLocale: append to enabledLocales; recompute fallbackChain to include the new locale (defaults to inserting just before defaultLocale); scaffold an empty LocalePack for any in-flight draft version. Emit theme.locale_added.v1.
RemoveLocale: reject if locale === defaultLocale (DefaultLocaleRequiredError); reject if any published or preview_ready version has copy in this locale that's referenced by an active CTA (LocaleInUseError); else remove from enabledLocales, prune fallbackChain, archive the LocalePack rows for in-flight drafts. Emit theme.locale_removed.v1.
2.8 UpdateBookingFlowConfigUseCase
PATCH /api/v1/booking-flow-configs/:id — apply patch, validate against canonical step + field registry, persist with OCC, emit theme.booking_flow_config_updated.v1.
2.9 UpdateEmailThemeUseCase
PATCH /api/v1/email-themes/:id — validate token contrast for email-safe pairs, validate fontFamilyEmail against the safe list, persist, emit theme.email_theme_updated.v1. Bust the theme:<themeId>:email Memorystore key only on publish (drafts don't affect notification rendering).
2.10 UpsertContentBlockUseCase, DeleteContentBlockUseCase, UpdateNavigationUseCase
Standard CRUD pattern with OCC, allow-list sanitisation for body, ordinal uniqueness check, route registry check (for nav), and corresponding content_block.* / theme.draft_updated.v1 events.
2.11 RequestAiPaletteSuggestionUseCase
Trigger: POST /api/v1/themes/:id/ai-suggest-palette.
input = { themeId, primaryColor, brandKeywords?, actor }
output = { suggestionId, draftedTokens, provenance, hitlTaskUrl }
Steps:
- RBAC:
theme:authoror higher. - Call
aiClient.suggestPalette({ tenantId, primaryColor, brandKeywords, correlationId })— orchestrator-routed; returns{ tokens, provenance }. - Do not auto-apply. Persist a
PaletteSuggestionrow (read-model, not an aggregate) keyed bysuggestionId. Return it to the caller along with ahitlTaskUrllinking to the backoffice approval surface. - Approval (separate use case
ApplyAiPaletteSuggestionUseCaseinvoked from the HITL surface): merges drafted tokens onto the active draftThemeVersion, attachesaiProvenance, setsapprovedBy/approvedAt, then proceeds as a normal patch.
2.12 RequestAiTranslationDraftUseCase and ApplyAiTranslationDraftUseCase
Symmetric pattern: request returns drafted entries with provenance; apply is HITL-gated and writes through LocalePackRepository.save with the provenance attached.
2.13 Consumed-event handlers
| Event | Handler use case |
|---|---|
melmastoon.tenant.created.v1 | ProvisionDefaultThemeUseCase (§2.1) |
melmastoon.tenant.deleted.v1 | PurgeTenantThemesUseCase (soft-delete + schedule hard purge per retention policy in 30 days) |
melmastoon.tenant.config_updated.v1 | RefreshTenantFormattingUseCase (re-emits theme.tokens_changed.v1 only if currency / phone format / firstDayOfWeek changed for the active publication; re-builds bundle) |
melmastoon.media.deleted.v1 | DetectBrokenAssetUseCase (scan the active published version + all preview_ready versions; if the URL is referenced, emit theme.broken_asset_detected.v1 and post a backoffice alert) |
melmastoon.property.created.v1 (Phase 2) | ProvisionPropertyOverrideThemeUseCase (gated by chain_branding feature flag) |
All event handlers run inside a UnitOfWork.withTransaction, are idempotent by eventId, and write through an inbox table for de-duplication per the platform pattern in 04 §10.
3. Queries (CQRS read models)
The service is mostly write-heavy at authoring time and read-heavy at delivery time. Read APIs go through dedicated query handlers with cache-aside semantics; they do not load full aggregates.
| Query | Read model | Cache strategy |
|---|---|---|
GetPublishedBundleByThemeQuery | published_theme_view (mat-view of theme + active publication + bundle URL + cache tag) | Memorystore key theme:<themeId>:published (TTL 1h, invalidated on publish) |
GetPublishedBundleByHostQuery | host_to_theme_view (mat-view: host → tenantId → themeId) | Memorystore key theme:by-host:<host> (TTL 5 min, invalidated on tenant config update + publish) |
GetEmailThemeForRenderingQuery | published_email_theme_view | Memorystore key theme:<themeId>:email (TTL 10 min) |
ListThemeVersionsQuery | direct Postgres scan with RLS | none |
GetThemeVersionForEditQuery | direct Postgres read, joining child aggregates | none |
ResolveLayoutPresetCatalogQuery | platform-global mat-view | Memorystore (TTL 1h) |
GetPreviewBundleQuery | direct Postgres read of preview_token + bundle from GCS | Memorystore key theme:preview:<hash> (TTL = token TTL) |
Read models live under src/application/queries/; their materialisation strategy is documented in DATA_MODEL §6.
4. Saga participation
This service is not the saga orchestrator for any cross-service flow. It participates in two upstream sagas:
4.1 Tenant onboarding (orchestrated by tenant-service)
tenant-service theme-config-service
│ │
│ tenant.created.v1 │
├─────────────────────────────▶│ ProvisionDefaultThemeUseCase
│ │ 1. create Theme
│ │ 2. create initial ThemeVersion (scaffold)
│ │ 3. publish atomically
│ │ 4. emit theme.draft_created.v1
│ │ theme.published.v1
│ │ theme.cdn_cache_invalidated.v1
│ ◀── theme.published.v1 ─────│
│ → completes the onboarding │
│ saga step "BRAND_READY" │
Failure path: if any step throws, we emit theme.publish_rejected.v1 and the tenant-onboarding saga compensates by marking the brand step degraded (the tenant can still operate; backoffice prompts the admin to complete branding). We never block tenant creation on this; the inbox-handler retries on the next consumed-event delivery.
4.2 Asset deletion notification (cooperative, no compensation)
file-storage-service emits melmastoon.media.deleted.v1 when a tenant admin removes an asset that turns out to still be referenced. Our handler emits theme.broken_asset_detected.v1 and surfaces a backoffice alert; we do not auto-rebuild or auto-republish.
5. Orchestration: building the published bundle
The bundle build is a pure function; this is critical because it runs in two places (PublishThemeVersionUseCase and RollbackThemeUseCase) and must produce byte-identical output for the same input so SHA-256 verification works.
function buildBundle(input: {
version: ThemeVersion;
blocks: ContentBlock[];
navs: NavigationConfig[];
bookingFlow: BookingFlowConfig;
emailTheme: EmailTheme;
localePacks: LocalePack[];
tenantConfig: { defaultLocale: Locale; enabledLocales: Locale[]; currency: string };
}): BundleArtifact {
const tokens = resolveTokens(input.version.tokens); // expand spacing.start/end, etc.
const cssVars = renderCssVariables(tokens); // sorted keys
const emailThemeBlock = renderEmailTheme(input.emailTheme, tokens);
const i18n = sortLocalePacks(input.localePacks);
const layouts = input.version.layoutSelections;
const blocks = sortBlocks(input.blocks);
const navs = sortNavs(input.navs);
const bookingFlow = canonicalizeBookingFlow(input.bookingFlow);
return {
json: canonicalize({
schemaVersion: 1,
tenantConfig: input.tenantConfig,
tokens,
layouts,
blocks,
navs,
bookingFlow,
i18n,
}),
cssVars,
emailTheme: emailThemeBlock,
meta: {
themeId: input.version.themeId,
themeVersionId: input.version.id,
ordinal: input.version.ordinal,
builtAt: input.version.publishedAt ?? '<at-publish>',
},
};
}
canonicalize recursively sorts object keys, normalises numeric precision (6 decimal places), and trims insignificant whitespace. The resulting JSON byte stream is gzipped (level 9) before SHA-256 + upload.
6. Concurrency, idempotency, ordering
- OCC everywhere. Every mutating use case loads the aggregate's
version, applies the change, andsave(expectedVersion). Mismatch →412 PRECONDITION_FAILED. - Idempotency-Key. All POST/PATCH endpoints require an
Idempotency-Key; deduplication window 24h via theidempotency_keystable. Replays return the original response. - Outbox.
EventPublisherwrites tooutboxinside the same transaction as the aggregate save; a separate publisher worker drains the outbox to Pub/Sub at-least-once. - Inbox. Consumed events are deduplicated by
eventIdin theconsumed_eventstable per 04 §10. - Publish ordering. Two concurrent publish attempts for the same theme race on a
SELECT ... FOR UPDATEofthemes.id; the loser seesMELMASTOON.THEME.PUBLISH_CONFLICT. - Preview-token revocation. Atomic with the state transition that triggered it (further edit → revoke; publish → revoke).
7. Composition root (DI overview)
src/infrastructure/composition.ts wires:
ThemeRepository ← PostgresThemeRepository
ThemeVersionRepository ← PostgresThemeVersionRepository
ThemePublicationRepository ← PostgresThemePublicationRepository
ContentBlockRepository ← PostgresContentBlockRepository
NavigationConfigRepository ← PostgresNavigationConfigRepository
BookingFlowConfigRepository ← PostgresBookingFlowConfigRepository
EmailThemeRepository ← PostgresEmailThemeRepository
LocalePackRepository ← PostgresLocalePackRepository
LayoutPresetRegistryRepository ← PostgresLayoutPresetRegistryRepository (read from platform-global table)
PreviewTokenRepository ← PostgresPreviewTokenRepository
BundleStoragePort ← GcsBundleStorageAdapter
CdnInvalidationPort ← CloudCdnInvalidationAdapter
PublishedBundleCachePort ← MemorystoreBundleCacheAdapter
EventPublisher ← OutboxPubSubPublisher
AssetIntegrityClient ← FileStorageHttpClientAdapter
AIClient ← AiOrchestratorHttpClientAdapter
TenantConfigClient ← TenantServiceHttpClientAdapter (with Memorystore cache)
UnitOfWork ← DrizzleUnitOfWork
Clock ← SystemClock
IdGenerator ← UlidIdGenerator
Hasher ← NodeCryptoHasher
ContrastChecker ← Wcag21ContrastChecker
The composition root is the only place outside src/infrastructure/ where adapters and ports are paired; the application layer never imports an adapter type.
8. Background workers
| Worker | Trigger | Responsibility |
|---|---|---|
outbox-publisher | Postgres logical replication slot or polling every 250 ms | Drains outbox to Pub/Sub at-least-once; marks rows published |
cdn-invalidation-retrier | Polling every 5 s | Retries failed CDN invalidations with exponential backoff (max 6); alerts after 5 min |
preview-token-sweeper | Cron every 1 h | Hard-deletes PreviewToken rows past expiresAt + 7 days |
broken-asset-scanner | Cron daily 02:30 UTC + on media.deleted.v1 | Scans active published versions for broken MediaRef URLs; emits theme.broken_asset_detected.v1 for new failures |
tenant-purge | Cron every 1 h | Hard-purges themes for tenants soft-deleted ≥ 30 days ago |
cache-warmer | On-demand (post-publish) | Pre-fetches the new bundle through the BFFs to warm CDN edges in the tenant's primary regions |
All workers run as long-lived Cloud Run services with dedicated min replicas (see DEPLOYMENT_TOPOLOGY).
9. References
- Domain shape:
DOMAIN_MODEL - HTTP boundary:
API_CONTRACTS - Event payloads:
EVENT_SCHEMAS - Persistence:
DATA_MODEL - AI HITL contract:
AI_INTEGRATION,docs/08-ai-architecture.md - Outbox / inbox patterns:
docs/04-event-driven-architecture.md§10