# Vite and tRPC: from one query to separated concerns

A small Vite and tRPC notes app, from a direct query to separated concerns.

By Sergio Cardenas, 2026-11-03

Canonical: https://warike.tech/blog/vite-trpc-separated-concerns

## 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 renders Note\[], the tRPC client calls trpc.notes.list.useQuery(), and one tRPC router procedure owns validation, behavior, row mapping and SQL against the 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](https://github.com/warike/trpc-enforced-layers).

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

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

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

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

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

```text
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.

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

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

```ts
// ## 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.

```ts
// 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.** The final path, each hop with one job: Vite + tRPC calls notes.list; the transport validates and calls NotesApi; the app implements NotesApi; the service maps storage to a response; the repository owns SQL and db; 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.

```ts
// 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()),
});
```

```text
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.

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

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

## Sources

- [tRPC](https://trpc.io)
- [Zod](https://zod.dev)
- [Vite](https://vite.dev)
- [Oxlint](https://oxc.rs/docs/guide/usage/linter)
