tx-agent-kit
Core Concepts

Domain Modeling

Entities, value objects, and pure domain rules in the domain layer

The domain/ layer is the innermost layer of each DDD slice. It contains entities, value objects, and pure domain rules. Everything in this layer is a plain TypeScript function or type with no dependencies on infrastructure, frameworks, or side effects.

Entities

Entities are the core data structures of your domain. They are defined as TypeScript interfaces with the Record suffix:

// packages/core/src/domains/organization/domain/organization.ts

export interface OrganizationRecord {
  readonly id: string
  readonly name: string
  readonly billingEmail: string | null
  readonly isSubscribed: boolean
  readonly subscriptionStatus: SubscriptionStatus
  readonly createdAt: Date
  readonly updatedAt: Date
}

export type SubscriptionStatus = "active" | "inactive" | "trialing" | "past_due" | "canceled" | "paused" | "unpaid"

Key conventions for entities: use readonly properties to signal immutability intent, use the *Record naming convention (e.g., OrganizationRecord, InvoiceRecord), define them in domain/ so ports/ can import them (port files cannot define interfaces), and use named exports only. Default exports are banned in domain files.

Value objects

Value objects represent concepts that are defined by their attributes rather than an identity. They are typically string literal unions, branded types, or small structures:

// Value object: currency
export type Currency = "USD" | "EUR" | "GBP"

// Value object: money (defined by amount + currency, not identity)
export interface Money {
  readonly amount: number
  readonly currency: Currency
}

// Value object: branded ID
export type TaskId = string & { readonly _brand: "TaskId" }

export const TaskId = (raw: string): TaskId => raw as TaskId

Value objects are compared by value, not reference. Two Money objects with the same amount and currency are considered equal.

Pure domain rules

Domain rules are pure functions that encode business logic. They take inputs and return outputs with no side effects:

// packages/core/src/domains/organization/domain/organization.ts

export const canTransitionSubscription = (
  from: SubscriptionStatus,
  to: SubscriptionStatus,
): boolean => {
  const allowed: Record<SubscriptionStatus, ReadonlyArray<SubscriptionStatus>> = {
    inactive: ["active", "trialing"],
    trialing: ["active", "canceled"],
    active: ["past_due", "canceled", "paused"],
    past_due: ["active", "canceled", "unpaid"],
    canceled: ["active"],
    paused: ["active", "canceled"],
    unpaid: ["canceled"],
  }
  return allowed[from].includes(to)
}

export const isSubscriptionExpiring = (org: OrganizationRecord, now: Date): boolean => {
  if (org.subscriptionStatus !== "active") return false
  if (!org.subscriptionCurrentPeriodEnd) return false
  return org.subscriptionCurrentPeriodEnd < now
}

Notice that isSubscriptionExpiring takes now as a parameter rather than calling Date.now(). This is a hard constraint: domain code must not use Date.now, new Date(), or Math.random directly. Time and randomness are injected through function parameters or port interfaces.

This constraint exists for two reasons. First, testability: pure functions with injected time can be tested deterministically. Second, determinism: Temporal workflows replay domain logic, and non-deterministic code breaks replay.

Composition of domain rules

Complex business logic is built by composing simple pure functions:

export const canInvite = (
  org: OrganizationRecord,
  inviterUserId: string,
  orgMembers: ReadonlyArray<{ userId: string; role: string }>,
): boolean => {
  const member = orgMembers.find(m => m.userId === inviterUserId)
  if (!member) return false
  return member.role === "owner" || member.role === "admin"
}

export const validateOrganizationCreation = (input: {
  readonly name: string
}): ReadonlyArray<string> => {
  const errors: Array<string> = []
  if (input.name.trim().length < 2) errors.push("Name must be at least 2 characters")
  if (input.name.length > 64) errors.push("Name must be 64 characters or fewer")
  return errors
}

These functions are trivial to unit test because they have no dependencies:

// domain/organization.test.ts
import { canTransitionSubscription, validateOrganizationCreation } from "./organization.js"

test("inactive subscriptions can transition to active", () => {
  expect(canTransitionSubscription("inactive", "active")).toBe(true)
})

test("unpaid subscriptions cannot transition to active", () => {
  expect(canTransitionSubscription("unpaid", "active")).toBe(false)
})

test("short name is invalid", () => {
  const errors = validateOrganizationCreation({ name: "A" })
  expect(errors).toContain("Name must be at least 2 characters")
})

What does NOT belong in domain/

The domain layer has strict exclusions:

ExcludedReason
Database queriesInfrastructure concern. Belongs in packages/infra/db/src/repositories/
HTTP callsInfrastructure concern. Belongs in adapters/
Date.now() / new Date()Non-deterministic. Inject time as parameter
Math.random()Non-deterministic. Inject via ports
console.logSide effect. Use @tx-agent-kit/logging in outer layers
process.envConfiguration. Use typed env modules
Effect servicesDomain should be plain TypeScript; Effect is for orchestration
Default exportsConvention. Named exports only

By keeping the domain layer pure and minimal, it becomes the most stable and testable part of the codebase.

On this page