# oRPC Backend Usage Guidelines ## 1. Overview ### What is oRPC oRPC (OpenAPI RPC) is a type-safe RPC framework for TypeScript that provides end-to-end type safety from your backend to frontend. It combines the best aspects of REST APIs and RPC frameworks while generating OpenAPI specifications automatically. ### Why oRPC over tRPC or plain REST | Feature | oRPC | tRPC | REST | |---------|------|------|------| | Type Safety | End-to-end | End-to-end | Manual | | OpenAPI Generation | Built-in | Plugin required | Manual | | HTTP Method Control | Full control | Limited | Full control | | Learning Curve | Low | Low | Medium | | Middleware Support | Native | Native | Framework-dependent | | Schema Validation | Zod native | Zod native | Manual | Key advantages of oRPC: - **OpenAPI-first**: Automatic OpenAPI spec generation for documentation and client generation - **HTTP semantics**: Full control over HTTP methods, paths, and tags - **Type inference**: Automatic TypeScript types from Zod schemas - **Middleware composition**: Chainable middleware for auth, logging, etc. ### Project Structure with oRPC ``` packages/api/ ├── orpc/ │ ├── router.ts # Main router composition │ ├── procedures.ts # Base procedure definitions │ └── middleware/ # Reusable middleware │ ├── log-id-middleware.ts │ └── locale-middleware.ts ├── modules/ │ └── [module]/ │ ├── router.ts # Module router exports │ ├── types.ts # Zod schemas and TypeScript types │ └── procedures/ # Individual procedure implementations │ ├── create-item.ts │ ├── list-items.ts │ └── update-item.ts └── lib/ # Shared utilities ``` ## 2. Router Setup ### Main Router Structure The main router composes all module routers under a common prefix: ```typescript // orpc/router.ts import type { RouterClient } from "@orpc/server"; import { usersRouter } from "../modules/users/router"; import { itemsRouter } from "../modules/items/router"; import { publicProcedure } from "./procedures"; export const router = publicProcedure // Prefix for OpenAPI paths .prefix("/api") .router({ users: usersRouter, items: itemsRouter, // Add more module routers here }); // Export type for frontend client export type ApiRouterClient = RouterClient; ``` ### Module Router Composition Each module exports a router object that groups related procedures: ```typescript // modules/items/router.ts import { createItem } from "./procedures/create-item"; import { deleteItem } from "./procedures/delete-item"; import { findItem } from "./procedures/find-item"; import { listItems } from "./procedures/list-items"; import { updateItem } from "./procedures/update-item"; export const itemsRouter = { list: listItems, find: findItem, create: createItem, update: updateItem, delete: deleteItem, // Nested routes are supported drafts: { list: listDrafts, save: saveDraft, }, }; ``` ### Base Procedures with Middleware Define base procedures with common middleware: ```typescript // orpc/procedures.ts import { ORPCError, os } from "@orpc/server"; import { logIdMiddleware } from "./middleware/log-id-middleware"; // Public procedure - no authentication required export const publicProcedure = os .$context<{ headers: Headers; }>() .use(logIdMiddleware); // Protected procedure - requires authentication export const protectedProcedure = publicProcedure.use( async ({ context, next }) => { const session = await getSession(context.headers); if (!session) { throw new ORPCError("UNAUTHORIZED"); } return await next({ context: { session: session.session, user: session.user, }, }); }, ); // Admin procedure - requires admin role export const adminProcedure = protectedProcedure.use( async ({ context, next }) => { if (context.user.role !== "admin") { throw new ORPCError("FORBIDDEN"); } return await next(); }, ); ``` ## 3. Procedure Definition ### Query Procedures (GET-like) Use GET method for read operations that don't modify data: ```typescript // modules/items/procedures/list-items.ts import { z } from "zod"; import { protectedProcedure } from "../../../orpc/procedures"; // Define input schema const listItemsInputSchema = z.object({ limit: z.number().min(1).max(100).default(50), cursor: z.object({ createdAt: z.string(), id: z.string(), }).optional(), filters: z.object({ status: z.enum(["active", "archived"]).optional(), category: z.string().optional(), }).optional(), }); // Define output schema const listItemsOutputSchema = z.object({ items: z.array(z.object({ id: z.string(), name: z.string(), status: z.string(), createdAt: z.date(), })), nextCursor: z.object({ createdAt: z.string(), id: z.string(), }).nullable(), hasMore: z.boolean(), }); export const listItems = protectedProcedure .route({ method: "GET", path: "/items", tags: ["Items"], summary: "List items with cursor pagination", description: "Retrieve a paginated list of items for the current user", }) .input(listItemsInputSchema) .output(listItemsOutputSchema) .handler(async ({ input, context }) => { const { limit, cursor, filters } = input; const { user } = context; // Query implementation const items = await db.query.items.findMany({ where: { userId: user.id, ...filters }, limit: limit + 1, // Fetch one extra to check hasMore orderBy: [desc(items.createdAt), desc(items.id)], }); const hasMore = items.length > limit; const resultItems = hasMore ? items.slice(0, limit) : items; return { items: resultItems, nextCursor: hasMore && resultItems.length > 0 ? { createdAt: resultItems[resultItems.length - 1].createdAt.toISOString(), id: resultItems[resultItems.length - 1].id, } : null, hasMore, }; }); ``` ### Mutation Procedures (POST/PUT/DELETE-like) Use POST for create operations, PUT/PATCH for updates, DELETE for removals: ```typescript // modules/items/procedures/create-item.ts import { ORPCError } from "@orpc/client"; import { z } from "zod"; import { protectedProcedure } from "../../../orpc/procedures"; const createItemInputSchema = z.object({ name: z.string().min(1).max(255), description: z.string().optional(), categoryId: z.string().optional(), }); const createItemOutputSchema = z.object({ item: z.object({ id: z.string(), name: z.string(), description: z.string().nullable(), createdAt: z.date(), }), }); export const createItem = protectedProcedure .route({ method: "POST", path: "/items", tags: ["Items"], summary: "Create a new item", }) .input(createItemInputSchema) .output(createItemOutputSchema) .handler(async ({ input, context }) => { const { name, description, categoryId } = input; const { user } = context; // Validate category if provided if (categoryId) { const category = await db.query.categories.findFirst({ where: { id: categoryId, userId: user.id }, }); if (!category) { throw new ORPCError("NOT_FOUND", { message: "Category not found", }); } } const item = await db.insert(items).values({ name, description, categoryId, userId: user.id, }).returning(); return { item: item[0] }; }); ``` ### Update Procedure Example ```typescript // modules/items/procedures/update-item.ts import { ORPCError } from "@orpc/client"; import { z } from "zod"; import { protectedProcedure } from "../../../orpc/procedures"; const updateItemInputSchema = z.object({ itemId: z.string(), name: z.string().min(1).max(255).optional(), description: z.string().optional(), status: z.enum(["active", "archived"]).optional(), }); export const updateItem = protectedProcedure .route({ method: "PUT", path: "/items/{itemId}", tags: ["Items"], summary: "Update an item", }) .input(updateItemInputSchema) .handler(async ({ input, context }) => { const { itemId, ...updates } = input; const { user } = context; // Verify ownership const existingItem = await db.query.items.findFirst({ where: { id: itemId }, }); if (!existingItem) { throw new ORPCError("NOT_FOUND", { message: "Item not found" }); } if (existingItem.userId !== user.id) { throw new ORPCError("FORBIDDEN", { message: "You don't have permission to modify this item", }); } const updated = await db.update(items) .set({ ...updates, updatedAt: new Date() }) .where(eq(items.id, itemId)) .returning(); return { item: updated[0] }; }); ``` ### Input Validation with Zod oRPC uses Zod for input validation. Define schemas in a separate `types.ts` file for reusability: ```typescript // modules/items/types.ts import { z } from "zod"; // Input Schemas export const createItemInputSchema = z.object({ name: z.string().min(1).max(255), description: z.string().max(1000).optional(), tags: z.array(z.string()).max(10).optional(), }); export const updateItemInputSchema = z.object({ itemId: z.string(), name: z.string().min(1).max(255).optional(), description: z.string().max(1000).optional(), }); export const listItemsInputSchema = z.object({ limit: z.number().int().min(1).max(100).default(50), cursor: z.object({ createdAt: z.string(), id: z.string(), }).optional(), }); // Output Schemas export const itemSchema = z.object({ id: z.string(), name: z.string(), description: z.string().nullable(), status: z.enum(["active", "archived"]), createdAt: z.date(), updatedAt: z.date(), }); export const operationResultSchema = z.object({ success: z.boolean(), }); export const batchOperationResultSchema = z.object({ success: z.boolean(), successCount: z.number(), failedCount: z.number(), failedIds: z.array(z.string()).optional(), }); // Type exports (inferred from schemas) export type CreateItemInput = z.infer; export type UpdateItemInput = z.infer; export type ListItemsInput = z.infer; export type Item = z.infer; export type OperationResult = z.infer; ``` ## 4. Middleware ### Authentication Middleware Built into `protectedProcedure`: ```typescript // orpc/procedures.ts export const protectedProcedure = publicProcedure.use( async ({ context, next }) => { const session = await getSession(context.headers); if (!session) { throw new ORPCError("UNAUTHORIZED"); } // Add user info to context for downstream handlers return await next({ context: { session: session.session, user: session.user, }, }); }, ); ``` ### Logging Middleware Generate and propagate request IDs for tracing: ```typescript // orpc/middleware/log-id-middleware.ts import { os } from "@orpc/server"; function generateLogId(): string { return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; } function getOrGenerateLogId(headers: Headers): string { // Prefer client-provided x-log-id for distributed tracing const existingLogId = headers.get("x-log-id"); if (existingLogId) { return existingLogId; } return generateLogId(); } export const logIdMiddleware = os .$context<{ headers: Headers; }>() .middleware(async ({ context, next }) => { const logId = getOrGenerateLogId(context.headers); // Run with tracing context return await runWithTrace(logId, async () => { return await next({ context: { logId, }, }); }); }); ``` ### Locale Middleware Extract locale from cookies for i18n: ```typescript // orpc/middleware/locale-middleware.ts import { os } from "@orpc/server"; import { getCookie } from "@orpc/server/helpers"; import { config } from "@your-app/config"; import type { Locale } from "@your-app/i18n"; export const localeMiddleware = os .$context<{ headers: Headers; }>() .middleware(async ({ context, next }) => { const locale = (getCookie( context.headers, config.i18n.localeCookieName, ) as Locale) ?? config.i18n.defaultLocale; return await next({ context: { locale, }, }); }); ``` ### Using Middleware in Procedures Apply middleware to specific procedures: ```typescript // modules/contact/procedures/submit-contact-form.ts import { localeMiddleware } from "../../../orpc/middleware/locale-middleware"; import { publicProcedure } from "../../../orpc/procedures"; export const submitContactForm = publicProcedure .route({ method: "POST", path: "/contact", tags: ["Contact"], summary: "Submit contact form", }) .input(contactFormSchema) .use(localeMiddleware) // Apply locale middleware .handler(async ({ input, context: { locale } }) => { // locale is now available in context await sendEmail({ to: config.contactForm.to, locale, subject: config.contactForm.subject, text: `Name: ${input.name}\n\nEmail: ${input.email}\n\nMessage: ${input.message}`, }); }); ``` ### Error Handling Middleware Create custom error handling: ```typescript // orpc/middleware/error-middleware.ts import { ORPCError, os } from "@orpc/server"; import { logger } from "@your-app/logs"; export const errorMiddleware = os.middleware(async ({ next, path }) => { try { return await next(); } catch (error) { // Log error with context logger.error("Procedure error", { path, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); // Re-throw oRPC errors as-is if (error instanceof ORPCError) { throw error; } // Wrap unknown errors throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "An unexpected error occurred", }); } }); ``` ## 5. Context ### How to Access User Session The session is available in context after `protectedProcedure`: ```typescript export const getProfile = protectedProcedure .route({ method: "GET", path: "/users/profile", tags: ["Users"] }) .handler(async ({ context }) => { // context.user contains the authenticated user const { user, session } = context; return { id: user.id, email: user.email, name: user.name, role: user.role, sessionId: session.id, }; }); ``` ### How to Access Logger Use the logger from the shared logs package: ```typescript import { logger } from "@your-app/logs"; export const createItem = protectedProcedure .route({ method: "POST", path: "/items", tags: ["Items"] }) .input(createItemInputSchema) .handler(async ({ input, context }) => { logger.info("Creating item", { userId: context.user.id, itemName: input.name, }); try { const item = await db.insert(items).values({ ...input, userId: context.user.id, }).returning(); logger.info("Item created successfully", { itemId: item[0].id }); return { item: item[0] }; } catch (error) { logger.error("Failed to create item", { userId: context.user.id, error: error instanceof Error ? error.message : String(error), }); throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to create item", }); } }); ``` ### How to Access Database Import the database client and use it directly: ```typescript import { db } from "@your-app/database"; import { items, categories } from "@your-app/database/drizzle/schema"; import { eq, and, desc } from "drizzle-orm"; export const listItems = protectedProcedure .route({ method: "GET", path: "/items", tags: ["Items"] }) .handler(async ({ context }) => { // Using Drizzle query builder const userItems = await db.query.items.findMany({ where: eq(items.userId, context.user.id), orderBy: desc(items.createdAt), with: { category: true, // Include relations }, }); // Or using raw select const itemsWithCategory = await db .select({ id: items.id, name: items.name, categoryName: categories.name, }) .from(items) .leftJoin(categories, eq(items.categoryId, categories.id)) .where(eq(items.userId, context.user.id)); return { items: userItems }; }); ``` ## 6. Best Practices ### Input/Output Schema Naming Conventions Follow consistent naming patterns: ```typescript // Input schemas: [action][Entity]InputSchema export const createItemInputSchema = z.object({ ... }); export const updateItemInputSchema = z.object({ ... }); export const listItemsInputSchema = z.object({ ... }); export const deleteItemInputSchema = z.object({ ... }); // Output schemas: [action][Entity]OutputSchema or [entity]Schema export const itemSchema = z.object({ ... }); export const listItemsOutputSchema = z.object({ ... }); export const operationResultSchema = z.object({ ... }); // Shared/reusable schemas: [entity]Schema or [concept]Schema export const paginationSchema = z.object({ limit: z.number().int().min(1).max(100).default(50), cursor: z.object({ createdAt: z.string(), id: z.string(), }).optional(), }); ``` ### Error Handling Patterns Use appropriate error codes and messages: ```typescript import { ORPCError } from "@orpc/client"; // Resource not found throw new ORPCError("NOT_FOUND", { message: "Item not found", }); // Permission denied throw new ORPCError("FORBIDDEN", { message: "You don't have permission to access this resource", }); // Authentication required throw new ORPCError("UNAUTHORIZED", { message: "Please sign in to continue", }); // Validation error (usually handled by Zod, but for custom validation) throw new ORPCError("BAD_REQUEST", { message: "Invalid email format", }); // Conflict (e.g., duplicate entry) throw new ORPCError("CONFLICT", { message: "An item with this name already exists", }); // Server error (wrap internal errors) try { await externalService.call(); } catch (error) { logger.error("External service failed", { error }); throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Service temporarily unavailable", }); } ``` ### Procedure Organization 1. **One procedure per file**: Keep procedures focused and testable 2. **Group related procedures**: Use module routers to organize by domain 3. **Reuse schemas**: Define common schemas in `types.ts` 4. **Consistent file naming**: Use kebab-case matching the procedure name ``` modules/items/ ├── router.ts # Exports all procedures ├── types.ts # Shared schemas and types └── procedures/ ├── create-item.ts # createItem procedure ├── delete-item.ts # deleteItem procedure ├── find-item.ts # findItem procedure ├── list-items.ts # listItems procedure └── update-item.ts # updateItem procedure ``` ### Performance Tips 1. **Use cursor pagination** instead of offset for large datasets 2. **Batch database queries** to avoid N+1 problems 3. **Add appropriate indexes** for filtered/sorted columns 4. **Use select** to fetch only needed columns ```typescript // Avoid N+1 queries - fetch related data in batch const items = await db.query.items.findMany({ where: eq(items.userId, user.id), limit, }); // Batch fetch labels for all items const itemIds = items.map(i => i.id); const labels = await db.query.itemLabels.findMany({ where: inArray(itemLabels.itemId, itemIds), }); // Group labels by itemId const labelsByItemId = new Map(); for (const label of labels) { const existing = labelsByItemId.get(label.itemId) || []; existing.push(label); labelsByItemId.set(label.itemId, existing); } // Combine results const itemsWithLabels = items.map(item => ({ ...item, labels: labelsByItemId.get(item.id) || [], })); ``` ### Testing Procedures Structure tests to cover various scenarios: ```typescript import { describe, expect, it } from "vitest"; import { createCaller } from "../test-utils"; describe("createItem", () => { it("creates an item successfully", async () => { const caller = createCaller({ user: testUser }); const result = await caller.items.create({ name: "Test Item", description: "A test item", }); expect(result.item.name).toBe("Test Item"); expect(result.item.id).toBeDefined(); }); it("throws UNAUTHORIZED for unauthenticated users", async () => { const caller = createCaller({ user: null }); await expect( caller.items.create({ name: "Test" }) ).rejects.toThrow("UNAUTHORIZED"); }); it("validates input schema", async () => { const caller = createCaller({ user: testUser }); await expect( caller.items.create({ name: "" }) // Empty name ).rejects.toThrow(); }); }); ```