| 1 | +# Worked example — an HTTP CRUD service under SAMA v2 |
| 2 | + |
| 3 | +The [/sama/v2](/sama/v2) spec is profile-agnostic by design. This page is one concrete instantiation of it: a small e-commerce orders API. The same four canonical layers and the same import law govern; only the **profile** changes — the sublayer split inside Layers 1 and 2 reflects what HTTP-CRUD services actually need. |
| 4 | + |
| 5 | +Use this as a reading anchor, not a copy-paste skeleton. A SAMA v2-conformant CRUD service has many valid shapes; this page commits to one and explains every decision. |
| 6 | + |
| 7 | +## The profile |
| 8 | + |
| 9 | +A single `sama.profile.toml` at the repo root, machine-ingestible by the verifier: |
| 10 | + |
| 11 | +```toml |
| 12 | +sama_version = "2.0" |
| 13 | +profile = "orders-api" |
| 14 | + |
| 15 | +# Layer 0 — Pure. Domain types, validation rules as data, pure transforms. |
| 16 | +# No I/O, no clock, no framework. |
| 17 | +[layers.0] |
| 18 | +prefixes = ["a_"] |
| 19 | + |
| 20 | +# Layer 1 — Core. Business decisions and orchestration. No DB, no HTTP, |
| 21 | +# no clock. Calls into Layer 2 only through dependency-injected interfaces |
| 22 | +# the Pure layer declares. |
| 23 | +# - b1_ policy: pure decisions over domain state ("canCancelOrder") |
| 24 | +# - b2_ service: orchestrators that compose policy + delegate to adapters |
| 25 | +# (b2 may import b1; b1 must never import b2) |
| 26 | +[layers.1] |
| 27 | +sublayers = [ |
| 28 | + { name = "policy", prefix = "b1_" }, |
| 29 | + { name = "service", prefix = "b2_" }, |
| 30 | +] |
| 31 | + |
| 32 | +# Layer 2 — Adapter. The boundary. External input is PARSED here (never |
| 33 | +# cast). DB, network, filesystem, framework bindings. |
| 34 | +# - c1_ repository: DB-backed CRUD (one repo per aggregate) |
| 35 | +# - c2_ gateway: outbound HTTP / SMTP / SMS to third parties |
| 36 | +# - c3_ controller: inbound HTTP request parsing (JSON.parse, new URL, |
| 37 | +# schema validation). NOT to be confused with Layer 3 route handlers. |
| 38 | +# c3 turns bytes into typed structures; d_ wires those structures to |
| 39 | +# a service call. |
| 40 | +[layers.2] |
| 41 | +sublayers = [ |
| 42 | + { name = "repository", prefix = "c1_" }, |
| 43 | + { name = "gateway", prefix = "c2_" }, |
| 44 | + { name = "controller", prefix = "c3_" }, |
| 45 | +] |
| 46 | + |
| 47 | +# Layer 3 — Entry. Route handlers + server bootstrap. Composes c3_ |
| 48 | +# controllers + b2_ services. Owns no business logic. |
| 49 | +[layers.3] |
| 50 | +prefixes = ["d_"] |
| 51 | +``` |
| 52 | + |
| 53 | +Lex check: `a_ < b1_ < b2_ < c1_ < c2_ < c3_ < d_`. Layer order: `0 < 1 < 1 < 2 < 2 < 2 < 3`. The Sorted check (§4.1) is satisfied for free — `ls src/` reads top-to-bottom in dependency order. |
| 54 | + |
| 55 | +## The directory |
| 56 | + |
| 57 | +``` |
| 58 | +src/ |
| 59 | +├── a_order.ts # Layer 0 — Order, OrderLine, OrderStatus types |
| 60 | +├── a_customer.ts # Layer 0 — Customer, Address types |
| 61 | +├── a_payment.ts # Layer 0 — Payment, Money, Currency types |
| 62 | +├── a_pricing.ts # Layer 0 — pure pricing math, tax rules |
| 63 | +├── a_ports.ts # Layer 0 — adapter interface declarations |
| 64 | +│ # (OrderRepository, PaymentGateway, ...) |
| 65 | +├── b1_order_policy.ts # Layer 1 — canCancelOrder, isEligibleForRefund |
| 66 | +├── b1_payment_policy.ts # Layer 1 — paymentRetryStrategy, refundWindow |
| 67 | +├── b2_order_service.ts # Layer 1 — createOrder, fulfillOrder, cancelOrder |
| 68 | +├── b2_payment_service.ts # Layer 1 — chargeOrder, refundOrder |
| 69 | +├── c1_order_repo.ts # Layer 2 — implements OrderRepository against bun:sqlite |
| 70 | +├── c1_customer_repo.ts # Layer 2 — implements CustomerRepository |
| 71 | +├── c2_stripe_gateway.ts # Layer 2 — implements PaymentGateway via Stripe REST |
| 72 | +├── c2_email_gateway.ts # Layer 2 — implements EmailGateway via Resend |
| 73 | +├── c3_order_controller.ts # Layer 2 — parseCreateOrderRequest, parseListQuery |
| 74 | +├── c3_payment_controller.ts # Layer 2 — parseChargeRequest, parseRefundRequest |
| 75 | +├── d_orders_handlers.ts # Layer 3 — POST /orders, GET /orders/:id, ... |
| 76 | +├── d_payments_handlers.ts # Layer 3 — POST /payments, POST /refunds |
| 77 | +└── d_server.ts # Layer 3 — Bun.serve / Express / Hono bootstrap |
| 78 | + |
| 79 | ++ sibling .test.ts for every b*_ and c*_ file (Modeled-tests check §4.3) |
| 80 | +``` |
| 81 | + |
| 82 | +Twenty-odd source files plus sibling tests. Small enough to navigate; covers every layer the spec names. |
| 83 | + |
| 84 | +## Layer 0 — Pure |
| 85 | + |
| 86 | +Domain types, validation rules as data, pure transforms. No I/O. No clock. No `new Date()` inside a Layer 0 function (use a clock value passed in from Layer 1+). No framework imports — `node:fs`, `express`, `bun:sqlite`, `Bun.serve` all live higher. |
| 87 | + |
| 88 | +```ts |
| 89 | +// a_order.ts |
| 90 | +export type OrderStatus = "pending" | "paid" | "fulfilled" | "cancelled"; |
| 91 | + |
| 92 | +export interface OrderLine { |
| 93 | + productId: string; |
| 94 | + quantity: number; |
| 95 | + unitPriceCents: number; |
| 96 | +} |
| 97 | + |
| 98 | +export interface Order { |
| 99 | + id: string; |
| 100 | + customerId: string; |
| 101 | + lines: ReadonlyArray<OrderLine>; |
| 102 | + status: OrderStatus; |
| 103 | + createdAt: string; // ISO 8601; produced by Layer 1+ |
| 104 | +} |
| 105 | + |
| 106 | +// a_ports.ts — the interfaces Layer 1 calls through. Implementations |
| 107 | +// live in Layer 2; declaring them in Layer 0 lets b2_*_service.ts |
| 108 | +// import only types (no upward edge to c1_*). |
| 109 | +export interface OrderRepository { |
| 110 | + save(order: Order): Promise<void>; |
| 111 | + findById(id: string): Promise<Order | null>; |
| 112 | + listByCustomer(customerId: string): Promise<Order[]>; |
| 113 | +} |
| 114 | + |
| 115 | +export interface PaymentGateway { |
| 116 | + charge(orderId: string, amountCents: number, currency: string): Promise<{ chargeId: string } | { error: string }>; |
| 117 | +} |
| 118 | +``` |
| 119 | + |
| 120 | +The crucial Layer 0 move: **adapter interfaces declared here, not in Layer 2**. A `b2_order_service.ts` that orchestrates `OrderRepository` doesn't import from `c1_order_repo.ts` — it imports the *interface* from `a_ports.ts` and is handed a concrete instance at construction. This is what keeps the Law check green: Layer 1 → Layer 0 only. |
| 121 | + |
| 122 | +## Layer 1 — Core |
| 123 | + |
| 124 | +Business logic and orchestration. Two sublayers — `b1_` policy (pure decisions) and `b2_` service (composes policy + delegates to interfaces). `b2` may import `b1`; `b1` must never import `b2` (the profile's sublayer order enforces this via §2.2). |
| 125 | + |
| 126 | +```ts |
| 127 | +// b1_order_policy.ts — pure functions, no I/O, no clock |
| 128 | +import type { Order } from "./a_order.ts"; |
| 129 | + |
| 130 | +export const canCancelOrder = (order: Order, nowIso: string): boolean => { |
| 131 | + if (order.status !== "pending" && order.status !== "paid") return false; |
| 132 | + const ageMs = Date.parse(nowIso) - Date.parse(order.createdAt); |
| 133 | + return ageMs < 24 * 60 * 60 * 1000; // 24-hour cancellation window |
| 134 | +}; |
| 135 | + |
| 136 | +// b2_order_service.ts — orchestrator. Takes ports at construction. |
| 137 | +import type { Order, OrderRepository, PaymentGateway } from "./a_ports.ts"; |
| 138 | +import { canCancelOrder } from "./b1_order_policy.ts"; |
| 139 | + |
| 140 | +export interface OrderServiceDeps { |
| 141 | + orders: OrderRepository; |
| 142 | + payments: PaymentGateway; |
| 143 | + now: () => string; // clock as a port — pure functions stay pure |
| 144 | +} |
| 145 | + |
| 146 | +export const createOrder = async (deps: OrderServiceDeps, input: CreateOrderInput): Promise<Order> => { |
| 147 | + // ... validates, computes totals via pure a_pricing, calls deps.orders.save |
| 148 | +}; |
| 149 | + |
| 150 | +export const cancelOrder = async (deps: OrderServiceDeps, orderId: string): Promise<void> => { |
| 151 | + const order = await deps.orders.findById(orderId); |
| 152 | + if (!order) throw new Error("order not found"); |
| 153 | + if (!canCancelOrder(order, deps.now())) throw new Error("outside cancellation window"); |
| 154 | + await deps.orders.save({ ...order, status: "cancelled" }); |
| 155 | +}; |
| 156 | +``` |
| 157 | + |
| 158 | +Every Layer 1 file has a sibling `.test.ts` (Modeled-tests check §4.3). The policy tests are pure unit tests over fixture orders; the service tests use in-memory fake implementations of the ports (not mocks — concrete in-memory `OrderRepository` and `PaymentGateway` that the test constructs). |
| 159 | + |
| 160 | +## Layer 2 — Adapter |
| 161 | + |
| 162 | +The boundary. Three sublayers — `c1_` repository (DB), `c2_` gateway (outbound HTTP), `c3_` controller (inbound HTTP parsing). Within Layer 2, controllers may import repositories + gateways through their interfaces. |
| 163 | + |
| 164 | +```ts |
| 165 | +// c1_order_repo.ts — implements a_ports.OrderRepository |
| 166 | +import { Database } from "bun:sqlite"; |
| 167 | +import type { Order, OrderRepository } from "./a_ports.ts"; |
| 168 | + |
| 169 | +export const createOrderRepository = (db: Database): OrderRepository => ({ |
| 170 | + save: async (order) => { |
| 171 | + db.prepare("INSERT OR REPLACE INTO orders (id, customer_id, status, ...) VALUES (?, ?, ?, ...)") |
| 172 | + .run(order.id, order.customerId, order.status); |
| 173 | + }, |
| 174 | + findById: async (id) => { |
| 175 | + const row = db.prepare("SELECT * FROM orders WHERE id = ?").get(id); |
| 176 | + return row ? rowToOrder(row) : null; |
| 177 | + }, |
| 178 | + // ... |
| 179 | +}); |
| 180 | + |
| 181 | +// c3_order_controller.ts — parsing only. The §4.4 Modeled-boundary |
| 182 | +// check FORBIDS JSON.parse / new URL outside this layer. |
| 183 | +import type { CreateOrderInput } from "./a_order.ts"; |
| 184 | + |
| 185 | +export const parseCreateOrderRequest = (body: string): { ok: true; value: CreateOrderInput } | { ok: false; error: string } => { |
| 186 | + let parsed: unknown; |
| 187 | + try { |
| 188 | + parsed = JSON.parse(body); // ← parse only here |
| 189 | + } catch (e) { |
| 190 | + return { ok: false, error: "invalid JSON" }; |
| 191 | + } |
| 192 | + // ... schema validation, returns typed CreateOrderInput |
| 193 | +}; |
| 194 | +``` |
| 195 | + |
| 196 | +Layer 2 imports from Layer 0 and Layer 1 (e.g. a repository helper might call a `b1_` policy to enrich rows before returning). Never from Layer 3. |
| 197 | + |
| 198 | +## Layer 3 — Entry |
| 199 | + |
| 200 | +Route handlers + server bootstrap. Owns zero business logic — every handler is a thin composition: parse request via `c3_*_controller`, invoke `b2_*_service`, serialize the result. |
| 201 | + |
| 202 | +```ts |
| 203 | +// d_orders_handlers.ts |
| 204 | +import { parseCreateOrderRequest } from "./c3_order_controller.ts"; |
| 205 | +import { createOrder } from "./b2_order_service.ts"; |
| 206 | + |
| 207 | +export const postOrdersHandler = (deps: OrderServiceDeps) => async (req: Request): Promise<Response> => { |
| 208 | + const body = await req.text(); |
| 209 | + const parsed = parseCreateOrderRequest(body); // bytes → typed |
| 210 | + if (!parsed.ok) return new Response(parsed.error, { status: 400 }); |
| 211 | + const order = await createOrder(deps, parsed.value); // pure orchestration call |
| 212 | + return Response.json(order, { status: 201 }); |
| 213 | +}; |
| 214 | + |
| 215 | +// d_server.ts |
| 216 | +import { Database } from "bun:sqlite"; |
| 217 | +import { createOrderRepository } from "./c1_order_repo.ts"; |
| 218 | +import { createStripeGateway } from "./c2_stripe_gateway.ts"; |
| 219 | +import { postOrdersHandler } from "./d_orders_handlers.ts"; |
| 220 | + |
| 221 | +const db = new Database("orders.db"); |
| 222 | +const deps = { |
| 223 | + orders: createOrderRepository(db), |
| 224 | + payments: createStripeGateway(process.env.STRIPE_KEY!), |
| 225 | + now: () => new Date().toISOString(), |
| 226 | +}; |
| 227 | +Bun.serve({ |
| 228 | + port: 3000, |
| 229 | + routes: { "POST /orders": postOrdersHandler(deps) }, |
| 230 | +}); |
| 231 | +``` |
| 232 | + |
| 233 | +The Entry layer is where adapters get wired into services. Test files don't normally sit beside `d_*` — the Modeled-tests rule (§4.3) only requires sibling tests for Layer 1 and Layer 2. Layer 3 is exercised by end-to-end integration tests, not unit tests. |
| 234 | + |
| 235 | +## Common mistakes — and the check that catches them |
| 236 | + |
| 237 | +| What an agent might do | Which v2 check catches it | |
| 238 | +|---|---| |
| 239 | +| Put `JSON.parse(req.body)` inside `d_orders_handlers.ts` | **#4 Modeled (boundary)** — boundary parsing must live in Layer 2 (here: `c3_*_controller.ts`) | |
| 240 | +| Import `c1_order_repo.ts` from `b2_order_service.ts` | **#6 Law (§1.2)** — Layer 1 → Layer 2 is upward (use the `a_ports.ts` interface instead) | |
| 241 | +| Import `b1_order_policy.ts` from `b2_order_service.ts` | ✓ Legal — same layer, `b2` is later in the sublayer order so it may import `b1` | |
| 242 | +| Import `b2_order_service.ts` from `b1_order_policy.ts` | **#6 Law (§1.2)** — same layer but reversed sublayer order (`b1` may not import `b2`) | |
| 243 | +| Create `b2_payment_service.ts` without a sibling `.test.ts` | **#3 Modeled (tests)** — Layer 1 behaviour files require sibling tests | |
| 244 | +| Let `a_pricing.ts` import `b1_order_policy.ts` | **#7 Consistency (§3)** — file's `a_` prefix claims Pure but imports reach Layer 1; the prefix lies | |
| 245 | +| Let `d_orders_handlers.ts` grow to 850 LOC | **#5 Atomic** — over the 700-line cap; split per route group | |
| 246 | +| Add a `src/utils/json.ts` (no `a_`/`b_`/`c_`/`d_` prefix) | **#2 Architecture** — unprefixed file does not map to any canonical layer | |
| 247 | + |
| 248 | +Every column 2 entry is a deterministic check the verifier runs — the agent's harness cannot talk it out of the verdict. |
| 249 | + |
| 250 | +## See also |
| 251 | + |
| 252 | +- [/sama/v2](/sama/v2) — the canonical spec this profile instantiates |
| 253 | +- [/sama/v2/verify](/sama/v2/verify) — the live verifier running against this site's own (different) profile |
| 254 | +- [/sama/v2#5-operational--core-metrics-definitions](/sama/v2#5-operational--core-metrics-definitions) — the §5 metrics this profile would emit |
| 255 | + |
| 256 | +[← /sama/v2](/sama/v2) · [← /sama](/sama) |