Skip to main content
0.0.x

Cart, contract-first

In chapter 2 you read a typed catalog built around a contract that was largely generated from Prisma. Now you design a domain that does not exist yet — a shopping cart — the other way round: contract first, then everything else. You write the cart's procedures and schemas as the single source of truth, then derive a fully typed client from them without a codegen step. This is the discipline that makes the checkout saga in the next chapter safe to build.

  1. 1 · Scaffold
  2. 2 · Catalog service
  3. 3 · Cart contracts
  4. 4 · Checkout saga
  5. 5 · Shipping webhook
  6. 6 · Storefront UI
  7. 7 · Deploy

What you will build

You will add a new cart contract under contracts/versions/v1/ with list / getById / create / update procedures, Zod schemas for a cart and its line items, and typed errors inherited from baseContract. Then you will derive a typed @orpc/client from that contract and call it — proving that the contract alone, with no implementation written yet, is enough to give a client end-to-end type safety.

Before you begin

You should have finished chapter 2, so:

  • my-shop/ has a working products service on :3001 (note: this tutorial pins the port to 3001; in unpinned scaffolds, each project is allocated its own randomized high-range ports) and a contracts/versions/v1/ directory with products.contract.ts in it.
  • aspire start is up (the dashboard answers at https://localhost:18888).

Confirm the contracts workspace is where you left it:

ls contracts/versions/v1/

You should see products.contract.ts among the files. The CLI will add cart.contract.ts next to it.

Step 1 — Scaffold the cart contract

Let the CLI lay down the contract file and wire it into the version aggregate for you, exactly as the Add a service recipe does. From the workspace root:

netscript contract add cart

This writes contracts/versions/v1/cart.contract.ts — a starter contract with example schemas and procedures — and regenerates contracts/versions/v1/mod.ts so the new contract is already exported and reachable as v1.cart. There is no mod.ts to hand-edit: the aggregate now imports both the plain CartContractV1 and the implemented CartV1, re-exports the plain object, and adds cart to the v1.* map — the same CartContractV1 (routes) → CartV1 (implemented) convention the generated products contract uses.

Confirm the file landed:

ls contracts/versions/v1/

You now replace the scaffolded example with the cart's real shape.

Step 2 — Define the cart schemas

A cart holds line items, each referencing a product by id with a quantity, plus a status. Open the scaffolded contracts/versions/v1/cart.contract.ts and replace its starter schemas with the ones below. The helpers (positiveInt, nonNegativeInt, the pagination schemas) and baseContract all come from the @netscript/contracts package — swap the scaffold's oc/@orpc/contract import for baseContract so every route inherits the shared typed errors, exactly as the playground's orders contract does:

// contracts/versions/v1/cart.contract.ts
import { z } from 'zod';
import { implement } from '@orpc/server';
import {
  baseContract,
  nonNegativeInt,
  OffsetPaginationQuerySchema,
  paginationLimit,
  paginationOffset,
  positiveInt,
} from '@netscript/contracts';

// A single line in a cart.
export const CartItemSchemaV1 = z.object({
  productId: positiveInt({ description: 'Product being added' }),
  quantity: z.number().int().positive().describe('How many'),
});

// The cart status lifecycle.
export const CartStatusSchemaV1 = z.enum(['open', 'checking_out', 'ordered', 'abandoned']);

// A cart, as returned by the API.
export const CartSchemaV1 = z.object({
  id: positiveInt({ description: 'Cart ID' }),
  customerId: z.string().min(1).describe('Owner of the cart'),
  status: CartStatusSchemaV1,
  items: z.array(CartItemSchemaV1).describe('Line items'),
  total: z.number().nonnegative().describe('Computed cart total'),
  createdAt: z.string().datetime(),
});

// Inputs the create/update procedures accept.
export const CreateCartSchemaV1 = z.object({
  customerId: z.string().min(1).describe('Owner of the cart'),
  items: z.array(CartItemSchemaV1).describe('Initial line items'),
});

export const UpdateCartSchemaV1 = z.object({
  id: positiveInt({ description: 'Cart ID to update' }),
  status: CartStatusSchemaV1.optional(),
  items: z.array(CartItemSchemaV1).optional().describe('Replaces existing items'),
});

These hand-authored schemas play the same role the generated @database/zod schemas played for products: they are both the runtime validators and the TypeScript types every consumer derives.

Now reduce the scaffolded CartContractV1 to a single baseContract route — the paginated list — so the CLI has a baseContract builder to extend in the next step, and keep the trailing implement() call the scaffold generated:

// contracts/versions/v1/cart.contract.ts (continued)
export const CartContractV1 = {
  // List carts with pagination.
  list: baseContract
    .route({ method: 'GET', path: '/cart' })
    .input(OffsetPaginationQuerySchema.extend({ status: CartStatusSchemaV1.optional() }))
    .output(z.object({
      items: z.array(CartSchemaV1),
      total: nonNegativeInt({ description: 'Total count' }),
      limit: paginationLimit({ description: 'Results per page' }),
      offset: paginationOffset({ description: 'Current offset' }),
      hasMore: z.boolean(),
    })),
};

// implement() makes the contract `.handler()`-bindable — the same ProductsContractV1 → ProductsV1
// convention the generated products contract uses.
export const CartV1 = implement(CartContractV1);

Step 3 — Add the remaining procedures

Add the rest of the cart's surface with the CLI rather than hand-editing the contract object. Each contract add-route call appends a typed route to CartContractV1, reusing the baseContract builder you seeded in Step 2, so every procedure inherits the typed errors (NOT_FOUND, VALIDATION_ERROR, …) you met in chapter 2:

netscript contract add-route cart getById \
  --method GET \
  --path /cart/{id} \
  --input "z.object({ id: positiveInt({ description: 'Cart ID' }) })" \
  --output "CartSchemaV1"

netscript contract add-route cart create \
  --method POST \
  --path /cart \
  --input "CreateCartSchemaV1" \
  --output "CartSchemaV1"

netscript contract add-route cart update \
  --method PATCH \
  --path /cart/{id} \
  --input "UpdateCartSchemaV1" \
  --output "CartSchemaV1"

Inspect the result — as source, or as machine-readable JSON:

netscript contract inspect cart
netscript contract inspect cart --json

The appended routes leave CartContractV1 with the full surface; the equivalent source shape is:

// contracts/versions/v1/cart.contract.ts (CartContractV1, after the CLI additions)
export const CartContractV1 = {
  // List carts with pagination.
  list: baseContract
    .route({ method: 'GET', path: '/cart' })
    .input(OffsetPaginationQuerySchema.extend({ status: CartStatusSchemaV1.optional() }))
    .output(z.object({
      items: z.array(CartSchemaV1),
      total: nonNegativeInt({ description: 'Total count' }),
      limit: paginationLimit({ description: 'Results per page' }),
      offset: paginationOffset({ description: 'Current offset' }),
      hasMore: z.boolean(),
    })),

  // Fetch one cart. @throws NOT_FOUND when the id is unknown.
  getById: baseContract
    .route({ method: 'GET', path: '/cart/{id}' })
    .input(z.object({ id: positiveInt({ description: 'Cart ID' }) }))
    .output(CartSchemaV1),

  // Create a cart. @throws VALIDATION_ERROR when input is invalid.
  create: baseContract
    .route({ method: 'POST', path: '/cart' })
    .input(CreateCartSchemaV1)
    .output(CartSchemaV1),

  // Update status or items. @throws NOT_FOUND, VALIDATION_ERROR.
  update: baseContract
    .route({ method: 'PATCH', path: '/cart/{id}' })
    .input(UpdateCartSchemaV1)
    .output(CartSchemaV1),
};

The shape is intentionally the same as products and orders: a paginated list, a getById that can throw NOT_FOUND, a create that can throw VALIDATION_ERROR, and an update. Reusing the shape means anyone who has read one NetScript contract can read this one. The v1.cart entry the scaffold wired into mod.ts still points at the implemented CartV1, so these new procedures are reachable from both the @my-shop/contracts barrel and @my-shop/contracts/versions/v1 with no extra wiring.

The cart contract surface
NameTypeDescription
list GET /cart List carts, optionally filtered by status. Returns items + total + pagination metadata.
getById GET /cart/{id} Fetch one cart by id. Throws the typed NOT_FOUND error when the id is unknown.
create POST /cart Open a new cart for a customer with initial items. Throws VALIDATION_ERROR on bad input.
update PATCH /cart/{id} Change cart status or replace items. The checkout saga (chapter 4) flips status to checking_out.

Step 4 — Derive a typed client

Here is the payoff of contract-first: a client needs only the contract to be fully typed — no running server, no generated SDK, no hand-written request types. The typed-client packages are not in the scaffold's import map, so add them once:

deno add npm:@orpc/client npm:@orpc/openapi-client

Then create a small script to prove it:

// scripts/cart-client.ts
import { createORPCClient } from '@orpc/client';
import { OpenAPILink } from '@orpc/openapi-client/fetch';
import { CartContractV1 } from '@my-shop/contracts/versions/v1';

// The client's type comes entirely from the contract. The REST projection is under /api.
const link = new OpenAPILink(CartContractV1, { url: 'http://localhost:3001/api' });
const client = createORPCClient<typeof CartContractV1>(link);

// `created` is typed as CartSchemaV1 — the editor knows its fields before you run anything.
const created = await client.create({
  customerId: 'cust_1001',
  items: [{ productId: 1, quantity: 2 }],
});

console.log(created.status, created.total);

Even with no cart service implemented yet, your editor types created.status as the CartStatusSchemaV1 union and created.items as CartItemSchemaV1[]. Pass the wrong shape to client.create(...) and it is a compile error — the contract is enforcing the boundary from both sides.

Verify your progress

The contract is code, so the real verification is that it type-checks and that the typed client compiles against it. From the workspace root:

deno task check

A clean check proves the cart schemas, routes, and the CLI-generated mod.ts registration all line up, and that the typed client in scripts/cart-client.ts is consistent with CartContractV1.

  • [ ] contracts/versions/v1/cart.contract.ts exists with list / getById / create / update.
  • [ ] Every route is built from baseContract, so it carries the shared typed errors.
  • [ ] netscript contract inspect cart lists all four procedures.
  • [ ] CartV1 is wired into contracts/versions/v1/mod.ts and reachable as v1.cart.
  • [ ] The typed client in scripts/cart-client.ts compiles — created is typed without a running server.
  • [ ] deno task check passes.

What you built

  • A brand-new cart domain defined contract-first — schemas, a list / getById / create / update procedure set, and baseContract typed errors — modeled on the playground's orders contract.
  • The contract scaffolded and its procedures added with netscript contract add / contract add-route, which regenerated contracts/versions/v1/mod.ts following the CartContractV1 (routes) → CartV1 (implemented) convention — reachable as v1.cart from the @my-shop/contracts barrel and the @my-shop/contracts/versions/v1 subpath, with no hand-wiring.
  • A typed @orpc/client derived from the contract alone, fully type-locked with no codegen.

You now have a cart whose every interaction is described by a contract. In the next chapter that contract becomes the input to the riskiest part of any shop — checkout — which you make reliable with a durable saga.