DDD Construction Pattern
How domain-driven design is implemented in tx-agent-kit
tx-agent-kit implements domain-driven design (DDD) with a specific construction pattern that separates concerns into well-defined layers. This pattern is enforced mechanically, not just documented.
Directory structure
Each domain in packages/core/src/domains/ follows this structure:
packages/core/src/domains/<domain>/
events.ts # PUBLIC cross-domain event contract — the ONLY importable file from sibling domains
domain/ # Entities, value objects, pure domain rules, event payload shapes
ports/ # Interfaces and capability contracts
application/ # Use-case orchestration
adapters/ # External system adapters (optional)
runtime/ # Layer wiring (optional)
ui/ # Presentation-facing helpers (optional)Persistence implementations live separately in packages/infra/db/src/repositories/, not inside the domain slice.
Cross-domain boundaries
The cardinal rule:
Events are public nouns. Ports are public verbs. Errors are private semantics.
| What | Cross-domain? | How |
|---|---|---|
| Domain events | Yes — via events.ts at domain root | The ONLY cross-domain import between sibling domains |
| Ports | No — wired through DI | Adapters bridge repos to ports; services depend on their own ports |
| Domain errors | No — translate at seams | Rich typed ADT errors inside a domain; translate at port/app boundary |
| Shared infra errors | Yes — via errors.ts | CoreError (unauthorized, notFound, etc.) at core package level |
The events.ts contract
events.ts lives at the domain root (not inside domain/) and is mechanically enforced as the single import surface other domains may touch. It contains:
- Event type discriminants (string literal constants like
'billing.credits_purchased') - Typed payload shapes (re-exported from
domain/*-events.ts) - Version constants (monotonically increasing per event type)
// packages/core/src/domains/billing/events.ts
export type {
BillingCreditsPurchasedEventPayload,
BillingUsageCapExceededEventPayload,
BillingWelcomeCreditGrantedEventPayload
} from './domain/billing-events.js'
export const billingEvents = {
creditsPurchased: 'billing.credits_purchased',
usageCapExceeded: 'billing.usage_cap_exceeded',
welcomeCreditGranted: 'billing.welcome_credit_granted'
} as const
export const billingEventVersions = {
'billing.credits_purchased': 1,
'billing.usage_cap_exceeded': 1,
'billing.welcome_credit_granted': 1
} as constConsumers import with import type only — events are data contracts, not runtime code. Other domains MUST NOT import internal services, repositories, or domain logic from a sibling; only events.ts. ESLint + scripts/lint/enforce-domain-event-contracts.mjs enforce this.
Error architecture
- Inside a domain — rich typed ADT errors (
Either<Result, DomainError>) - At port boundaries — translate to seam-specific error types that don't leak internals
- At application layer — compose and remap into
CoreError - At API layer — final translation to HTTP via
mapCoreError - Never import another domain's internal error types
Layer responsibilities
domain/
The innermost layer containing pure domain logic with zero dependencies on infrastructure:
// packages/core/src/domains/billing/domain/invoice.ts
export interface InvoiceRecord {
readonly id: string
readonly organizationId: string
readonly amount: number
readonly currency: string
readonly status: InvoiceStatus
readonly createdAt: Date
}
export type InvoiceStatus = "draft" | "sent" | "paid" | "cancelled"
export const canTransition = (
from: InvoiceStatus,
to: InvoiceStatus,
): boolean => {
const transitions: Record<InvoiceStatus, ReadonlyArray<InvoiceStatus>> = {
draft: ["sent", "cancelled"],
sent: ["paid", "cancelled"],
paid: [],
cancelled: [],
}
return transitions[from].includes(to)
}All functions in domain/ must be pure: no Date.now, new Date, Math.random, or any side effects. Only named exports are allowed (no default exports). The layer cannot import from packages/infra/db, effect, or any adapter. All *Record interfaces that ports reference must originate here.
ports/
Abstract interfaces defining what capabilities the domain needs without specifying how they are implemented:
// packages/core/src/domains/billing/ports/invoice-repository.port.ts
import type { InvoiceRecord } from "../domain/invoice.js"
export type InvoiceRepositoryPort = {
readonly findById: (id: string) => Effect.Effect<InvoiceRecord | null>
readonly findByOrganization: (organizationId: string) => Effect.Effect<ReadonlyArray<InvoiceRecord>>
readonly create: (input: CreateInvoiceInput) => Effect.Effect<InvoiceRecord>
readonly updateStatus: (id: string, status: InvoiceStatus) => Effect.Effect<InvoiceRecord>
}Port files must use export type and import record types from domain/. export interface declarations are banned. Ports can only import from domain/ and other ports/ files; they cannot reference concrete implementations.
application/
Use-case orchestration that composes domain logic with port-defined capabilities:
// packages/core/src/domains/billing/application/invoice.service.ts
import { canTransition } from "../domain/invoice.js"
import type { InvoiceRepositoryPort } from "../ports/invoice-repository.port.js"
export const makeInvoiceService = (repo: InvoiceRepositoryPort) => ({
transitionStatus: (id: string, newStatus: InvoiceStatus) =>
Effect.gen(function* () {
const invoice = yield* repo.findById(id)
if (!invoice) return yield* Effect.fail(new InvoiceNotFound({ id }))
if (!canTransition(invoice.status, newStatus)) {
return yield* Effect.fail(
new InvalidStatusTransition({ from: invoice.status, to: newStatus })
)
}
return yield* repo.updateStatus(id, newStatus)
}),
})The application layer can import from domain/, ports/, and other application/ files, but never from concrete infrastructure. It orchestrates calls to port methods and domain functions without containing SQL or HTTP calls itself.
Persistence in packages/infra/db/src/repositories/
Concrete repository implementations live in the database package, not inside the core domain:
// packages/infra/db/src/repositories/invoice.repository.ts
import type { InvoiceRepositoryPort } from "@tx-agent-kit/core/domains/billing/ports/invoice-repository.port.js"
export const makeInvoiceRepository = (db: DrizzleClient): InvoiceRepositoryPort => ({
findById: (id) => Effect.tryPromise(() =>
db.select().from(invoices).where(eq(invoices.id, id)).then(rows => rows[0] ?? null)
),
// ... other methods
})This separation ensures that packages/core has zero dependency on drizzle-orm or any database library.
What is NOT allowed in core domains
The structural invariant checks enforce these prohibitions:
| Prohibition | Rationale |
|---|---|
No repositories/ directory inside packages/core/src/domains/ | Persistence belongs in packages/infra/db |
No services/ directory inside packages/core/src/domains/ | Use application/ instead for consistency |
No imports from drizzle-orm, pg, or database-specific packages | Core must remain infrastructure-free |
No process.env reads | Use typed config modules |
No console.* calls | Use @tx-agent-kit/logging |