← Insights

Vite and tRPC: from one query to separated concerns

Sergio Cardenas · November 3, 2026

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, 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.

  1. Start direct. A tRPC router can own one query without creating a design problem.
  2. Use Zod as the contract. The schema validates at runtime; TypeScript types come from it.
  3. Move SQL and mapping deliberately. Repository, service, app, and transport each gain one job.
  4. 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.

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, 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 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 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.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

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 NotesApp
import { NotesService } from "./service.js";

const service = new NotesService(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.

// 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.

$ 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.

Share