Guides
•6 min read
How to Build Shopping Cart Business Rules
Quantity limits, coupon validation, inventory checks, and checkout gating — the constraint-driven business rules that make Directive shine.
The Problem
The multi-module example shows a skeleton cart but not the constraint-driven business rules: when a quantity exceeds stock, automatically adjust it. When a coupon code is entered, validate it asynchronously. When checkout is requested, gate it on authentication and cart validity. These interdependent rules need clear ordering, and errors in one shouldn't block the others.
The Solution
import { createModule, createSystem, t } from '@directive-run/core';
import { devtoolsPlugin } from '@directive-run/core/plugins';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
maxStock: number;
}
// Define authSchema first so cart can reference it via crossModuleDeps
const authSchema = {
facts: {
isAuthenticated: t.boolean(),
userId: t.string(),
},
derivations: {
isAuthenticated: t.boolean(),
},
};
const cart = createModule('cart', {
schema: {
facts: {
items: t.object<CartItem[]>(),
couponCode: t.string(),
couponDiscount: t.number(),
couponStatus: t.string<'idle' | 'checking' | 'valid' | 'invalid'>(),
checkoutRequested: t.boolean(),
checkoutStatus: t.string<'idle' | 'processing' | 'complete' | 'failed'>(),
},
derivations: {
subtotal: t.number(),
itemCount: t.number(),
isEmpty: t.boolean(),
discount: t.number(),
tax: t.number(),
total: t.number(),
hasOverstockedItem: t.boolean(),
freeShipping: t.boolean(),
},
},
crossModuleDeps: { auth: authSchema },
init: (facts) => {
facts.items = [];
facts.couponCode = '';
facts.couponDiscount = 0;
facts.couponStatus = 'idle';
facts.checkoutRequested = false;
facts.checkoutStatus = 'idle';
},
derive: {
subtotal: (facts) => facts.self.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
itemCount: (facts) => facts.self.items.reduce((sum, item) => sum + item.quantity, 0),
isEmpty: (facts) => facts.self.items.length === 0,
discount: (facts) => facts.self.couponDiscount,
tax: (facts, derive) => Math.round((derive.subtotal - derive.discount) * 0.08 * 100) / 100,
total: (facts, derive) => Math.max(0, derive.subtotal - derive.discount + derive.tax),
hasOverstockedItem: (facts) => facts.self.items.some((item) => item.quantity > item.maxStock),
freeShipping: (facts, derive) => derive.subtotal >= 50,
},
constraints: {
quantityLimit: {
priority: 80,
when: (facts) => facts.self.items.some((item) => item.quantity > item.maxStock),
require: { type: 'ADJUST_QUANTITY' },
},
couponValidation: {
priority: 70,
when: (facts) => facts.self.couponCode !== '' && facts.self.couponStatus === 'idle',
require: (facts) => ({
type: 'VALIDATE_COUPON',
code: facts.self.couponCode,
}),
},
checkoutReady: {
priority: 60,
after: ['quantityLimit', 'couponValidation'],
when: (facts) => {
return (
facts.self.checkoutRequested &&
facts.self.items.length > 0 &&
!facts.self.items.some((item) => item.quantity > item.maxStock) &&
facts.auth.isAuthenticated
);
},
require: { type: 'PROCESS_CHECKOUT' },
},
},
resolvers: {
adjustQuantity: {
requirement: 'ADJUST_QUANTITY',
resolve: async (req, context) => {
context.facts.items = context.facts.items.map((item) => ({
...item,
quantity: Math.min(item.quantity, item.maxStock),
}));
},
},
validateCoupon: {
requirement: 'VALIDATE_COUPON',
resolve: async (req, context) => {
context.facts.couponStatus = 'checking';
const res = await fetch(`/api/coupons/validate?code=${encodeURIComponent(req.code)}`);
if (!res.ok) {
throw new Error(`Coupon validation failed: ${res.status}`);
}
const data = await res.json();
if (data.valid) {
context.facts.couponDiscount = data.discount;
context.facts.couponStatus = 'valid';
} else {
context.facts.couponDiscount = 0;
context.facts.couponStatus = 'invalid';
}
},
},
processCheckout: {
requirement: 'PROCESS_CHECKOUT',
retry: { attempts: 2, backoff: 'exponential' },
resolve: async (req, context) => {
context.facts.checkoutStatus = 'processing';
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: context.facts.items,
couponCode: context.facts.couponCode,
}),
});
if (!res.ok) {
context.facts.checkoutStatus = 'failed';
throw new Error('Checkout failed');
}
context.facts.items = [];
context.facts.checkoutStatus = 'complete';
context.facts.checkoutRequested = false;
},
},
},
events: {
addItem: (facts, item: CartItem) => {
const existing = facts.items.find((i) => i.id === item.id);
if (existing) {
facts.items = facts.items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i,
);
} else {
facts.items = [...facts.items, { ...item, quantity: 1 }];
}
},
removeItem: (facts, { id }: { id: string }) => {
facts.items = facts.items.filter((i) => i.id !== id);
},
updateQuantity: (facts, { id, quantity }: { id: string; quantity: number }) => {
facts.items = facts.items.map((i) =>
i.id === id ? { ...i, quantity: Math.max(0, quantity) } : i,
);
facts.items = facts.items.filter((i) => i.quantity > 0);
},
applyCoupon: (facts, { code }: { code: string }) => {
facts.couponCode = code;
facts.couponStatus = 'idle';
facts.couponDiscount = 0;
},
requestCheckout: (facts) => {
facts.checkoutRequested = true;
},
},
});
const auth = createModule('auth', {
schema: authSchema,
init: (facts) => {
facts.isAuthenticated = false;
facts.userId = '';
},
derive: {
isAuthenticated: (facts) => facts.isAuthenticated,
},
});
const system = createSystem({
modules: { cart, auth },
plugins: [devtoolsPlugin({ panel: true })],
});
function CartSummary({ system }) {
const { facts, derived } = useDirective(system);
return (
<div>
<p>Items: {derived['cart::itemCount']}</p>
<p>Subtotal: ${derived['cart::subtotal'].toFixed(2)}</p>
{derived['cart::discount'] > 0 && (
<p>Discount: -${derived['cart::discount'].toFixed(2)}</p>
)}
<p>Tax: ${derived['cart::tax'].toFixed(2)}</p>
<p>Total: ${derived['cart::total'].toFixed(2)}</p>
{derived['cart::freeShipping'] && <p>Free shipping!</p>}
<button
disabled={derived['cart::isEmpty']}
onClick={() => system.events.cart.requestCheckout()}
>
Checkout
</button>
</div>
);
}
Step by Step
quantityLimitconstraint (priority 80) fires first — when any item exceeds stock, the resolver clamps all quantities. This runs before checkout so the cart is always valid.couponValidationconstraint fires when a coupon code is set and status isidle. The resolver calls the API and setscouponStatustovalidorinvalid.checkoutReadyusesafter— it waits for bothquantityLimitandcouponValidationto settle before evaluating. This ensures checkout never proceeds with invalid quantities or an unchecked coupon.Module-level
crossModuleDeps—crossModuleDeps: { auth: authSchema }is declared on the cart module, giving all constraints and derivations access tofacts.auth.*. Own-module facts are accessed viafacts.self.*(e.g.,facts.self.items,facts.self.checkoutRequested). TheauthSchemais defined above the cart module so it can be referenced. Theauthmodule then reuses the same schema object:schema: authSchema.Derivation composition —
totaldepends onsubtotal,discount, andtax. Changing any item recalculates all three. ThefreeShippingderivation readssubtotalfor threshold detection. Note thatderivecallbacks usefacts.self.*for own-module facts, while thederiveparameter (e.g.,derive.subtotal) is always scoped to the current module.devtoolsPlugin({ panel: true })opens the DevTools panel, showing real-time constraint evaluation, resolver status, and fact changes — invaluable for debugging business rules.
Common Variations
Bundle discounts
derive: {
bundleDiscount: (facts) => {
const hasShirt = facts.self.items.some((i) => i.category === 'shirts');
const hasPants = facts.self.items.some((i) => i.category === 'pants');
return hasShirt && hasPants ? 10 : 0;
},
},
Guest checkout path
constraints: {
checkoutReady: {
when: (facts) => {
return facts.self.checkoutRequested && facts.self.items.length > 0 && (
facts.auth.isAuthenticated || facts.self.guestEmail !== ''
);
},
require: { type: 'PROCESS_CHECKOUT' },
},
},
Real-time inventory checking
constraints: {
checkInventory: {
when: (facts) => facts.self.items.length > 0 && facts.self.inventoryStale,
require: (facts) => ({
type: 'CHECK_INVENTORY',
itemIds: facts.self.items.map((i) => i.id),
}),
},
},
Related
- Interactive Example — try it in your browser
- Constraints —
after, priority, and cross-module deps - Authentication Flow — login/logout patterns
- DevTools Plugin — debugging constraints in real time
- Error Boundaries — handling checkout failures

