tRPC Procedure Architecture
Overview
This document describes the architectural pattern for tRPC procedures in this codebase and the ESLint rules that enforce it.
Core Principle
All tRPC procedures must be pure, isomorphic, and dependency-free. Business logic, I/O operations, database access, and external dependencies must flow through the ctx (context) parameter.
Why This Pattern?
- Testability: Procedures with minimal imports are easier to test. Dependencies are injected via
ctx. - Isomorphism: Procedures can potentially be analyzed, validated, or even run in different contexts.
- Security: Prevents procedures from directly accessing sensitive APIs or performing unauthorized operations.
- Clarity: Makes it obvious that all side effects happen through controlled channels (ctx).
- Dependency Injection: All external dependencies are provided through ctx, making it easy to mock in tests.
Allowed Imports
Procedure files (**/procedures/**/*.ts) may ONLY import:
✅ Always Allowed
-
Zod - For schema definitions
import { z } from 'zod'; -
Type-only imports - For TypeScript types
import type { SomeType } from '@/lib/types'; -
StudioProcedure type - The procedure definition type
import { StudioProcedure } from '@/lib/api/server/trpc';
✅ Permitted With Caution
- Other isomorphic/safe utility libraries that don't perform I/O
- Pure utility functions (consider if they should be available via ctx instead)
Blocked Imports
The following imports are explicitly forbidden in procedure files:
❌ Database Access
// ❌ WRONG
import { Pool } from 'pg';
import { query } from '@/lib/db';
// ✅ CORRECT - Use ctx
async execute({ ctx, input }) {
const result = await ctx.pool.query('SELECT ...');
}
❌ Node.js Built-ins
// ❌ WRONG
import { readFile } from 'fs/promises';
import { randomBytes } from 'crypto';
import { resolve } from 'path';
// ✅ CORRECT - Provide via ctx
async execute({ ctx, input }) {
const data = await ctx.fs.readFile(input.path);
const key = await ctx.crypto.randomBytes(32);
}
❌ AWS SDK
// ❌ WRONG
import { KMSClient } from '@aws-sdk/client-kms';
// ✅ CORRECT - Use ctx
async execute({ ctx, input }) {
const encrypted = await ctx.kms.encrypt(input.data);
}
❌ Other Module Imports
// ❌ WRONG
import { someUtility } from '@/lib/admin';
import something from '@/lib/graph/index';
// ✅ CORRECT - Either:
// 1. Import only types
import type { SomeType } from '@/lib/admin';
// 2. Access via ctx
async execute({ ctx, input }) {
const result = await ctx.admin.someUtility(input);
}
ESLint Configuration
The import restrictions are enforced by ESLint rules in eslint.config.mjs:
{
files: ['**/procedures/**/*.ts'],
rules: {
'no-restricted-imports': ['error', { /* ... */ }],
'@typescript-eslint/no-import-type-side-effects': 'error',
}
}
Adding to the Block List
To block additional imports, edit eslint.config.mjs and add to the paths or patterns array:
paths: [
{
name: 'some-package',
message: 'This package should be provided through ctx.'
}
]
Adding to the Allow List
If you need to allow a safe, isomorphic library:
- First, consider: Should this be provided via
ctxinstead? - If it's truly safe (no I/O, no side effects), document it here and ensure it's not blocked in the ESLint config.
Example: Compliant Procedure
/**
* Get Changes In Range Procedure
*/
import { StudioProcedure } from '@/lib/api/server/trpc';
import { z } from 'zod';
const InputSchema = z.object({
startTime: z.date(),
endTime: z.date(),
});
type Input = z.infer<typeof InputSchema>;
const OutputSchema = z.array(z.object({
eventId: z.number(),
eventTime: z.date(),
// ...
}));
type Output = z.infer<typeof OutputSchema>;
export default {
type: 'query' as const,
input: InputSchema,
output: OutputSchema,
async execute({ ctx, input }) {
// All business logic uses ctx
const result = await ctx.pool.query(`
SELECT * FROM audit.events
WHERE event_time BETWEEN $1 AND $2
`, [input.startTime, input.endTime]);
return result.rows.map(row => ({
eventId: row.event_id,
eventTime: row.event_time,
// ...
}));
},
} satisfies StudioProcedure<Input, Output>;
Example: Non-Compliant Procedure
/**
* WRONG - DO NOT DO THIS
*/
import { StudioProcedure } from '@/lib/api/server/trpc';
import { z } from 'zod';
import { Pool } from 'pg'; // ❌ Direct database import
import { randomBytes } from 'crypto'; // ❌ Node.js built-in
const pool = new Pool({ /* ... */ }); // ❌ Global state
export default {
type: 'mutation' as const,
input: z.object({ /* ... */ }),
output: z.object({ /* ... */ }),
async execute({ ctx, input }) {
// ❌ Using directly imported dependencies
const key = randomBytes(32);
const result = await pool.query('SELECT ...');
return { /* ... */ };
},
} satisfies StudioProcedure<any, any>;
Fixing Violations
When ESLint reports a violation in a procedure file:
- Remove the forbidden import
- Update the context type to provide the needed functionality
- Implement the functionality in the context provider (e.g., in
src/lib/api/server/trpc.tsor a context factory) - Use the functionality via
ctxin your procedure
Example:
// Before (violation)
import { randomBytes } from 'crypto';
export default {
async execute({ ctx, input }) {
const key = randomBytes(32);
return { key: key.toString('hex') };
},
};
// After (compliant)
export default {
async execute({ ctx, input }) {
const key = await ctx.crypto.generateKey(32);
return { key };
},
};
Testing Procedures
With this architecture, procedures are easy to test by mocking the ctx:
import procedure from './myProcedure';
test('myProcedure', async () => {
const mockCtx = {
pool: {
query: vi.fn().mockResolvedValue({ rows: [] }),
},
// ... other ctx mocks
};
const result = await procedure.execute({
ctx: mockCtx,
input: { /* ... */ },
});
expect(result).toEqual({ /* ... */ });
});
When to Expand Context
Add new services/utilities to ctx when:
- Multiple procedures need the same functionality
- The operation involves I/O or side effects
- The operation requires configuration or credentials
- You want centralized control over the operation
Questions?
If you're unsure whether an import is appropriate for a procedure file, ask yourself:
- Does this import perform I/O? → Should go through ctx
- Does this import maintain state? → Should go through ctx
- Does this import require credentials/config? → Should go through ctx
- Is this a pure, stateless utility? → May be okay, but consider ctx anyway
When in doubt, provide it through ctx. This maintains the architectural principle and keeps procedures clean and testable.