tx-agent-kit
Packages

Core

DDD domain slices with strict layer direction, named exports, and determinism governance.

Core (packages/core)

The core package contains the domain logic for the entire system. It is organized as DDD domain slices under src/domains/, with each domain following a strict folder structure and layer dependency direction.

Domain Structure

Each domain slice must contain these folders and files:

packages/core/src/domains/<domain>/
  events.ts     # PUBLIC cross-domain event contract (the ONLY importable file)
  domain/       # Entities, value objects, pure business rules
  ports/        # Capability contracts (Effect Context.Tag services)
  application/  # Use-case orchestration (Effect services)
  adapters/     # External system adapter implementations

The runtime/ folder (layer wiring and composition) and ui/ folder (presentation-facing helpers) are optional.

The repositories/ and services/ folders are forbidden. Concrete persistence lives in packages/infra/db, and use-case orchestration belongs in application/.

Current Domains

DomainPurpose
authUser authentication, principal extraction, session management, OAuth providers
organizationOrganization CRUD, members, onboarding state, invitations
teamTeams (workspaces) within organizations, team members, brand settings, review tokens
roleRole + permission management, role-permission policy matrix
billingStripe subscriptions, credit ledger, usage caps, welcome credit, auto-recharge
assetsMedia uploads (R2), deduplication, thumbnails, collections, storage metering
notificationsIn-app notification delivery, fan-out from domain events
email_campaignsTransactional + campaign email reference implementation (kept as a non-trivial CRUD example)

Cross-Domain Boundaries

Events are public nouns. Ports are public verbs. Errors are private semantics.

WhatCross-domain?How
Domain events (events.ts at domain root)YesThe ONLY file other domains may import from this domain
Ports (ports/)No — wired via DIAdapters bridge repos to ports; services depend on their own ports
Domain errorsNo — translated at seamsRich typed ADT errors inside a domain; translated at port/app boundary
Shared infra errorsYesCoreError (unauthorized, notFound, etc.) at core package level

The events.ts contract exposes event type discriminants, typed payload shapes, and version constants:

// 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 only with import type — events are data contracts, not runtime code. ESLint + structural lint enforce that events.ts is the only cross-domain importable file at a domain root.

Layer Dependency Direction

Dependencies must flow inward. The enforcement script validates every import path:

domain     -> domain only
ports      -> domain, ports
application -> domain, ports, application
adapters   -> domain, ports, adapters
runtime    -> all layers
ui         -> all layers

Cross-domain imports are forbidden except through events.ts. Each domain is otherwise a self-contained unit.

Domain Layer

The domain layer defines entities, value objects, and pure business rules. It must not import anything outside its own domain layer.

// packages/core/src/domains/organization/domain/organization.ts
export interface OrganizationRecord {
  id: string
  name: string
  billingEmail: string | null
  isSubscribed: boolean
  subscriptionStatus: SubscriptionStatus
  createdAt: Date
  updatedAt: Date
}

export const isValidOrganizationName = (name: string): boolean => {
  const trimmed = name.trim()
  return trimmed.length >= 2 && trimmed.length <= 64
}

Determinism constraint: Date.now(), new Date(), and Math.random() are forbidden in domain-layer code. Inject time and randomness through ports.

Ports Layer

Ports define abstract capability contracts using Effect's Context.Tag:

// packages/core/src/domains/organization/ports/organization-ports.ts
export class OrganizationStorePort extends Context.Tag('OrganizationStorePort')<
  OrganizationStorePort,
  {
    list: (userId: string, params: ListParams) => Effect.Effect<PaginatedResult<OrganizationRecord>, unknown>
    getById: (id: string) => Effect.Effect<OrganizationRecord | null, unknown>
    create: (input: CreateOrganizationInput) => Effect.Effect<OrganizationRecord | null, unknown>
    update: (input: UpdateOrganizationInput) => Effect.Effect<OrganizationRecord | null, unknown>
    remove: (id: string) => Effect.Effect<{ deleted: true }, unknown>
  }
>() {}

Ports must return Effect.Effect (never Promise), declare an explicit kind marker (crud or custom), avoid implementing layers with Layer.succeed/Layer.effect, and import port types from the domain layer via *Record interfaces.

Application Layer

Application modules orchestrate use cases by combining ports:

// packages/core/src/domains/organization/application/organization-service.ts
export class OrganizationService extends Context.Tag('OrganizationService')<OrganizationService, {
  getById: (principal: Principal, organizationId: string) => Effect.Effect<Organization, CoreError>
  create: (principal: Principal, input: CreateOrganizationCommand) => Effect.Effect<Organization, CoreError>
  // ...
}>() {}

Governance Rules

All domain layer files must use named exports; export default is forbidden. Environment reads must go through dedicated config modules rather than accessing process.env directly. Type assertions to any are not permitted. Model unknowns explicitly and decode with schema at boundaries. Suppression directives such as @ts-ignore are likewise forbidden; fix the underlying type issue instead of suppressing the error.

On this page