The domain layer is the innermost ring of the onion. It contains only pure TypeScript: effect/Schema types, value-set constants, helper functions, and tagged error classes. Nothing in this layer imports from a port, an adapter, or a framework service — it has zero side-effects and zero runtime dependencies beyond Effect’s schema primitives.
MenuItem is the central catalog type. It captures everything the domain needs to validate an order and compute a price.
const MenuItemSchema = Schema.Struct({
id: DrinkIdSchema,
name: Schema.String,
kind: DrinkKindSchema,
basePriceCents: Schema.Int,
availableMilks: Schema.Array(MilkSchema),
availableTemperatures: Schema.Array(TemperatureSchema),
maxShots: Schema.Int,
}).annotate({ identifier: "MenuItem" });
export type MenuItem = typeof MenuItemSchema.Type;
DrinkId values
export const drinkIds = [
"espresso",
"americano",
"latte",
"cappuccino",
"cold-brew",
"tea",
] as const;
export const DrinkIdSchema = Schema.Literals(drinkIds);
DrinkSize values and price multipliers
export const drinkSizes = ["small", "medium", "large"] as const;
export type DrinkSize = (typeof drinkSizes)[number];
| Size | Multiplier |
|---|
small | 1.00× |
medium | 1.15× |
large | 1.30× |
Milk options
export const milks = ["whole", "oat", "almond", "none"] as const;
export type Milk = (typeof milks)[number];
Temperature options
export const temperatures = ["hot", "iced", "extra-hot"] as const;
export type Temperature = (typeof temperatures)[number];
The static menu data ships as a satisfies ReadonlyArray<MenuItem> literal so it is validated by the schema at compile time. A summary of each item:
| ID | Base price | Available milks | Available temperatures | Max shots |
|---|
espresso | 300 ¢ | none | hot | 4 |
americano | 350 ¢ | none | hot, iced | 4 |
latte | 450 ¢ | whole, oat, almond, none | hot, iced, extra-hot | 4 |
cappuccino | 425 ¢ | whole, oat, almond, none | hot, extra-hot | 4 |
cold-brew | 400 ¢ | whole, oat, almond, none | iced | 2 |
tea | 325 ¢ | none | hot, iced | 0 |
Price Calculation
export const calculatePriceCents = (item: MenuItem, size: DrinkSize, shots: number): number => {
const scaledBase = Math.round(item.basePriceCents * sizeMultipliers[size]);
const includedShots = defaultShotsFor(item);
const extraShots = Math.max(shots - includedShots, 0);
return scaledBase + extraShots * 75;
};
The formula is: round(basePriceCents × sizeMultiplier) + max(shots − includedShots, 0) × 75. Tea drinks have includedShots = 0 and maxShots = 0, so they never carry a shot surcharge.
Each extra shot above the item’s included default costs 75 cents. Espresso-based drinks include one shot by default; tea includes zero.
Order Types
CoffeeOrder
export const CoffeeOrderSchema = Schema.Struct({
id: OrderIdSchema,
customerName: Schema.String,
drinkId: DrinkIdSchema,
drinkName: Schema.String,
size: DrinkSizeSchema,
milk: MilkSchema,
temperature: TemperatureSchema,
shots: Schema.Int,
notes: Schema.optionalKey(Schema.String),
status: OrderStatusSchema,
priceCents: Schema.Int,
createdAt: Schema.DateTimeUtc,
}).annotate({ identifier: "CoffeeOrder" });
export type CoffeeOrder = typeof CoffeeOrderSchema.Type;
const orderIdPattern = /^order-\d+$/;
export const OrderIdSchema = Schema.String.check(Schema.isPattern(orderIdPattern)).annotate({
identifier: "OrderId",
});
Valid examples: order-1, order-0042. The in-memory generator zero-pads to four digits (order-0001).
OrderStatus values
export const orderStatuses = ["pending", "brewing", "ready", "picked-up", "cancelled"] as const;
export type OrderStatus = (typeof orderStatuses)[number];
Order State Machine
The validTransitions map defines every legal status change. canTransitionTo enforces it at runtime.
const validTransitions: Record<OrderStatus, ReadonlyArray<OrderStatus>> = {
pending: ["brewing", "cancelled"],
brewing: ["ready", "cancelled"],
ready: ["picked-up"],
"picked-up": [],
cancelled: [],
};
export const canTransitionTo = (from: OrderStatus, to: OrderStatus): boolean =>
validTransitions[from].some((nextStatus) => nextStatus === to);
pending ──► brewing ──► ready ──► picked-up
│ │
└────────────┴──► cancelled
picked-up and cancelled are terminal states. No further transitions are allowed once an order reaches either.
Domain Errors
All domain errors extend Schema.TaggedErrorClass so they carry a _tag discriminant, can be matched exhaustively, and carry HTTP status metadata for the presentation layer.
export class DrinkNotFoundError extends Schema.TaggedErrorClass<DrinkNotFoundError>()(
"DrinkNotFoundError",
{ drinkId: Schema.String },
{ httpApiStatus: 404 },
) {}
export class InvalidOrderInputError extends Schema.TaggedErrorClass<InvalidOrderInputError>()(
"InvalidOrderInputError",
{ message: Schema.String },
{ httpApiStatus: 400 },
) {}
export class OrderNotFoundError extends Schema.TaggedErrorClass<OrderNotFoundError>()(
"OrderNotFoundError",
{ orderId: OrderIdSchema },
{ httpApiStatus: 404 },
) {}
export class InvalidOrderStatusTransitionError extends Schema.TaggedErrorClass<InvalidOrderStatusTransitionError>()(
"InvalidOrderStatusTransitionError",
{ orderId: OrderIdSchema, from: OrderStatusSchema, to: OrderStatusSchema },
{ httpApiStatus: 409 },
) {}
| Error | HTTP status | When raised |
|---|
DrinkNotFoundError | 404 | drinkId not present in the menu |
InvalidOrderInputError | 400 | Blank customer name, unsupported size/milk/temperature, invalid shot count |
OrderNotFoundError | 404 | orderId does not match any stored order |
InvalidOrderStatusTransitionError | 409 | Requested status change violates the state machine |