A typed catalog service
In chapter 1 you scaffolded my-shop/ and watched it boot
under Aspire, with a placeholder products service answering on :3001. Now you make that service
real: a typed product catalog whose schemas are generated from your Prisma model, whose handlers
read from Postgres, and whose surface is served by defineService. By the end you will have proven
NetScript's central idea on your own data — the contract is the single source of truth.
- 1 · Scaffold
- 2 · Catalog service
- 3 · Cart contracts
- 4 · Checkout saga
- 5 · Shipping webhook
- 6 · Deploy
What you will build
You will define a products contract with typed list / getById / create / update
procedures whose Zod schemas are generated from Prisma, bind handlers that query a Postgres-backed
database client, and serve the whole thing with defineService(...) on port 3001. You will then
curl the catalog over its OpenAPI projection and watch the contract reject a malformed write before
it ever reaches your handler.
Before you begin
You should have finished chapter 1, so:
my-shop/exists withservices/products/andcontracts/directories.aspire startis up from theaspire/folder, so the dashboard at http://localhost:18888 is live and Postgres is online.
With Aspire up, initialize the database so the catalog has real tables to read and write. Run these
from the workspace root (a second terminal — leave aspire start going in the first):
netscript db init --name init # create + apply the first migration
netscript db generate # generate the Prisma client
netscript db seed # optional: seed some sample products
Confirm the service is reachable before you change anything:
curl http://localhost:3001/health
A healthy JSON response means the scaffolded products service is up on port 3001 and ready to
turn into a real catalog.
Step 1 — Define the catalog 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. The key move for a database-backed service is that you do not
hand-write the entity schema — it is generated from your Prisma model and re-exported from a
@database/zod module, so the contract and the table can never drift.
Open contracts/versions/v1/products.contract.ts and define the catalog surface:
// contracts/versions/v1/products.contract.ts
import { z } from 'zod';
import { implement } from '@orpc/server';
import { baseContract } from '../../shared.ts';
import {
IdQuerySchema,
nonNegativeInt,
OffsetPaginationQuerySchema,
paginationLimit,
paginationOffset,
positiveInt,
} from '@shared/utils';
import {
ProductCategorySchema,
ProductSchema,
ProductUncheckedCreateInputObjectZodSchema,
ProductUncheckedUpdateInputObjectZodSchema,
} from '@database/zod';
// The entity schema is GENERATED from Prisma — no hand-written shape to drift.
export const ProductsSchemaV1 = ProductSchema;
// Create/update inputs are derived from the same generated schemas.
export const CreateProductsSchemaV1 = ProductUncheckedCreateInputObjectZodSchema.omit({ id: true });
export const UpdateProductsSchemaV1 = ProductUncheckedUpdateInputObjectZodSchema
.pick({ id: true, name: true, description: true, price: true, category: true, stock: true })
.extend({ id: positiveInt({ description: 'Product ID to update' }) });
// Routes declare method + typed input + typed output. No handler body here.
export const productsContract = {
list: baseContract
.route({ method: 'GET' })
.input(OffsetPaginationQuerySchema.extend({ category: ProductCategorySchema.optional() }))
.output(z.object({
items: z.array(ProductsSchemaV1),
total: nonNegativeInt({ description: 'Total count' }),
limit: paginationLimit({ description: 'Results per page' }),
offset: paginationOffset({ description: 'Current offset' }),
hasMore: z.boolean(),
})),
getById: baseContract.route({ method: 'GET' }).input(IdQuerySchema).output(ProductsSchemaV1),
create: baseContract.route({ method: 'POST' }).input(CreateProductsSchemaV1).output(ProductsSchemaV1),
update: baseContract.route({ method: 'PUT' }).input(UpdateProductsSchemaV1).output(ProductsSchemaV1),
};
// implement() turns the contract into a `.handler()`-bindable object.
export const productsContractV1 = implement(productsContract);
Three ideas carry the whole pattern:
- Schemas come from Prisma.
ProductSchemaand theProduct*InputObjectZodSchemashapes are generated into@database/zodfrom yourschema.prisma. They double as runtime validation and as the TypeScript types that flow everywhere else — change a column, regenerate, and every consumer re-type-checks. baseContractgives you typed errors for free. It is defined once asos.errors({ ... })and carriesNOT_FOUND,VALIDATION_ERROR,UNAUTHORIZED,FORBIDDEN,RATE_LIMITED, andSERVICE_UNAVAILABLE. Every route built from it canthrowthose typed errors and the client sees them, typed.implement(productsContract)producesproductsContractV1, whose.handler(...)is now bound to the schemas above. Return the wrong shape anddeno task checkfails.
Step 2 — Implement the handlers against Postgres
A contract describes the shape; a handler supplies the behavior. Database-backed handlers need a
Prisma client, and NetScript injects it through a typed context: v1.products.$context<{ db }>()
declares the context shape, then each router.<proc>.handler(...) receives it as context.
Open services/products/src/routers/v1.ts and bind the handlers:
// services/products/src/routers/v1.ts
import { v1 } from '@my-shop/contracts';
import { notFound } from '@shared/utils';
import type { DB } from '@database';
// Declare the context the handlers need: a Prisma client.
const router = v1.products.$context<{ db: DB['client'] }>();
export const productsV1 = {
list: router.list.handler(async ({ input, context }) => {
const { db } = context;
const where = input.category ? { category: input.category } : {};
const [total, items] = await Promise.all([
db.product.count({ where }),
db.product.findMany({ where, skip: input.offset, take: input.limit }),
]);
return { items, total, limit: input.limit, offset: input.offset, hasMore: input.offset + input.limit < total };
}),
getById: router.getById.handler(async ({ input, errors, path, context }) => {
const item = await context.db.product.findUnique({ where: { id: input.id } });
if (!item) notFound({ errors, path, resourceId: input.id });
return item;
}),
create: router.create.handler(async ({ input, context }) => {
return await context.db.product.create({ data: input });
}),
update: router.update.handler(async ({ input, errors, path, context }) => {
const existing = await context.db.product.findUnique({ where: { id: input.id } });
if (!existing) notFound({ errors, path, resourceId: input.id });
return await context.db.product.update({ where: { id: input.id }, data: input });
}),
};
What is type-locked here, for free:
inputis already parsed and typed to the contract's input schema — you never re-validate it.context.dbis the Prisma client you injected;db.product.*is the generated table API.notFound({ errors, path, resourceId })raises the typedNOT_FOUNDerror frombaseContract— the client receives a 404 with a typed body, not a generic throw.- Each handler's return value must satisfy the contract's output schema. Drop a field and
deno task checkrejects it.
Now aggregate the handlers into the service router. The router namespaces by version so the service
can host v1, v2, and so on side by side:
// services/products/src/router.ts
import { health } from './routers/health.ts';
import { productsV1 } from './routers/v1.ts';
export const v1 = { health, products: productsV1 };
export const router = { v1 };
export type Router = typeof router;
Step 3 — Serve it with defineService
The service entry point is services/products/src/main.ts. Because this service talks to the
database, you pass it a Prisma client through the db option — defineService then makes that client
available as the handler context.db you used above:
// services/products/src/main.ts
import { defineService } from '@netscript/service';
import { router } from './router.ts';
import { db } from '@database';
// One call wires CORS, request logging, OpenAPI, RPC, and health endpoints.
await defineService(router, {
name: 'products',
version: '1.0.0',
port: parseInt(Deno.env.get('PORT') || '3001'),
db: await db.getClient(),
openapi: { title: 'Products API', description: 'Product catalog service' },
debug: true,
});
defineService(router, { … }) is the one-shot form the scaffold uses, and what you will reach for
99% of the time. NetScript also exposes a fluent createService(router, { … }).withDatabase(db)...serve()
builder when you need to control middleware order, attach auth, or run startup hooks — the
services capability compares them — but defineService covers a typical
catalog.
Either form binds the same router and exposes the same surface on port 3001:
| Name | Type | Description |
|---|---|---|
/api/rpc/* |
POST |
The typed oRPC surface. A typed client calls procedures (v1.products.list) under this prefix with end-to-end type safety. |
/api/v1/products/list |
GET |
The OpenAPI/REST projection of the same procedure, for tools and curl. |
/health |
GET |
Liveness probe — the plain JSON health check you hit in chapter 1. |
Verify your progress
If aspire start is orchestrating everything, the products service is already live on :3001 with
your new handlers. From a second terminal, list the catalog over the OpenAPI projection:
curl "http://localhost:3001/api/v1/products/list?limit=5&offset=0"
You should see your seeded products and a total, shaped exactly like the contract's list output.
Now prove the contract is doing real work — send a create with a missing required field and watch
it get rejected with a typed validation error, before any handler runs:
curl -X POST http://localhost:3001/api/v1/products/create \
-H 'content-type: application/json' \
-d '{ "name": "Incomplete product" }'
You should get a 422 carrying the VALIDATION_ERROR shape from baseContract. Finally, confirm
the whole workspace still type-checks end to end:
deno task check
- [ ]
netscript db init / generate / seedcompleted with Aspire up. - [ ]
GET /api/v1/products/listreturns seeded products and atotal. - [ ] A malformed
createreturns a typed422 VALIDATION_ERROR. - [ ]
deno task checkpasses — the handlers satisfy the generated output schemas.
What you built
- A versioned oRPC contract whose entity and input schemas are generated from Prisma via
@database/zod, with typed errors inherited frombaseContract. - Handlers bound with
v1.products.$context<{ db }>()→.handler(...)that read and write Postgres through the injected Prisma client, fully type-locked to the contract. - A running catalog served by
defineService(router, { name, version, port, db, openapi })on port 3001, with its OpenAPI projection confirmed bycurlanddeno task check.
You own the contract-first, database-backed loop every NetScript service follows. Next you apply it to a domain that does not exist yet — a shopping cart — by designing its contract first.