Sources
Domain-Driven Design (DDD) is an approach to software development that centers complex domain logic and connects implementation to an evolving model of the core business concepts. While traditionally associated with object-oriented programming, DDD's strategic patterns (like bounded contexts) and tactical patterns can be effectively implemented in functional languages.
Value Objects are DDD building blocks that represent descriptive aspects of the domain with no conceptual identity. They are:
- Immutable
- Defined by their attributes rather than identity
- Interchangeable when their values are equal
- Often composed to form larger concepts
Examples: Money, Address, DateRange, Coordinates, Measurements.
Functional programming offers compelling advantages for domain modeling:
- Value objects naturally immutable
- Eliminates many concurrency issues
- Predictable state management
- Functions always return same output for same input
- Easier reasoning about business logic
- Reliable testing and debugging
- Small, focused functions combine into complex operations
- Mathematical properties provide guarantees
- Natural aggregation patterns
- Pure domain logic separated from side effects
- "Functional core, imperative shell" pattern
- Clear boundaries between computation and I/O
Effect-TS provides powerful abstractions for functional DDD:
// Impossible states become unrepresentable
type Money = { amount: number; currency: string }
// vs
class Money extends Data.Class<{ amount: number; currency: string }> {}Data.ClassandData.structfor value objects- Structural equality via
Equal - Serialization support
- Railway-oriented programming with
Effect - Tagged errors for domain-specific failures
- Composable error handling
- Monoid, Semigroup, and other typeclasses
- Principled composition patterns
- Parallel-safe operations
Monoids provide mathematical foundations for combining value objects:
- Associativity:
(a + b) + c = a + (b + c)- Order of operations doesn't matter - Identity:
empty + a = a + empty = a- Natural "zero" value exists
- Natural aggregation: Combine customer orders, financial transactions, inventory
- Incremental computation: Cache intermediate results, process in chunks
- Parallel processing: Safe to split work across threads/processes
- Composability: Build complex business operations from simple rules
Let's start with the imports and a simple Money value object:
import * as Data from "effect/Data"
import * as Effect from "effect/Effect"
import * as Equal from "effect/Equal"
import { Monoid } from "@effect/typeclass/Monoid"Now we create our Money value object using Data.Class:
class Money extends Data.Class<{
readonly amount: number
readonly currency: string
}> {
static zero = (currency: string): Money => new Money({ amount: 0, currency })
static of = (amount: number, currency: string): Money =>
new Money({ amount, currency })
}Data.Class provides several benefits:
- Structural equality: Two Money instances with same values are considered equal
- Immutability: All properties are readonly by default
- Serialization: Built-in JSON serialization support
- Type safety: Full TypeScript support with proper inference
Next, we define a domain-specific error for currency mismatches:
class CurrencyMismatchError extends Data.TaggedError("CurrencyMismatchError")<{
readonly expected: string
readonly actual: string
}> {}Data.TaggedError creates a tagged union error type that works seamlessly with
Effect's error handling.
Now let's implement the Monoid for Money:
const createMoneyMonoid = (currency: string): Monoid<Money> => ({
empty: Money.zero(currency),
combine: (x: Money, y: Money): Money => {
if (x.currency !== y.currency) {
throw new CurrencyMismatchError({
expected: x.currency,
actual: y.currency
})
}
return Money.of(x.amount + y.amount, x.currency)
},
combineMany: (start: Money, others: Iterable<Money>): Money =>
Array.from(others).reduce(
(acc, money) => createMoneyMonoid(start.currency).combine(acc, money),
start
),
combineAll: (moneys: Iterable<Money>): Money => {
const array = Array.from(moneys)
if (array.length === 0) {
throw new Error("Cannot combineAll empty collection without currency")
}
const first = array[0]
const monoid = createMoneyMonoid(first.currency)
return monoid.combineMany(monoid.empty, array)
}
})This implementation encodes the business rule that you can only combine money of the same currency.
Instead of throwing errors, we can use Effect for railway-oriented programming:
const safeMoneyMonoid = (currency: string) => ({
empty: Money.zero(currency),
combine: (x: Money, y: Money): Effect.Effect<Money, CurrencyMismatchError> =>
x.currency !== y.currency
? Effect.fail(
new CurrencyMismatchError({
expected: x.currency,
actual: y.currency
})
)
: Effect.succeed(Money.of(x.amount + y.amount, x.currency))
})The combine function now returns an Effect that either succeeds with a
combined Money or fails with a specific error type.
For combining collections, we can use Effect.reduce:
const combineAll = (
moneys: ReadonlyArray<Money>
): Effect.Effect<Money, CurrencyMismatchError> =>
Effect.reduce(
moneys,
Money.zero(moneys[0]?.currency ?? "USD"),
(acc, money) => safeMoneyMonoid(acc.currency).combine(acc, money)
)Here's how to use it in a program:
const program = Effect.gen(function* () {
const transactions = [
Money.of(100, "USD"),
Money.of(50, "USD"),
Money.of(25, "USD")
]
const total = yield* combineAll(transactions)
console.log(`Total: ${total.amount} ${total.currency}`)
return total
})
Effect.runSync(program) // Output: Total: 175 USDThis approach gives us composable error handling where failures can be handled at the appropriate level in our application.
Let's create a more complex example with inventory management. First, we define an InventoryItem:
class InventoryItem extends Data.Class<{
readonly productId: string
readonly quantity: number
readonly reservedQuantity: number
}> {
get availableQuantity(): number {
return this.quantity - this.reservedQuantity
}
static empty = (productId: string): InventoryItem =>
new InventoryItem({ productId, quantity: 0, reservedQuantity: 0 })
}The availableQuantity getter demonstrates how we can add computed properties
to our value objects while maintaining immutability.
Now we create an Inventory that holds multiple items:
class Inventory extends Data.Class<{
readonly items: ReadonlyMap<string, InventoryItem>
}> {
static empty: Inventory = new Inventory({ items: new Map() })
getItem(productId: string): InventoryItem {
return this.items.get(productId) ?? InventoryItem.empty(productId)
}
}The Inventory uses a ReadonlyMap to ensure immutability while providing
efficient lookups.
Now for the Monoid implementation:
const InventoryMonoid: Monoid<Inventory> = {
empty: Inventory.empty,
combine: (x: Inventory, y: Inventory): Inventory => {
const mergedItems = new Map(x.items)
for (const [productId, item] of y.items) {
const existing = mergedItems.get(productId)
if (existing) {
// Combine quantities for same product
mergedItems.set(
productId,
new InventoryItem({
productId,
quantity: existing.quantity + item.quantity,
reservedQuantity: existing.reservedQuantity + item.reservedQuantity
})
)
} else {
mergedItems.set(productId, item)
}
}
return new Inventory({ items: mergedItems })
},
combineMany: (start: Inventory, others: Iterable<Inventory>): Inventory =>
Array.from(others).reduce(InventoryMonoid.combine, start),
combineAll: (inventories: Iterable<Inventory>): Inventory =>
InventoryMonoid.combineMany(InventoryMonoid.empty, inventories)
}This Monoid encodes the business logic for merging inventories: quantities are summed for the same products.
For a more sophisticated example, let's create a Temperature value object with unit conversions:
type TemperatureUnit = "celsius" | "fahrenheit" | "kelvin"
class Temperature extends Data.Class<{
readonly value: number
readonly unit: TemperatureUnit
}> {
static celsius = (value: number): Temperature =>
new Temperature({ value, unit: "celsius" })
static fahrenheit = (value: number): Temperature =>
new Temperature({ value, unit: "fahrenheit" })
static kelvin = (value: number): Temperature =>
new Temperature({ value, unit: "kelvin" })
}Now we add unit conversion logic:
toCelsius(): Temperature {
switch (this.unit) {
case "celsius":
return this
case "fahrenheit":
return Temperature.celsius(((this.value - 32) * 5) / 9)
case "kelvin":
return Temperature.celsius(this.value - 273.15)
}
}The toCelsius method demonstrates how value objects can contain domain logic
while remaining immutable.
For the Monoid, we'll implement temperature averaging (a common meteorological operation):
const TemperatureMonoid: Monoid<Temperature> = {
empty: Temperature.celsius(0),
combine: (x: Temperature, y: Temperature): Temperature => {
// Convert to common unit and average
const xCelsius = x.toCelsius()
const yCelsius = y.toCelsius()
return Temperature.celsius((xCelsius.value + yCelsius.value) / 2)
},
combineMany: (
start: Temperature,
others: Iterable<Temperature>
): Temperature => {
const temps = [start, ...Array.from(others)]
const sum = temps.reduce((acc, temp) => acc + temp.toCelsius().value, 0)
return Temperature.celsius(sum / temps.length)
},
combineAll: (temps: Iterable<Temperature>): Temperature =>
TemperatureMonoid.combineMany(TemperatureMonoid.empty, temps)
}This Monoid automatically handles unit conversions and implements averaging as the combination operation.
// Business logic: Starting state for calculations
const startingSalesReport = SalesReportMonoid.empty
const emptyCart = ShoppingCartMonoid.empty// Merge two customer orders
const combinedOrder = OrderMonoid.combine(order1, order2)
// Add payment to running total
const newTotal = PaymentMonoid.combine(currentTotal, newPayment)// Add multiple line items to existing order
const orderWithItems = OrderMonoid.combineMany(existingOrder, newLineItems)
// Accumulate daily sales into monthly total
const monthlyTotal = SalesMonoid.combineMany(currentMonth, dailySales)// Total all customer orders
const grandTotal = OrderMonoid.combineAll(customerOrders)
// Aggregate all warehouse inventories
const totalInventory = InventoryMonoid.combineAll(warehouseInventories)Property-based testing works perfectly with Monoids since they follow mathematical laws:
import * as Effect from "effect/Effect"
import { describe, it, expect } from "vitest"
describe("Money Monoid", () => {
const usdMonoid = createMoneyMonoid("USD")
it("should satisfy identity law", () => {
const money = Money.of(100, "USD")
expect(Equal.equals(usdMonoid.combine(money, usdMonoid.empty), money)).toBe(
true
)
})
it("should satisfy associativity law", () => {
const a = Money.of(100, "USD")
const b = Money.of(50, "USD")
const c = Money.of(25, "USD")
const left = usdMonoid.combine(usdMonoid.combine(a, b), c)
const right = usdMonoid.combine(a, usdMonoid.combine(b, c))
expect(Equal.equals(left, right)).toBe(true)
})
})For Effect-based Monoids, test both success and failure cases:
it("should handle currency mismatch", () => {
const program = Effect.gen(function* () {
const usd = Money.of(100, "USD")
const eur = Money.of(50, "EUR")
return yield* safeMoneyMonoid("USD").combine(usd, eur)
})
const result = Effect.runSync(Effect.either(program))
expect(result._tag).toBe("Left") // Error case
})Combining Effect-TS with Monoid-based value objects provides:
- Type-safe domain modeling with impossible states eliminated
- Composable business logic through mathematical guarantees
- Predictable aggregation with associative operations
- Robust error handling via Effect types
- Testable components with clear properties
This approach scales from simple value objects to complex domain aggregates while maintaining mathematical rigor and business logic clarity.
The key insight: Monoids capture the essence of "things that can be combined" in your domain, making your business rules explicit, composable, and correct by construction.