Advanced Rules + Constraints
August 8, 2026 · View on GitHub
Build on the basics by composing multiple rules, controlling evaluation order with rule arrays, and using constraints to enforce system-wide invariants.
Time: 15–20 minutes
Level: Intermediate
Prerequisites: Getting Started tutorial
What You'll Build
An order processing pipeline that:
- Validates an order with multiple rules
- Uses constraints to enforce business invariants
- Demonstrates rule ordering
- Shows how rules emit facts for downstream reads
Step 1: Define the Domain Schema
import {
createApp,
definePath,
defineRule,
defineConstraint,
RuleResult,
fact,
} from '@plures/praxis/unified';
// Domain state
type OrderItem = { sku: string; qty: number; price: number };
const calculateTotal = (items: OrderItem[]) =>
items.reduce((sum, item) => sum + item.qty * item.price, 0);
const Order = definePath<{
items: OrderItem[];
status: 'draft' | 'validated' | 'priced' | 'submitted';
}>('order', { items: [], status: 'draft' });
Step 2: Compose Ordered Rules
Unified rules evaluate in the order they are passed to createApp(). Place rules that derive prerequisite facts earlier in the array so later reads see a predictable pipeline.
// First — validate stock before pricing
const validateStock = defineRule({
id: 'order.validateStock',
watch: ['order'],
evaluate: (values) => {
const order = values['order'] as { items: { qty: number }[]; status: string };
if (order.status !== 'draft') return RuleResult.noop();
const allValid = order.items.every((item) => item.qty > 0 && item.qty <= 100);
if (!allValid) {
return RuleResult.emit([fact('order.stockInvalid', {})]);
}
return RuleResult.emit([fact('order.stockValidated', {})]);
},
});
// Second — compute total after validation
const computeTotal = defineRule({
id: 'order.computeTotal',
watch: ['order'],
evaluate: (values) => {
const order = values['order'] as { items: OrderItem[] };
const total = calculateTotal(order.items);
return RuleResult.emit([fact('order.totalComputed', { total })]);
},
});
// Third — apply discount tiers from the same order data
const applyDiscount = defineRule({
id: 'order.applyDiscount',
watch: ['order'],
evaluate: (values) => {
const order = values['order'] as { items: OrderItem[] };
const total = calculateTotal(order.items);
let discount = 0;
if (total >= 200) discount = 0.15;
else if (total >= 100) discount = 0.1;
else if (total >= 50) discount = 0.05;
return RuleResult.emit([fact('order.discountApplied', { discount })]);
},
});
Tutorial simplification: unified rules currently watch graph paths, not emitted facts, so applyDiscount reads the same order source as computeTotal. In production, keep shared calculations in one helper or write the total to a graph path before discounting.
Step 3: Define Constraints
Unified constraints are invariants that guard mutations before state is committed. Use them for requirements that must block invalid state.
// Constraint — final total must never be negative
const totalNonNegative = defineConstraint({
id: 'order.totalNonNegative',
description: 'Order total after discount must be non-negative',
watch: ['order'],
validate: (values) => {
const order = values['order'] as { items: OrderItem[] };
const total = calculateTotal(order.items);
return total >= 0 || `Final total is negative: ${total}`;
},
});
// Constraint — order must have at least one item to leave draft
const hasItems = defineConstraint({
id: 'order.hasItems',
description: 'Order must contain at least one item before submission',
watch: ['order'],
validate: (values) => {
const order = values['order'] as { items: unknown[]; status: string };
if (order.status === 'draft') return true;
return order.items.length > 0 || 'Cannot submit an empty order';
},
});
Step 4: Wire and Run
const app = createApp({
name: 'order-processing',
schema: [Order],
rules: [validateStock, computeTotal, applyDiscount],
constraints: [totalNonNegative, hasItems],
});
// Add items to the order
const orderResult = app.mutate('order', {
items: [
{ sku: 'WIDGET-A', qty: 3, price: 25.0 },
{ sku: 'GADGET-B', qty: 1, price: 75.0 },
],
status: 'draft',
});
const total = orderResult.facts.find((f) => f.tag === 'order.totalComputed')?.payload as { total: number } | undefined;
console.log(total?.total); // demo only — use OpenTelemetry in production
// Expected output: 150
const discount = orderResult.facts.find((f) => f.tag === 'order.discountApplied')?.payload as { discount: number } | undefined;
console.log(discount?.discount); // demo only — use OpenTelemetry in production
// Expected output: 0.1
const finalTotal = (total?.total ?? 0) * (1 - (discount?.discount ?? 0));
console.log(finalTotal); // demo only — use OpenTelemetry in production
// Expected output: 135
// Try submitting an empty order — constraint fails
const emptyResult = app.mutate('order', { items: [], status: 'submitted' });
console.log(emptyResult.accepted); // demo only — use OpenTelemetry in production
// Expected output: false
Rule Composition Patterns
Fact Reads
Rules can emit facts for the app to inspect after a mutation. In the unified API, rules watch graph paths, so dependent computations should either watch the same paths or run after the prerequisite mutation.
// Rule A emits "order.stockValidated"
// Rule B computes pricing from the same order path
// Application code reads "order.totalComputed" from the mutation result
Conditional Rule Activation
Use RuleResult.noop() to skip evaluation when preconditions aren't met:
const onlyWhenValidated = defineRule({
id: 'order.price',
watch: ['order'],
evaluate: (values) => {
const order = values['order'] as { status: string };
if (order.status !== 'validated') return RuleResult.noop();
// ... pricing logic
return RuleResult.emit([fact('order.priced', {})]);
},
});
Constraint Behavior
Constraints block invalid mutations and return a diagnostic message:
const maxOrderTotal = defineConstraint({
id: 'order.maxTotal',
description: 'Orders over \$1000 need manager approval',
watch: ['order'],
validate: (values) => {
const order = values['order'] as { items: OrderItem[] };
const total = calculateTotal(order.items);
return total <= 1000 || 'Manager approval required for orders over \$1000';
},
});
Full Source
Click to expand src/order-pipeline.ts
import {
createApp,
definePath,
defineRule,
defineConstraint,
RuleResult,
fact,
} from '@plures/praxis/unified';
type OrderItem = { sku: string; qty: number; price: number };
const calculateTotal = (items: OrderItem[]) =>
items.reduce((sum, item) => sum + item.qty * item.price, 0);
const Order = definePath<{
items: OrderItem[];
status: 'draft' | 'validated' | 'priced' | 'submitted';
}>('order', { items: [], status: 'draft' });
const validateStock = defineRule({
id: 'order.validateStock',
watch: ['order'],
evaluate: (values) => {
const order = values['order'] as { items: { qty: number }[]; status: string };
if (order.status !== 'draft') return RuleResult.noop();
const allValid = order.items.every((item) => item.qty > 0 && item.qty <= 100);
return allValid
? RuleResult.emit([fact('order.stockValidated', {})])
: RuleResult.emit([fact('order.stockInvalid', {})]);
},
});
const computeTotal = defineRule({
id: 'order.computeTotal',
watch: ['order'],
evaluate: (values) => {
const order = values['order'] as { items: OrderItem[] };
const total = calculateTotal(order.items);
return RuleResult.emit([fact('order.totalComputed', { total })]);
},
});
const applyDiscount = defineRule({
id: 'order.applyDiscount',
watch: ['order'],
evaluate: (values) => {
const order = values['order'] as { items: OrderItem[] };
const total = calculateTotal(order.items);
let discount = 0;
if (total >= 200) discount = 0.15;
else if (total >= 100) discount = 0.1;
else if (total >= 50) discount = 0.05;
return RuleResult.emit([fact('order.discountApplied', { discount })]);
},
});
const totalNonNegative = defineConstraint({
id: 'order.totalNonNegative',
description: 'Order total after discount must be non-negative',
watch: ['order'],
validate: (values) => {
const order = values['order'] as { items: OrderItem[] };
const total = calculateTotal(order.items);
return total >= 0 || `Final total is negative: ${total}`;
},
});
const hasItems = defineConstraint({
id: 'order.hasItems',
description: 'Order must contain at least one item before submission',
watch: ['order'],
validate: (values) => {
const order = values['order'] as { items: unknown[]; status: string };
if (order.status === 'draft') return true;
return order.items.length > 0 || 'Cannot submit an empty order';
},
});
const app = createApp({
name: 'order-processing',
schema: [Order],
rules: [validateStock, computeTotal, applyDiscount],
constraints: [totalNonNegative, hasItems],
});
const orderResult = app.mutate('order', {
items: [
{ sku: 'WIDGET-A', qty: 3, price: 25.0 },
{ sku: 'GADGET-B', qty: 1, price: 75.0 },
],
status: 'draft',
});
const total = orderResult.facts.find((f) => f.tag === 'order.totalComputed')?.payload as { total: number } | undefined;
const discount = orderResult.facts.find((f) => f.tag === 'order.discountApplied')?.payload as { discount: number } | undefined;
console.log(total?.total); // 150; demo only — use OpenTelemetry in production
console.log(discount?.discount); // 0.1; demo only — use OpenTelemetry in production
What's Next
- Cloud Sync + Auth Workflow — synchronize state across devices
- Decision Ledger Guide — attach contracts to rules
- API Reference