Structural Checks
Cross-file invariant validation via scripts/lint/enforce-domain-invariants.mjs.
The structural invariant checker at scripts/lint/enforce-domain-invariants.mjs validates cross-file relationships that ESLint cannot check. It reads multiple files, compares structures, and reports violations.
Table Parity Checks
Effect Schema Parity
For every pgTable(...) declaration in packages/infra/db/src/, there must be a corresponding file in packages/infra/db/src/effect-schemas/:
users table -> packages/infra/db/src/effect-schemas/users.ts
organizations -> packages/infra/db/src/effect-schemas/organizations.ts
invitations -> packages/infra/db/src/effect-schemas/invitations.tsEach schema file must satisfy the following requirements:
| Requirement | Detail |
|---|---|
| Import source | Must import from effect/Schema |
| Row schema export | Must export a *RowSchema constant |
| Row shape export | Must export a *RowShape type |
| Barrel re-export | Must be re-exported from effect-schemas/index.ts |
Factory Parity
For every table, there must be a factory in packages/infra/db/src/factories/:
users table -> packages/infra/db/src/factories/users.factory.ts
organizations -> packages/infra/db/src/factories/organizations.factory.tsEach factory must export a create*Factory function and be re-exported from factories/index.ts.
Domain Structure Validation
Required Folders
Every domain under packages/core/src/domains/ must contain the following:
| Required Folder | Constraint |
|---|---|
domain/ | At least one .ts file |
ports/ | At least one .ts file declaring Effect.Effect return types |
application/ | At least one non-index .ts file |
adapters/ | At least one .ts file importing from ports |
Forbidden Folders
Domains must not contain repositories/ or services/ directories. Use ports/ for contracts and adapters/ for implementations.
Layer Direction
The checker validates every import path within domain files:
| Source Layer | Allowed Imports |
|---|---|
domain | domain |
ports | domain, ports |
application | domain, ports, application |
adapters | domain, ports, adapters |
runtime | All layers |
ui | All layers |
Cross-domain imports are forbidden. Each domain is self-contained.
Kind Marker Validation
Repository Port Kinds
Every port file in packages/core/src/domains/*/ports/ must declare:
export const OrganizationRepositoryKind = 'crud' as constIf marked crud, the port must expose the full CRUD surface (list, getById, create, update, remove). If marked custom, it must not expose the full CRUD surface.
Route Kinds
Every route file in apps/api/src/routes/ must declare a kind marker. The checker validates that route kinds match their corresponding repository port kinds.
Source Hygiene
No TODO/FIXME/HACK
Placeholder comments are forbidden in source modules. Track work items in the issue tracker, not in code comments.
No Build Artifacts
Generated .js, .js.map, .d.ts, and .d.ts.map files are forbidden under src/ directories.
No Direct process.env
Source modules must read environment through dedicated env modules. Allowed env files are explicitly listed in the checker.
Integration Coverage Baselines
The checker verifies that critical integration suites exist and contain required markers:
| Suite | Requirement |
|---|---|
| API | Must exercise all critical endpoints |
| Web | Must cover all required components and pages |
| Worker | Must validate idempotent processing |
| Observability | Must cover all four services |
Test Colocation
All tests must be colocated with their source modules. The following patterns are forbidden:
| Forbidden Pattern | Reason |
|---|---|
__tests__/ directories | Tests must sit next to their source file |
.spec.ts files | Use .test.ts instead |
.integration.ts (without .test) | Use .integration.test.ts instead |
Every test file must have a colocated source module.
Domain Event Enforcement
The script scripts/lint/enforce-domain-event-contracts.mjs enforces ten rules that protect the integrity of the domain event system. Names are derived from event type strings via dot-split PascalCase conversion (e.g., organization.created → OrganizationCreatedEventPayload).
Rule 1 - Event Type Registry
All dot-separated event type strings used in eventType:, case, or === '...' patterns must be registered in domainEventTypes in packages/contracts/src/literals.ts. This prevents ad-hoc event types from slipping into domain or worker code without being centrally declared.
| Check | Detail |
|---|---|
| Scanned directories | packages/core/src, apps/worker/src |
| Patterns matched | eventType: '...', case '...', eventType === '...' |
| Filter | Only strings matching ^[a-z][a-z_]*\.[a-z][a-z_]*$ (ignores auth audit types) |
| Failure | Unregistered event type string used in source |
Rule 2 - Event Payload Interface Parity
Each registered event type must have a corresponding export interface <Type>EventPayload in the aggregate's domain/*-events.ts file.
| Check | Detail |
|---|---|
| Source of truth | domainEventTypes in packages/contracts/src/literals.ts |
| Expected location | packages/core/src/domains/<aggregate>/domain/*-events.ts |
| Expected export | export interface OrganizationCreatedEventPayload (derived via PascalCase) |
| Failure | Missing interface for a registered event type |
Rule 3 - Event Payload Schema Parity
Each registered event type must have a corresponding export const <Type>EventPayloadSchema in packages/temporal-client/src/types/.
| Check | Detail |
|---|---|
| Expected location | packages/temporal-client/src/types/*.ts |
| Expected export | export const OrganizationCreatedEventPayloadSchema (derived via PascalCase) |
| Failure | Missing schema constant for a registered event type |
Rule 5 - Transactional Event Write
Domain event inserts in repository files must appear inside a db.transaction() block or use a trx handle. This ensures atomicity between the business write and the outbox event.
| Check | Detail |
|---|---|
| Scanned files | All repository files in packages/infra/db/src/repositories/ (excluding domain-events.ts) |
| Pattern | .insert(domainEvents) with no preceding trx or .transaction( in 3000-char window |
| Failure | Domain event insert outside a transaction context |
Rule 6 - Event Handler Completeness
Every registered event type must have a case '...' handler in the worker's workflow dispatcher files (apps/worker/src/workflows*.ts).
| Check | Detail |
|---|---|
| Source of truth | domainEventTypes in contracts |
| Scanned files | apps/worker/src/workflows*.ts (non-test) |
| Failure | Missing case branch for a registered event type |
Rule 7 - Idempotent Workflow ID
Every startChild/executeChild call in apps/worker/src/workflows.ts must include a workflowId property containing event.id. This ensures deterministic, idempotent dispatch — if the same event is processed twice, Temporal returns WorkflowExecutionAlreadyStartedError instead of duplicating work.
| Check | Detail |
|---|---|
| Scanned file | apps/worker/src/workflows.ts |
| Extraction | Brace-balanced block capture of startChild/executeChild call arguments |
| Failure | Missing workflowId property, or workflowId value not containing event.id |
Rule 8 - No Payload as Casts
.payload as <Type> assertions are forbidden in worker source files. Use effect/Schema decode for event payload deserialization instead.
| Check | Detail |
|---|---|
| Scanned files | All .ts files under apps/worker/src/ (non-test) |
| Pattern | .payload as |
| Failure | Type assertion on event payload |
Rule 9 - Event Type Naming Convention
All event type strings in domainEventTypes must match the pattern ^[a-z][a-z_]*\.[a-z][a-z_]*$ — lowercase, dot-separated, with underscores allowed within segments.
| Check | Detail |
|---|---|
| Validated strings | Every entry in domainEventTypes |
| Valid examples | organization.created, billing.subscription_updated |
| Invalid examples | Organization.Created, org-created, org. |
Rule 11 - Retention Settings Completeness
Every table listed in retentionTableNames (from packages/contracts/src/literals.ts) must have a corresponding entry in the generated retention_settings reconcile schema. This prevents adding a table to the retention contract without providing its default retention configuration in the desired-state layer.
| Check | Detail |
|---|---|
| Source of truth | retentionTableNames array in packages/contracts/src/literals.ts |
| Validated against | Generated packages/infra/db/schemas/system-settings/reconcile_retention_settings.sql |
| Failure | Missing seed entry for a declared retention table |
Rule 12 - Domain Event Insert Helper
Repositories outside domain-events.ts must not use inline .insert(domainEvents).values(...) patterns. All domain event inserts must go through the shared helper insertDomainEventInTransaction(trx, ...) exported from the domain-events repository.
| Check | Detail |
|---|---|
| Scanned files | All repository files in packages/infra/db/src/repositories/ |
| Exemption | domain-events.ts (defines the helper itself) |
| Failure | Direct .insert(domainEvents).values(...) usage outside the exempted file |
This rule ensures that domain events are always written through a single, auditable code path with consistent validation and transactional guarantees.
Rule 13 - Exhaustive Switch Default
Every switch (event.eventType) dispatcher in apps/worker/src/workflows*.ts must include a default: case. This prevents silent drops when a new event type is registered but the dispatcher is not updated.
| Check | Detail |
|---|---|
| Scanned files | apps/worker/src/workflows*.ts (non-test) |
| Pattern | switch blocks dispatching on event.eventType |
| Failure | Missing default: case in event dispatcher |
Migration Naming Convention
Migration files in packages/infra/db/drizzle/migrations/ must follow the naming pattern NNNN_descriptive_name.sql where NNNN is a zero-padded four-digit prefix and the remainder uses lowercase with underscores.
| Check | Detail |
|---|---|
| Pattern | ^\d{4}_[a-z][a-z0-9_]*\.sql$ |
| Duplicate prefix detection | No two files may share the same numeric prefix |
| Failure | File name does not match convention, or duplicate prefix detected |
Test File Vitest Import
Every .test.ts and .integration.test.ts file must reference vitest. This catches stale or empty test files that would silently pass without exercising any assertions.
| Check | Detail |
|---|---|
| Scanned files | All *.test.ts and *.integration.test.ts files |
| Required markers | At least one of: vitest, describe, it(, test(, expect(, vi. |
| Failure | Test file with no vitest markers |