01 Domain modules 6 chapters

Theme 02 · Domain-Driven / Functional-Core Minded

Domain modules

Explanation

Domain modules

Plain Human Explanation

A domain module is a home for one product idea. If the idea is “orders,” the rules for pricing, cancellation, and fulfillment should not be scattered across routes, jobs, and UI helpers. Readers should know where to look when the product rule changes.

Technical Explanation

A domain module owns the types and functions for one business concept. It exposes small operations such as priceOrder, cancelOrder, or canShipOrder, and it hides raw details that callers should not change directly. In TypeScript, this usually means exported types, constructors, and transition functions rather than a class that knows about every database or framework concern.

Why It Matters

  • User impact: the same product rule behaves consistently across API requests, jobs, and admin tools.
  • Product behavior: important business words have one clear implementation home.
  • Risk: copied rules drift, so one path allows behavior another path rejects.
  • Decision point: use this when a concept has rules that more than one caller needs.

The Core Move

Put the product vocabulary and rules in one small module. Let framework code and storage code call that module instead of re-implementing the rule.

Small Example

Domain modules: Small Example

Bad TypeScript Example

export function routeCanCancelOrder(orderStatus: string) {
  return orderStatus === "draft" || orderStatus === "paid";
}

export function adminCanCancelOrder(orderStatus: string) {
  return orderStatus !== "shipped";
}
export function routeCanCancelOrder(
  orderStatus: string,
) {
  return (
    orderStatus === "draft" ||
    orderStatus === "paid"
  );
}

export function adminCanCancelOrder(
  orderStatus: string,
) {
  return orderStatus !== "shipped";
}

Good TypeScript Example

export type OrderStatus = "draft" | "paid" | "shipped" | "cancelled";

export function canCancelOrder(status: OrderStatus) {
  return status === "draft" || status === "paid";
}

export function cancelOrder(status: OrderStatus): OrderStatus {
  if (!canCancelOrder(status)) return status;
  return "cancelled";
}
export type OrderStatus =
  | "draft"
  | "paid"
  | "shipped"
  | "cancelled";

export function canCancelOrder(
  status: OrderStatus,
) {
  return (
    status === "draft" || status === "paid"
  );
}

export function cancelOrder(
  status: OrderStatus,
): OrderStatus {
  if (!canCancelOrder(status))
    return status;

  return "cancelled";
}

What Changed

  • The bad version has two cancellation rules that can drift.
  • The good version gives the order concept one cancellation rule.
  • Callers use the domain operation instead of inventing their own status checks.

Realistic Example

Domain modules: Realistic Example

This example uses order pricing, where discount and shipping rules should not be duplicated in every caller.

Bad TypeScript Example

type Cart = {
  items: Array<{
    priceCents: number;
    quantity: number;
  }>;
  coupon?: string;
};
type OrderStore = {
  createOrder(order: { totalCents: number }): Promise<{
    id: string;
  }>;
};

export async function checkout(cart: Cart, store: OrderStore) {
  const subtotal = cart.items.reduce((total, item) => total + item.priceCents * item.quantity, 0);
  const discount = cart.coupon === "SAVE10" ? Math.floor(subtotal * 0.1) : 0;
  const shipping = subtotal - discount > 5000 ? 0 : 799;

  return store.createOrder({ totalCents: subtotal - discount + shipping });
}
type Cart = {
  items: Array<{
    priceCents: number;
    quantity: number;
  }>;
  coupon?: string;
};
type OrderStore = {
  createOrder(order: {
    totalCents: number;
  }): Promise<{
    id: string;
  }>;
};

export async function checkout(
  cart: Cart,
  store: OrderStore,
) {
  const subtotal = cart.items.reduce(
    (total, item) =>
      total +
      item.priceCents * item.quantity,
    0,
  );

  const discount =
    cart.coupon === "SAVE10"
      ? Math.floor(subtotal * 0.1)
      : 0;

  const shipping =
    subtotal - discount > 5000 ? 0 : 799;

  return store.createOrder({
    totalCents:
      subtotal - discount + shipping,
  });
}

