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 TaskIdValue 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:
| Excluded | Reason |
|---|---|
| Database queries | Infrastructure concern. Belongs in packages/infra/db/src/repositories/ |
| HTTP calls | Infrastructure concern. Belongs in adapters/ |
Date.now() / new Date() | Non-deterministic. Inject time as parameter |
Math.random() | Non-deterministic. Inject via ports |
console.log | Side effect. Use @tx-agent-kit/logging in outer layers |
process.env | Configuration. Use typed env modules |
| Effect services | Domain should be plain TypeScript; Effect is for orchestration |
| Default exports | Convention. Named exports only |
By keeping the domain layer pure and minimal, it becomes the most stable and testable part of the codebase.