A typed read-model service
In chapter 1 you scaffolded my-dashboard/ and booted it under Aspire. Now you will shape the data
the dashboard reads: an orders oRPC contract with a typed list procedure, served by
defineService. The contract is the single source of truth — the same schema locks the types for
the server handler and, later, for the Fresh client.
- 1 · Scaffold
- 2 · Contract to service
- 3 · Cache-first query
- 4 · definePage + island
- 5 · Live stream
- 6 · Deploy
What you will build
An orders.list procedure: a GET route that takes pagination plus a status filter and returns a
typed page of orders. You will read the scaffolded contract, understand the
.route().input().output() shape, and serve it on port 3002 with defineService. This is the
read-model every later chapter consumes — the rows that will eventually update live in the browser.
Before you begin
You should have completed chapter 1: my-dashboard/ on
disk and aspire start up. Confirm the orders service is reachable before you change anything — in a
second terminal:
curl http://localhost:3002/health
A healthy JSON response means the scaffolded orders service is live on 3002 and ready to read.
Step 1 — Read the orders contract
Contracts are the typed seam between your service and every client. They live in contracts/,
versioned under versions/v1/, and are built from @orpc/contract routes
plus Zod schemas. Open contracts/versions/v1/orders.contract.ts. The dashboard
only needs the read path, so focus on the list procedure:
// contracts/versions/v1/orders.contract.ts (the read path the dashboard uses)
import { z } from 'zod';
import { baseContract } from '../../shared.ts';
// The order status the dashboard table colours its badges by.
export const OrderStatusSchema = z.enum([
'pending', 'processing', 'shipped', 'delivered', 'cancelled', 'returned', 'failed',
]);
// One order row, as the API returns it.
export const OrdersSchemaV1 = z.object({
id: z.number().int().positive(),
userId: z.number().int().positive(),
status: OrderStatusSchema,
total: z.number().nonnegative(),
shippingCity: z.string(),
shippingCountry: z.string(),
createdAt: z.string().datetime(),
});
export const ordersContract = {
list: baseContract
.route({ method: 'GET' })
.input(z.object({
limit: z.number().int().positive(),
offset: z.number().int().nonnegative(),
status: OrderStatusSchema.optional(),
}))
.output(z.object({
items: z.array(OrdersSchemaV1),
total: z.number().int().nonnegative(),
limit: z.number().int().positive(),
offset: z.number().int().nonnegative(),
hasMore: z.boolean(),
})),
};
The pattern is the whole point:
- Schemas first. Zod objects describe the input and the output. They double as runtime validation and as the TypeScript types that flow everywhere else.
baseContract.route({ method }).input(...).output(...)declares what the procedure accepts and returns — there is no handler body in the contract.baseContractis the scaffold's shared base that pre-declares common errors (NOT_FOUND,VALIDATION_ERROR, and friends).
Step 2 — Read the handler
A contract describes the shape; a handler supplies the behavior. Handlers live under
services/orders/src/routers/. Open services/orders/src/routers/v1.ts. The list handler binds to
the contract's list route and queries the database through the injected context:
// services/orders/src/routers/v1.ts (the list handler, trimmed)
import { v1 } from '@contracts';
const router = v1.orders.$context<{ db: OrdersDb }>();
export const ordersV1 = {
list: router.list.handler(async ({ input, context }) => {
const { db } = context;
const { limit, offset, status } = input;
const where = status ? { status } : {};
const total = await db.netscript.order.count({ where });
const orders = await db.netscript.order.findMany({
where,
include: { items: true },
skip: offset,
take: limit,
});
return {
items: orders.map(toOrderResponse),
total,
limit,
offset,
hasMore: offset + limit < total,
};
}),
// getById, create, update, delete, getStats … also bound here.
};
What is type-locked here, for free:
inputis already parsed and typed as{ limit: number; offset: number; status?: ... }. You never re-validate it — the contract did that.- The returned object must satisfy the contract's
outputschema. DrophasMore, mistypetotal, or forgetitems, anddeno task checkrejects it.
The $context<{ db: OrdersDb }>() call is how the handler declares it needs a database client; the
service injects it (next step). The router then aggregates the handlers and namespaces them by
version so the service can host v1, v2, and so on side by side.
Step 3 — Serve it with defineService
The service entry point is services/orders/src/main.ts. One defineService call wires CORS,
request logging, OpenAPI, the typed RPC surface, and health endpoints, and injects the database
client the handler asked for:
// services/orders/src/main.ts
import { defineService } from '@netscript/service';
import { router } from './router.ts';
import { db } from '@database';
// Each call returns the correctly typed client instance.
const netscript = await db.getClient();
await defineService(router, {
name: 'orders',
version: '1.0.0',
port: parseInt(Deno.env.get('PORT') || '3002'),
db: { netscript },
openapi: { title: 'Orders API', description: 'Order management service' },
debug: true,
});
The service binds the router and exposes the same surface on port 3002:
| Name | Type | Description |
|---|---|---|
/api/rpc/* |
POST |
The typed oRPC surface. Your typed client calls procedures (v1.orders.list) under this prefix with end-to-end type safety. This is what the dashboard talks to. |
/api/v1/orders/list |
GET |
The OpenAPI/REST projection of the same procedure — handy for curl and tools. |
/health |
GET |
Liveness probe — the plain JSON check you hit above. |
Step 4 — Initialize the database
The list handler reads from Postgres, so the database needs its schema and some rows. With
aspire start still up (Postgres is only live while Aspire is), run these from the workspace root
in a second terminal:
netscript db init --name init # create + apply the first migration
netscript db generate # generate the Prisma client
netscript db seed # seed development data (orders, users, products)
These talk to the Postgres container Aspire provisioned. Run them with no Aspire up and they fail — there is no database to reach. The full sequence is in Database & migration.
Verify your progress
Call the list procedure over the OpenAPI projection with curl. From a second terminal:
curl 'http://localhost:3002/api/v1/orders/list?limit=2&offset=0'
You should see up to two seeded orders and a total, shaped exactly like the contract's output:
{
"items": [
{ "id": 1, "userId": 1, "status": "delivered", "total": 129.5, "shippingCity": "Berlin", "shippingCountry": "DE", "createdAt": "2026-01-01T00:00:00.000Z" }
],
"total": 42,
"limit": 2,
"offset": 0,
"hasMore": true
}
Then confirm the whole workspace still type-checks — contract, handler, and router together — from the project root:
deno task check
- [ ]
curl …/api/v1/orders/list?limit=2&offset=0returns a typed page of orders. - [ ] The shape matches the contract's
output(items / total / limit / offset / hasMore). - [ ]
deno task checkis clean.
What you built
A typed orders.list read-model: an oRPC contract (@orpc/contract + Zod), a database-backed
handler bound with .handler(...), and a running service via defineService on port 3002, with
a verified /api/rpc/* surface and its /api/v1/orders/* OpenAPI projection. Next you will read this
service from the Fresh app through a typed SDK client and a cache-first query layer.