This post started with Refactoring at AI speed, without the AI slop, a
write-up of rebuilding an app in 51 days. Its module structure and
checked import rules stayed with me. I was curious, and I wanted to apply those
ideas to something small and see what each layer actually costs.
Start with a Vite table, one tRPC router, and one PostgreSQL table. The first
query belongs in the router. Then view, create, and update arrive, and
that one file owns validation, application behavior, row mapping, and SQL. The
request still works; the question is where each responsibility should live
next.
The following figure traces that first request, from the React component to the
table.
The first, direct path
Vite UI
NoteList renders Note[]
tRPC client
trpc.notes.list .useQuery()
tRPC router
One procedure does the work
validationbehaviorrow mappingSQL
one file owns all four
PostgreSQL
notes rows
Figure 1. The direct path: one router procedure does all four jobs.
TLDR
The useful comparison is not a framework choice. It is how much structure the
same small API needs as it grows.
Start direct. A tRPC router can own one query without creating a design
problem.
Use Zod as the contract. The schema validates at runtime; TypeScript
types come from it.
Move SQL and mapping deliberately. Repository, service, app, and
transport each gain one job.
Folders are not enforcement. An architecture policy catches a shortcut
that still compiles and passes ordinary tests.
All code used in this article is available in
the repository.
Objective
Build one notes API from a direct Vite+tRPC query to an explicit, enforceable
separation of concerns, without changing the call the React component makes.
Scope
Requirements
One Vite NoteList, one tRPC server, and one PostgreSQL notes table.
List, view, create, and update operations.
Zod schemas as the runtime contract and TypeScript type source.
An import rule that makes the chosen architecture executable.
Implementation
1. Start direct
For one read and one write, the router can own the procedure and the query.
tRPC gives the Vite client the router type, so calling a procedure is pleasant.
Add view, create, and update. The router now knows HTTP procedure
details, validation, application behavior, row shapes, and SQL. Nothing is
broken; this is simply where a boundary becomes useful.
Here is the router once all four procedures exist.
// server/src/router.ts: every procedure owns every concernnotes: t.router({
list: t.procedure.query(async () => db.query("SELECT ... FROM notes")),
view: t.procedure.input(noteId).query(async ({ input }) =>
db.query("SELECT ... FROM notes WHERE id = $1", [input.id])),
create: t.procedure.input(noteBody).mutation(async ({ input }) =>
db.query("INSERT INTO notes ...", [input.body])),
update: t.procedure.input(noteUpdate).mutation(async ({ input }) =>
db.query("UPDATE notes ...", [input.body, input.id])),
}),
3. Make the contract executable
A TypeScript type disappears at runtime. A Zod schema stays: it validates
untrusted input, defines the response shape, and lets TypeScript infer the
matching types. That is the schema-first approach: schemas are the source of
truth; types are derived from them.
The contract below replaces the hand-written types.
The change: remove separately hand-written Note, { id: string }, and
{ body: string } types. The schema validates at the boundary; z.infer
gives every internal caller the same static type.
The production codebase this approach comes from additionally uses a private
schema plus a named interface for some exported composite schemas to keep
generated declaration files small. That is a scale optimisation, not a
requirement for this first version.
4. Give each concern one home
The following layout gives each concern one file.
server/src/notes/
contract.ts # Zod schemas, inferred API types
repository.ts # the only SQL
service.ts # rows → contract values
api.ts # application-facing promise and app
transport.ts # tRPC procedures
server/src/index.ts # composition and mounting
Repository: PostgreSQL stays here
This is the only file that talks to PostgreSQL.
// server/src/notes/repository.tsexporttypeNotesRepository = {
listRows(): Promise<NoteRow[]>;
viewRow(id: string): Promise<NoteRow>;
createRow(input: CreateNoteInput): Promise<NoteRow>;
updateRow(id: string, input: UpdateNoteInput): Promise<NoteRow>;
};
exportfunctioncreatePostgresNotesRepository(): NotesRepository {
return {
listRows: async () => (await db.query<NoteRow>("SELECT id, body, created_at FROM notes")).rows,
viewRow: async (id) =>
(await db.query<NoteRow>("SELECT id, body, created_at FROM notes WHERE id = $1", [id])).rows[0],
createRow: async ({ body }) =>
(await db.query<NoteRow>("INSERT INTO notes (body) VALUES ($1) RETURNING id, body, created_at", [body]))
.rows[0],
updateRow: async (id, { body }) =>
(
await db.query<NoteRow>("UPDATE notes SET body = $1 WHERE id = $2 RETURNING id, body, created_at", [
body,
id,
])
).rows[0],
};
}
Service: rows are not the public shape
The service below turns rows into the public shape.
The following figure shows the same request after the split.
The final path: each hop has one job
Vite + tRPC
Calls notes.list
transport
Validates, calls NotesApi
validation
app
Implements NotesApi
behavior
service
Maps storage to a response
row mapping
repository
Owns SQL and db
SQL
PostgreSQL
Stores notes
Figure 2. After the split: each hop owns exactly one job.
Folders document that design; TypeScript does not enforce it.
An agent can import a service into transport, compile the server, and pass
ordinary behavior tests while quietly bypassing the app boundary.
Here is that shortcut in code.
// notes/transport.ts: compiles, but skips NotesApi and NotesAppimport { NotesService } from"./service.js";
const service = newNotesService(createPostgresNotesRepository());
const notesRouter = t.router({
list: t.procedure.query(() => service.list()),
});
TypeScript: passes · tests: may pass · architecture: bypassed
Calling a service from transport is not always wrong. It is wrong under this
contract-first policy: transport calls NotesApi; the app implements it. A
direct repository import would bypass still more. Without a declared policy,
neither mistake fails.
Make the policy executable
The following policy states which internal files each layer may import.
Run the check against the shortcut, then against the fix.
$ pnpm architecture:lint
✗ notes/transport.ts imports ./service.js
transport may internally import only notes/api.ts, notes/contract.ts and trpc.ts
// correct: notes/transport.ts
import type { NotesApi } from "./api.js";
export const notesRouter = createNotesTransport(api);
$ pnpm architecture:lint
✓ import boundaries respected
Separation of concerns is a design. The linter makes it a rule.
An architecture enforcer checks the TypeScript import graph against
such policies. It is separate from Oxlint, which also runs fast source-level
rules, including Zod rules.
Conclusions
The Vite call is still trpc.notes.list.useQuery(). PostgreSQL still receives
the same query. The difference is that the router is now only the transport
boundary, schemas own runtime contracts, the app owns the endpoint promise, the
service owns application shape, and the repository owns SQL.
Intro
This post started with Refactoring at AI speed, without the AI slop, a write-up of rebuilding an app in 51 days. Its module structure and checked import rules stayed with me. I was curious, and I wanted to apply those ideas to something small and see what each layer actually costs.
Start with a Vite table, one tRPC router, and one PostgreSQL table. The first query belongs in the router. Then
view,create, andupdatearrive, and that one file owns validation, application behavior, row mapping, and SQL. The request still works; the question is where each responsibility should live next.The following figure traces that first request, from the React component to the table.
The first, direct path
NoteListrendersNote[]trpc.notes.list.useQuery()does the work
notesrowsTLDR
The useful comparison is not a framework choice. It is how much structure the same small API needs as it grows.
All code used in this article is available in the repository.
Objective
Build one notes API from a direct Vite+tRPC query to an explicit, enforceable separation of concerns, without changing the call the React component makes.
Scope
Requirements
NoteList, one tRPC server, and one PostgreSQLnotestable.Implementation
1. Start direct
For one read and one write, the router can own the procedure and the query. tRPC gives the Vite client the router type, so calling a procedure is pleasant.
The server below is the whole first version.
// server/src/router.ts export const appRouter = t.router({ notes: t.router({ list: t.procedure.query(async () => { return (await db.query("SELECT id, body, created_at FROM notes ORDER BY created_at DESC")).rows; }), create: t.procedure.input(z.object({ body: z.string() })).mutation(async ({ input }) => { return ( await db.query("INSERT INTO notes (body) VALUES ($1) RETURNING id, body, created_at", [input.body]) ).rows[0]; }), }), }); export type AppRouter = typeof appRouter;On the client, the component asks for the list and refreshes it after a write.
// web/src/NoteList.tsx const notes = trpc.notes.list.useQuery(); const createNote = trpc.notes.create.useMutation({ onSuccess: () => utils.notes.list.invalidate(), });2. The pressure is visible in the router
Add
view,create, andupdate. The router now knows HTTP procedure details, validation, application behavior, row shapes, and SQL. Nothing is broken; this is simply where a boundary becomes useful.Here is the router once all four procedures exist.
// server/src/router.ts: every procedure owns every concern notes: t.router({ list: t.procedure.query(async () => db.query("SELECT ... FROM notes")), view: t.procedure.input(noteId).query(async ({ input }) => db.query("SELECT ... FROM notes WHERE id = $1", [input.id])), create: t.procedure.input(noteBody).mutation(async ({ input }) => db.query("INSERT INTO notes ...", [input.body])), update: t.procedure.input(noteUpdate).mutation(async ({ input }) => db.query("UPDATE notes ...", [input.body, input.id])), }),3. Make the contract executable
A TypeScript type disappears at runtime. A Zod schema stays: it validates untrusted input, defines the response shape, and lets TypeScript infer the matching types. That is the schema-first approach: schemas are the source of truth; types are derived from them.
The contract below replaces the hand-written types.
// contract/src/notes.ts import { z } from "zod"; export const noteSchema = z.object({ id: z.string().uuid(), body: z.string().trim().min(1).max(280), createdAt: z.string().datetime(), }); export type Note = z.infer<typeof noteSchema>; export const noteIdSchema = z.object({ id: z.string().uuid() }); export const createNoteInputSchema = z.object({ body: z.string().trim().min(1).max(280), }); export const updateNoteInputSchema = noteIdSchema.extend({ body: z.string().trim().min(1).max(280), }); export type NoteId = z.infer<typeof noteIdSchema>; export type CreateNoteInput = z.infer<typeof createNoteInputSchema>; export type UpdateNoteInput = z.infer<typeof updateNoteInputSchema>;The production codebase this approach comes from additionally uses a private schema plus a named interface for some exported composite schemas to keep generated declaration files small. That is a scale optimisation, not a requirement for this first version.
4. Give each concern one home
The following layout gives each concern one file.
Repository: PostgreSQL stays here
This is the only file that talks to PostgreSQL.
// server/src/notes/repository.ts export type NotesRepository = { listRows(): Promise<NoteRow[]>; viewRow(id: string): Promise<NoteRow>; createRow(input: CreateNoteInput): Promise<NoteRow>; updateRow(id: string, input: UpdateNoteInput): Promise<NoteRow>; }; export function createPostgresNotesRepository(): NotesRepository { return { listRows: async () => (await db.query<NoteRow>("SELECT id, body, created_at FROM notes")).rows, viewRow: async (id) => (await db.query<NoteRow>("SELECT id, body, created_at FROM notes WHERE id = $1", [id])).rows[0], createRow: async ({ body }) => (await db.query<NoteRow>("INSERT INTO notes (body) VALUES ($1) RETURNING id, body, created_at", [body])) .rows[0], updateRow: async (id, { body }) => ( await db.query<NoteRow>("UPDATE notes SET body = $1 WHERE id = $2 RETURNING id, body, created_at", [ body, id, ]) ).rows[0], }; }Service: rows are not the public shape
The service below turns rows into the public shape.
// server/src/notes/service.ts export class NotesService { constructor(private readonly repository: NotesRepository) {} list() { return this.repository.listRows().then((rows) => rows.map(toNote)); } view(input: NoteId) { return this.repository.viewRow(input.id).then(toNote); } create(input: CreateNoteInput) { return this.repository.createRow(input).then(toNote); } update(input: UpdateNoteInput) { return this.repository.updateRow(input.id, input).then(toNote); } } function toNote(row: NoteRow): Note { return { id: row.id, body: row.body, createdAt: row.created_at.toISOString() }; }API and transport: a promise, then tRPC
Next come the API promise and the tRPC transport that calls it.
// ## server/src/notes/api.ts import { NotesService } from "./service.js"; import type { Note, NoteId, CreateNoteInput, UpdateNoteInput } from "./contract.js"; export type NotesApi = { list(): Promise<Note[]>; view(input: NoteId): Promise<Note>; create(input: CreateNoteInput): Promise<Note>; update(input: UpdateNoteInput): Promise<Note>; }; export class NotesApp implements NotesApi { constructor(private readonly service: NotesService) {} list() { return this.service.list(); } view(input: NoteId) { return this.service.view(input); } create(input: CreateNoteInput) { return this.service.create(input); } update(input: UpdateNoteInput) { return this.service.update(input); } } // ---- // ## server/src/notes/transport.ts export function createNotesTransport(api: NotesApi) { return t.router({ list: t.procedure.output(z.array(noteSchema)).query(() => api.list()), view: t.procedure .input(noteIdSchema) .output(noteSchema) .query(({ input }) => api.view(input)), create: t.procedure .input(createNoteInputSchema) .output(noteSchema) .mutation(({ input }) => api.create(input)), update: t.procedure .input(updateNoteInputSchema) .output(noteSchema) .mutation(({ input }) => api.update(input)), }); }The transport does no SQL and no mapping. Its job is to apply Zod at the request and response boundary, then call the API promise.
Composition: only connect the pieces
The composition root only wires the pieces together.
// server/src/index.ts const repository = createPostgresNotesRepository(); const api: NotesApi = new NotesApp(new NotesService(repository)); export const appRouter = t.router({ notes: createNotesTransport(api), }); app.use("/trpc", createExpressMiddleware({ router: appRouter }));5. Enforce the flow, not just the folders
The following figure shows the same request after the split.
The final path: each hop has one job
notes.listNotesApiNotesApito a response
and
dbnotesFolders document that design; TypeScript does not enforce it. An agent can import a service into transport, compile the server, and pass ordinary behavior tests while quietly bypassing the app boundary.
Here is that shortcut in code.
// notes/transport.ts: compiles, but skips NotesApi and NotesApp import { NotesService } from "./service.js"; const service = new NotesService(createPostgresNotesRepository()); const notesRouter = t.router({ list: t.procedure.query(() => service.list()), });Calling a service from transport is not always wrong. It is wrong under this contract-first policy: transport calls
NotesApi; the app implements it. A direct repository import would bypass still more. Without a declared policy, neither mistake fails.Make the policy executable
The following policy states which internal files each layer may import.
// architecture-policy.ts: illustrative policy export const allowedInternalImports = { "notes/transport.ts": ["notes/api.ts", "notes/contract.ts", "trpc.ts"], "notes/api.ts": ["notes/service.ts", "notes/contract.ts"], "notes/service.ts": ["notes/repository.ts", "notes/contract.ts"], "notes/repository.ts": ["database/**", "notes/contract.ts"], };Run the check against the shortcut, then against the fix.
Conclusions
The Vite call is still
trpc.notes.list.useQuery(). PostgreSQL still receives the same query. The difference is that the router is now only the transport boundary, schemas own runtime contracts, the app owns the endpoint promise, the service owns application shape, and the repository owns SQL.