tx-agent-kit
Architecture

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.

WhatCross-domain?How
Domain eventsYes — via events.ts at domain rootThe ONLY cross-domain import between sibling domains
PortsNo — wired through DIAdapters bridge repos to ports; services depend on their own ports
Domain errorsNo — translate at seamsRich typed ADT errors inside a domain; translate at port/app boundary
Shared infra errorsYes — via errors.tsCoreError (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 const

Consumers 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:

ProhibitionRationale
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 packagesCore must remain infrastructure-free
No process.env readsUse typed config modules
No console.* callsUse @tx-agent-kit/logging

On this page