Built by the creator of tx|Primitives for memory, tasks & orchestrationVisit tx docs
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/tasks/domain/task.ts

export interface TaskRecord {
  readonly id: string
  readonly workspaceId: string
  readonly title: string
  readonly description: string | null
  readonly status: TaskStatus
  readonly assigneeId: string | null
  readonly createdAt: Date
  readonly updatedAt: Date
}

export type TaskStatus = "open" | "in_progress" | "completed" | "cancelled"

Key conventions for entities: use readonly properties to signal immutability intent, use the *Record naming convention (e.g., TaskRecord, 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/tasks/domain/task.ts

export const canTransition = (
  from: TaskStatus,
  to: TaskStatus,
): boolean => {
  const allowed: Record<TaskStatus, ReadonlyArray<TaskStatus>> = {
    open: ["in_progress", "cancelled"],
    in_progress: ["completed", "cancelled", "open"],
    completed: [],
    cancelled: [],
  }
  return allowed[from].includes(to)
}

export const isOverdue = (task: TaskRecord, now: Date): boolean => {
  if (task.status === "completed" || task.status === "cancelled") return false
  if (!task.dueDate) return false
  return task.dueDate < now
}

Notice that isOverdue 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 canAssign = (
  task: TaskRecord,
  assigneeId: string,
  workspaceMembers: ReadonlyArray<string>,
): boolean => {
  if (task.status === "completed" || task.status === "cancelled") return false
  return workspaceMembers.includes(assigneeId)
}

export const validateTaskCreation = (input: {
  readonly title: string
  readonly workspaceId: string
}): ReadonlyArray<string> => {
  const errors: Array<string> = []
  if (input.title.trim().length === 0) errors.push("Title must not be empty")
  if (input.title.length > 200) errors.push("Title must be 200 characters or fewer")
  return errors
}

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

// domain/task.test.ts
import { canTransition, validateTaskCreation } from "./task.js"

test("open tasks can transition to in_progress", () => {
  expect(canTransition("open", "in_progress")).toBe(true)
})

test("completed tasks cannot transition", () => {
  expect(canTransition("completed", "open")).toBe(false)
})

test("empty title is invalid", () => {
  const errors = validateTaskCreation({ title: "", workspaceId: "ws-1" })
  expect(errors).toContain("Title must not be empty")
})

What does NOT belong in domain/

The domain layer has strict exclusions:

ExcludedReason
Database queriesInfrastructure concern. Belongs in packages/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