Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save kevinmichaelchen/2d5fc3e6e817ba9053811d977492c058 to your computer and use it in GitHub Desktop.

Select an option

Save kevinmichaelchen/2d5fc3e6e817ba9053811d977492c058 to your computer and use it in GitHub Desktop.
Functional Domain-Driven Design with Effect-TS Monoid Value Objects

Functional Domain-Driven Design with Effect-TS Monoid Value Objects

Sources

Introduction

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.

Why Functional Programming for DDD?

Functional programming offers compelling advantages for domain modeling:

1. Immutability by Default

  • Value objects naturally immutable
  • Eliminates many concurrency issues
  • Predictable state management

2. Referential Transparency

  • Functions always return same output for same input
  • Easier reasoning about business logic
  • Reliable testing and debugging

3. Composability

  • Small, focused functions combine into complex operations
  • Mathematical properties provide guarantees
  • Natural aggregation patterns

4. Separation of Concerns

  • Pure domain logic separated from side effects
  • "Functional core, imperative shell" pattern
  • Clear boundaries between computation and I/O

The Effect-TS Advantage

Effect-TS provides powerful abstractions for functional DDD:

Type Safety

// Impossible states become unrepresentable
type Money = { amount: number; currency: string }
// vs
class Money extends Data.Class<{ amount: number; currency: string }> {}

Built-in Value Object Support

  • Data.Class and Data.struct for value objects
  • Structural equality via Equal
  • Serialization support

Error Handling

  • Railway-oriented programming with Effect
  • Tagged errors for domain-specific failures
  • Composable error handling

Mathematical Abstractions

  • Monoid, Semigroup, and other typeclasses
  • Principled composition patterns
  • Parallel-safe operations

Why Monoids for Value Objects?

Monoids provide mathematical foundations for combining value objects:

Mathematical Properties

  1. Associativity: (a + b) + c = a + (b + c) - Order of operations doesn't matter
  2. Identity: empty + a = a + empty = a - Natural "zero" value exists

Business Benefits

  • 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

Implementation Guide

Basic Value Object with Monoid

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.

Effect-Based Safe Combination

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 USD

This approach gives us composable error handling where failures can be handled at the appropriate level in our application.

Complex Value Objects

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.

Measurements and Units

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.

Leveraging Monoid Methods

empty: Identity Element

// Business logic: Starting state for calculations
const startingSalesReport = SalesReportMonoid.empty
const emptyCart = ShoppingCartMonoid.empty

combine: Binary Operation

// Merge two customer orders
const combinedOrder = OrderMonoid.combine(order1, order2)

// Add payment to running total
const newTotal = PaymentMonoid.combine(currentTotal, newPayment)

combineMany: Fold with Starting Value

// 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)

combineAll: Reduce Collection

// Total all customer orders
const grandTotal = OrderMonoid.combineAll(customerOrders)

// Aggregate all warehouse inventories
const totalInventory = InventoryMonoid.combineAll(warehouseInventories)

Testing Value Objects

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
})

Conclusion

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment