Skip to main content
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];
SizeMultiplier
small1.00×
medium1.15×
large1.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:
IDBase priceAvailable milksAvailable temperaturesMax shots
espresso300 ¢nonehot4
americano350 ¢nonehot, iced4
latte450 ¢whole, oat, almond, nonehot, iced, extra-hot4
cappuccino425 ¢whole, oat, almond, nonehot, extra-hot4
cold-brew400 ¢whole, oat, almond, noneiced2
tea325 ¢nonehot, iced0

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;

OrderId format

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 },
) {}
ErrorHTTP statusWhen raised
DrinkNotFoundError404drinkId not present in the menu
InvalidOrderInputError400Blank customer name, unsupported size/milk/temperature, invalid shot count
OrderNotFoundError404orderId does not match any stored order
InvalidOrderStatusTransitionError409Requested status change violates the state machine

Build docs developers (and LLMs) love