Architecture
Pigeon ships two server processes against one SQLite file: the Next.js web app (UI + tRPC) and the MCP server (stdio, agent-facing). Both read and write the same data/tracker.db. The whole system is structured around one rule that came out of a refactor wave (#260): src/server/ and src/mcp/ never import from each other; both consume src/lib/.
That rule is the clarifying decision. Without it, MCP code creeps tRPC types into business logic, the web app starts depending on stdio wiring, and the test surface collapses. With it, both processes stay independent — and the single most-touched file (the service module) stays portable.
This page explains why that rule exists, the ServiceResult<T> shape that lets shared logic stay portable, the lint that enforces it, and where new code goes.
The three layers
Section titled “The three layers”flowchart LR
subgraph WebProcess["Next.js process"]
UI["src/app + components<br/>(React 19, RSC)"]
TRPC["src/server/api/routers<br/>(tRPC v11)"]
SVC_SERVER["src/server/services<br/>(thin adapters)"]
DB_SERVER["src/server/db.ts<br/>(PrismaClient)"]
end
subgraph MCPProcess["MCP process (stdio)"]
SERVER_MCP["src/mcp/server.ts<br/>+ tools/"]
DB_MCP["src/mcp/db.ts<br/>(PrismaClient)"]
end
subgraph Shared["src/lib (shared, no I/O concerns)"]
LIB["src/lib/services<br/>(pure logic, ServiceResult)"]
SCHEMAS["src/lib/schemas<br/>(Zod)"]
end
UI --> TRPC --> SVC_SERVER --> DB_SERVER
SVC_SERVER --> LIB
SERVER_MCP --> LIB
SERVER_MCP --> DB_MCP
LIB -.consumes.-> SCHEMAS
style WebProcess fill:#eef
style MCPProcess fill:#fee
style Shared fill:#efe
The shaded boxes are processes, not just folders. Each owns a separate PrismaClient:
src/server/db.ts— singleton, dev-modeglobalThiscache for hot-reload.src/mcp/db.ts— singleton, no global cache (MCP doesn’t hot-reload).
Both files independently call initFts5(...) and apply the FTS sync extension; neither imports the other’s db.
Why three layers
Section titled “Why three layers”- MCP-as-separate-process. The MCP server runs over stdio in the agent’s child process tree, not as part of Next.js. It boots fast, can’t pull React or
next/server, and doesn’t have a request lifecycle. Sharing aPrismaClientinstance with the web app is impossible — they’re different OS processes. - Avoid tRPC creep into business logic. If
cardService.createreturnedTRPCError, then the MCP server would have to import@trpc/serverto use the function. That defeats the point. Services returnServiceResult<T>; routers convert toTRPCErrorat the edge. - Testability. Pure functions stay pure. The Attribution Engine in
src/lib/services/attribution.tshas no Prisma access — the snapshot is built by a separate function, so the heuristic itself can be unit-tested with hand-built input.
The ServiceResult<T> pattern
Section titled “The ServiceResult<T> pattern”Every service function returns a discriminated union:
export type ServiceError = { code: string; message: string;};
export type ServiceResult<T> = | { success: true; data: T } | { success: false; error: ServiceError };Routers unwrap at the edge by throwing TRPCError:
const result = await cardService.listAll(input);if (!result.success) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error.message, });}return result.data;MCP tool handlers do the equivalent with a different conversion (err() / ok() helpers in src/mcp/utils.ts). Same ServiceResult shape, different edge.
This works because the code strings are HTTP-flavored but not HTTP-bound (NOT_FOUND, INTERNAL_SERVER_ERROR, LIST_FAILED, etc.). Both edges can map them — TRPCError’s code enum and the MCP error envelope both accept the same conceptual buckets.
The boundary lint
Section titled “The boundary lint”Mechanical enforcement lives at scripts/lint-boundary.mjs. Two rules:
| Rule ID | Forbids | Why |
|---|---|---|
mcp-imports-server | src/mcp/** importing from @/server/... | MCP must not depend on tRPC / Next surface area. |
server-imports-mcp | src/server/** importing from @/mcp/... | The web app must not depend on stdio-MCP wiring. |
Two escapes exist:
- Per-line. Append
// boundary-lint-allow — <reason>to the offending import. - Baseline. Pre-#260 cross-imports are grandfathered in
scripts/boundary-lint-baseline.json(5 entries as of 6.2.1). The lint is a ratchet: new violations fail; the baseline shrinks as cards land. Update withnpm run lint:boundary -- --update-baseline(only when removing a grandfathered violation, never to silence a new one).
The lint runs in pre-commit hooks and CI.
Where does new code go?
Section titled “Where does new code go?”The decision flowchart agents and contributors should run before adding a file:
flowchart TD
Start[New code] --> Pure{Is it pure logic<br/>with no Prisma I/O?}
Pure -- yes --> Lib1[src/lib/services or<br/>src/lib/schemas]
Pure -- no --> Shared{Will both Next.js<br/>and MCP need it?}
Shared -- yes --> LibFactory[src/lib/services<br/>factory: createX(db)]
Shared -- no --> Edge{Which edge?}
Edge -- HTTP/tRPC --> Server[src/server/services<br/>+ src/server/api/routers]
Edge -- MCP stdio --> MCP[src/mcp/tools<br/>+ register-all-tools]
Edge -- React UI --> Comp[src/components<br/>or src/app]
style Lib1 fill:#efe
style LibFactory fill:#efe
style Server fill:#eef
style MCP fill:#fee
style Comp fill:#fef
A few worked examples:
| New thing | Goes in |
|---|---|
| Heuristic that picks one card from session state | src/lib/services/ (pure function, no Prisma) — see attribution.ts |
| Service callable from both tRPC and MCP | src/lib/services/ factory accepting db: PrismaClient — see claim.ts |
| New tRPC procedure for a UI page | Router in src/server/api/routers/, service in src/server/services/ (thin adapter) |
| New MCP tool | src/mcp/tools/<name>.ts registered via src/mcp/register-all-tools.ts |
| Shared Zod schema | src/lib/schemas/ |
| React component | src/components/ (board UI) or src/app/ (route-scoped) |
Common pitfalls
Section titled “Common pitfalls”- Importing tRPC types into
src/lib/services/. Don’t. Services returnServiceResult<T>; let the router convert. If you need typed errors, add a code string toServiceError.code, not aTRPCErrorshape. - Putting Prisma calls in pure-logic files. Pure logic stays pure. Build the snapshot in a separate file (
attribution-snapshot.ts) that does take aPrismaClient. - Sharing the
dbinstance across processes. Each process owns its own. The web app’sdblives insrc/server/db.ts; the MCP’s lives insrc/mcp/db.ts. They’re not interchangeable. - Adding a
from "@/server/..."import insidesrc/mcp/. The lint will catch it. Before reaching forboundary-lint-allow, see if the shared logic should live insrc/lib/services/instead. - Forgetting that there’s no
DecisionPrisma model. Decision isClaimwithkind = 'decision'. See Data model for the full primitive map.
See also
Section titled “See also”- Data model — the 18 Prisma models, grouped by domain.
- Attribution engine — the canonical worked example of “pure logic, no Prisma.”
docs/ARCHITECTURE.mdon GitHub — in-repo source-of-truth with file:line citations.