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.
Related Concepts
- 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.