Good TypeScript Example

export type CartItem = {
  priceCents: number;
  quantity: number;
};
export type Cart = {
  items: CartItem[];
  coupon?: "SAVE10";
};
export type PricedOrder = {
  subtotalCents: number;
  discountCents: number;
  shippingCents: number;
  totalCents: number;
};
type OrderStore = {
  createOrder(order: PricedOrder): Promise<{
    id: string;
  }>;
};

export function priceOrder(cart: Cart): PricedOrder {
  const subtotalCents = cart.items.reduce((total, item) => total + item.priceCents * item.quantity, 0);
  const discountCents = cart.coupon === "SAVE10" ? Math.floor(subtotalCents * 0.1) : 0;
  const shippingCents = subtotalCents - discountCents > 5000 ? 0 : 799;

  return {
    subtotalCents,
    discountCents,
    shippingCents,
    totalCents: subtotalCents - discountCents + shippingCents,
  };
}

export async function checkout(cart: Cart, store: OrderStore) {
  return store.createOrder(priceOrder(cart));
}
export type CartItem = {
  priceCents: number;
  quantity: number;
};
export type Cart = {
  items: CartItem[];
  coupon?: "SAVE10";
};
export type PricedOrder = {
  subtotalCents: number;
  discountCents: number;
  shippingCents: number;
  totalCents: number;
};
type OrderStore = {
  createOrder(order: PricedOrder): Promise<{
    id: string;
  }>;
};

export function priceOrder(
  cart: Cart,
): PricedOrder {
  const subtotalCents = cart.items.reduce(
    (total, item) =>
      total +
      item.priceCents * item.quantity,
    0,
  );

  const discountCents =
    cart.coupon === "SAVE10"
      ? Math.floor(subtotalCents * 0.1)
      : 0;

  const shippingCents =
    subtotalCents - discountCents > 5000
      ? 0
      : 799;

  return {
    subtotalCents,
    discountCents,
    shippingCents,
    totalCents:
      subtotalCents -
      discountCents +
      shippingCents,
  };
}

export async function checkout(
  cart: Cart,
  store: OrderStore,
) {
  return store.createOrder(
    priceOrder(cart),
  );
}

What Changed

  • The bad version buries pricing inside checkout workflow code.
  • The good version gives pricing its own domain function with named outputs.
  • Checkout, previews, admin tools, and tests can all use the same pricing rule.

System Example

Domain modules: System Example

At system scale, a domain module lets API handlers, scheduled jobs, and admin actions share the same rule without sharing framework code.

Larger System-Level Bad TypeScript Example

type Order = {
  id: string;
  status: "draft" | "paid" | "shipped" | "cancelled" | "delivered";
};
type OrderStore = {
  find(orderId: string): Promise<Order>;
  save(order: Order): Promise<void>;
};

export async function apiCancelOrder(orderId: string, store: OrderStore) {
  const order = await store.find(orderId);
  if (order.status === "shipped") return { status: 409 };
  await store.save({ ...order, status: "cancelled" });
  return { status: 200 };
}

export async function autoCancelExpiredOrder(orderId: string, store: OrderStore) {
  const order = await store.find(orderId);
  if (order.status !== "delivered") {
    await store.save({ ...order, status: "cancelled" });
  }
}
type Order = {
  id: string;
  status:
    | "draft"
    | "paid"
    | "shipped"
    | "cancelled"
    | "delivered";
};
type OrderStore = {
  find(orderId: string): Promise<Order>;
  save(order: Order): Promise<void>;
};

export async function apiCancelOrder(
  orderId: string,
  store: OrderStore,
) {
  const order = await store.find(orderId);

  if (order.status === "shipped")
    return {
      status: 409,
    };

  await store.save({
    ...order,
    status: "cancelled",
  });

  return {
    status: 200,
  };
}

