Skip to main content

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:

  1. Idempotency: if a Theme already exists for (tenantId, propertyId=null), exit (already provisioned).
  2. Resolve the platform scaffold (a hard-coded constant MELMASTOON_DEFAULT_SCAFFOLD in application/scaffolds/).
  3. Construct ThemeAggregate.create({ tenantId, defaultLocale: primaryLocale, enabledLocales: [primaryLocale, 'en-US'], fallbackChain: [primaryLocale, 'en-US'] }).
  4. Construct an initial ThemeVersion from the scaffold (tokens, layout selections, default content blocks for home and detail surfaces, default navigation, default booking flow with allowCashOnArrival: true for AF/PK markets, default email theme).
  5. Persist Theme + Version + child aggregates.
  6. Run PublishThemeVersionUseCase synchronously (skip the HITL gate because no AI was used).
  7. 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:

  1. Load Theme (RLS-scoped to tenantId).
  2. Determine ordinal = max(version.ordinal) + 1 from ThemeVersionRepository.listByTheme.
  3. If source === 'clone_active' and theme.activePublicationId !== null:
    • Load the active version + all child aggregates (content blocks, nav configs, booking flow, email theme, locale packs).
    • Construct a new ThemeVersion with cloned tokens / selections, then have the child repositories cloneAll* rows under the new versionId.
  4. Else: scaffold from the platform MELMASTOON_DEFAULT_SCAFFOLD.
  5. 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:

  1. Load version; assert status ∈ {draft, preview_ready} (else VersionImmutableError).
  2. 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.
  3. If status === 'preview_ready', transition to draft and previewTokens.revokeAllForVersion(versionId); emit theme.preview_revoked.v1 for each.
  4. Persist with OCC expectedVersion = ifMatchVersion.
  5. Emit theme.draft_updated.v1 (and theme.layout_preset_changed.v1 if 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:

  1. Load version; assert status === 'draft' || 'preview_ready'.
  2. Validate ttlHours ≤ 168.
  3. Generate secret = IdGenerator.secret(32); hash = Hasher.sha256Hex(secret).
  4. Build PreviewToken with tokenHash = hash, expiresAt = now + ttlHours.
  5. Build the preview bundle once and cache (PublishedBundleCachePort.set('theme:preview:<tokenHash>', bundleUrl, ttl)).
  6. Persist PreviewToken; transition version to preview_ready (idempotent if already there).
  7. Emit theme.preview_generated.v1.
  8. 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):

  1. Load version + parent theme + all child aggregates (content blocks, nav, booking-flow, email-theme, locale packs).
  2. 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 unless allowBrokenAssets.
    • Layout preset existence + RTL support for any RTL-enabled locale.
    • Booking-flow consistency (BookingFlowConfig invariants).
    • LocalePack completeness — warning, not block.
    • HITL: if any aggregate carries aiProvenance, require actor to be approver-eligible (RBAC theme:publish:ai_drafted); if missing, return 403 MELMASTOON.AI.HITL_REQUIRED.
  3. Build bundle — pure function buildBundle(version, children, tenantConfig) produces { json, cssVars, emailTheme, meta }. Bundle is canonicalised (sorted keys) so SHA-256 is reproducible.
  4. Upload to GCS via BundleStoragePort.upload({...cacheTag: 'theme:<themeId>'}). Returns { url, sha256, sizeGzippedBytes }.
  5. Open transaction:
    1. themeVersionRepository.save({ ...version, status: 'published', publishedAt: now, publishedBy: actor, publishedBundleUrl: url, publishedBundleSha256: sha256, publishedBundleSize: sizeGzippedBytes }, expectedVersion: ifMatchVersion).
    2. themeVersionRepository.bulkArchiveOthers(themeId, versionId, 'superseded', now) — flips the previously-published version (if any) to archived.
    3. themePublicationRepository.flipActive({ themeId, fromPublicationId: theme.activePublicationId, toVersionId: versionId, actor, now }) — inserts a new ThemePublication row, flips a partial unique-index swap.
    4. Update theme.activePublicationId.
    5. 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.
  6. Post-commit (best-effort, retried):
    1. PublishedBundleCachePort.set('theme:<themeId>:published', url, 3600).
    2. PublishedBundleCachePort.set('theme:<themeId>:email', emailThemeJsonUrl, 600).
    3. cdnInvalidationPort.invalidateByTag({ cacheTag: 'theme:<themeId>', reason: 'publish', correlationId }).
    4. previewTokenRepository.revokeAllForVersion(versionId, now) — this version is now public; preview tokens for it are no longer needed.
  7. 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:

  1. 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).
  2. 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.
  3. Inside one transaction: archive the currently-active version with archivedReason='rolled_back', flip ThemePublication to a new row pointing at the target, transition the target back to published. Update theme.activePublicationId.
  4. Emit theme.rolled_back.v1 (carries fromVersionId, 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).
  5. 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:

  1. RBAC: theme:author or higher.
  2. Call aiClient.suggestPalette({ tenantId, primaryColor, brandKeywords, correlationId }) — orchestrator-routed; returns { tokens, provenance }.
  3. Do not auto-apply. Persist a PaletteSuggestion row (read-model, not an aggregate) keyed by suggestionId. Return it to the caller along with a hitlTaskUrl linking to the backoffice approval surface.
  4. Approval (separate use case ApplyAiPaletteSuggestionUseCase invoked from the HITL surface): merges drafted tokens onto the active draft ThemeVersion, attaches aiProvenance, sets approvedBy/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

EventHandler use case
melmastoon.tenant.created.v1ProvisionDefaultThemeUseCase (§2.1)
melmastoon.tenant.deleted.v1PurgeTenantThemesUseCase (soft-delete + schedule hard purge per retention policy in 30 days)
melmastoon.tenant.config_updated.v1RefreshTenantFormattingUseCase (re-emits theme.tokens_changed.v1 only if currency / phone format / firstDayOfWeek changed for the active publication; re-builds bundle)
melmastoon.media.deleted.v1DetectBrokenAssetUseCase (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.

QueryRead modelCache strategy
GetPublishedBundleByThemeQuerypublished_theme_view (mat-view of theme + active publication + bundle URL + cache tag)Memorystore key theme:<themeId>:published (TTL 1h, invalidated on publish)
GetPublishedBundleByHostQueryhost_to_theme_view (mat-view: host → tenantId → themeId)Memorystore key theme:by-host:<host> (TTL 5 min, invalidated on tenant config update + publish)
GetEmailThemeForRenderingQuerypublished_email_theme_viewMemorystore key theme:<themeId>:email (TTL 10 min)
ListThemeVersionsQuerydirect Postgres scan with RLSnone
GetThemeVersionForEditQuerydirect Postgres read, joining child aggregatesnone
ResolveLayoutPresetCatalogQueryplatform-global mat-viewMemorystore (TTL 1h)
GetPreviewBundleQuerydirect Postgres read of preview_token + bundle from GCSMemorystore 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, and save(expectedVersion). Mismatch → 412 PRECONDITION_FAILED.
  • Idempotency-Key. All POST/PATCH endpoints require an Idempotency-Key; deduplication window 24h via the idempotency_keys table. Replays return the original response.
  • Outbox. EventPublisher writes to outbox inside 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 eventId in the consumed_events table per 04 §10.
  • Publish ordering. Two concurrent publish attempts for the same theme race on a SELECT ... FOR UPDATE of themes.id; the loser sees MELMASTOON.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

WorkerTriggerResponsibility
outbox-publisherPostgres logical replication slot or polling every 250 msDrains outbox to Pub/Sub at-least-once; marks rows published
cdn-invalidation-retrierPolling every 5 sRetries failed CDN invalidations with exponential backoff (max 6); alerts after 5 min
preview-token-sweeperCron every 1 hHard-deletes PreviewToken rows past expiresAt + 7 days
broken-asset-scannerCron daily 02:30 UTC + on media.deleted.v1Scans active published versions for broken MediaRef URLs; emits theme.broken_asset_detected.v1 for new failures
tenant-purgeCron every 1 hHard-purges themes for tenants soft-deleted ≥ 30 days ago
cache-warmerOn-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