export async function autoCancelExpiredOrder(
  orderId: string,
  store: OrderStore,
) {
  const order = await store.find(orderId);

  if (order.status !== "delivered") {
    await store.save({
      ...order,
      status: "cancelled",
    });
  }
}

Larger System-Level Good TypeScript Example

type Order = {
  id: string;
  status: "draft" | "paid" | "shipped" | "delivered" | "cancelled";
};
type OrderStore = {
  find(orderId: string): Promise<Order>;
  save(order: Order): Promise<void>;
};

function cancelOrder(order: Order): { ok: true; order: Order } | { ok: false; reason: "already-fulfilled" } {
  if (order.status === "shipped" || order.status === "delivered") {
    return { ok: false, reason: "already-fulfilled" };
  }
  if (order.status === "cancelled") return { ok: true, order };
  return { ok: true, order: { ...order, status: "cancelled" } };
}

export async function apiCancelOrder(orderId: string, store: OrderStore) {
  const result = cancelOrder(await store.find(orderId));
  if (!result.ok) return { status: 409, body: result };
  await store.save(result.order);
  return { status: 200, body: result.order };
}

export async function autoCancelExpiredOrder(orderId: string, store: OrderStore) {
  const result = cancelOrder(await store.find(orderId));
  if (result.ok) await store.save(result.order);
}
type Order = {
  id: string;
  status:
    | "draft"
    | "paid"
    | "shipped"
    | "delivered"
    | "cancelled";
};
type OrderStore = {
  find(orderId: string): Promise<Order>;
  save(order: Order): Promise<void>;
};

function cancelOrder(order: Order):
  | {
      ok: true;
      order: Order;
    }
  | {
      ok: false;
      reason: "already-fulfilled";
    } {
  if (
    order.status === "shipped" ||
    order.status === "delivered"
  ) {
    return {
      ok: false,
      reason: "already-fulfilled",
    };
  }

  if (order.status === "cancelled")
    return {
      ok: true,
      order,
    };

  return {
    ok: true,
    order: {
      ...order,
      status: "cancelled",
    },
  };
}

export async function apiCancelOrder(
  orderId: string,
  store: OrderStore,
) {
  const result = cancelOrder(
    await store.find(orderId),
  );

  if (!result.ok)
    return {
      status: 409,
      body: result,
    };

  await store.save(result.order);

  return {
    status: 200,
    body: result.order,
  };
}

export async function autoCancelExpiredOrder(
  orderId: string,
  store: OrderStore,
) {
  const result = cancelOrder(
    await store.find(orderId),
  );

  if (result.ok)
    await store.save(result.order);
}

What Changed

  • The bad version has two different cancellation rules in two system entry points.
  • The good version puts the cancellation decision in the order domain and handles fulfilled states explicitly.
  • The API and job still own their side effects, but neither owns the product rule.

When To Use It

Domain modules: When To Use It

Use This When

  • A product concept has rules used by more than one route, job, component, or service.
  • The same business words keep appearing in review comments or bug reports.
  • You want tests to exercise rules without booting the framework or database.

Avoid This When

  • The concept is only a pass-through data shape with no behavior.
  • The module would become a dumping ground for unrelated helpers.
  • The rule belongs to an external system and your code only forwards the request.

Tradeoffs

Domain modules add a named home for rules. The benefit is consistency and testability. The cost is keeping the module focused; once it starts importing framework objects, database clients, and email clients, it is no longer a clean rule home.

  • Refined values
  • State machines
  • Keeping business logic out of framework entrypoints

Practice Prompt

Domain modules: Practice Prompt

Beginner Exercise

Find one product rule that appears in two places. Write the shared business words that both places use.

Intermediate Exercise

Move that rule into a small domain module with a named type and one exported function. Update both callers to use it.

Stretch Exercise

Add a focused test for the domain function that does not use a database, HTTP request, or framework object.

Reflection Question

Did the new module make the rule easier to find, or did it just create another file to chase?

Suggest an edit

Leave a private editorial note. This creates a GitHub issue for this curriculum page.