tRPC Router Architecture
Asset360 uses a namespaced tRPC router structure that mirrors the frontend routes with two main namespaces: org and org.fund. This architecture provides type-safe API communication with clean separation between organization-level and fund-level operations.
Architecture Overview
Design Principles
- Route Mirroring: Backend tRPC routes directly mirror frontend URL structure
- Namespace Separation: Clear distinction between org and fund scoped operations
- Type Safety: End-to-end type safety from frontend to backend
- Access Control: Middleware handles authorization at namespace boundaries
- Consistent Patterns: All procedures follow the same structure and conventions
Namespace Structure
worker/trpc/routers/
├── org/ # Organization-level procedures
│ ├── index.ts # Main org router
│ ├── funds.ts # Fund management operations
│ ├── users.ts # User management operations
│ ├── organizations.ts # Organization management operations
│ ├── investors.ts # Investor management operations
│ ├── stats.ts # Org statistics and reporting
│ └── fund/ # Fund-scoped namespace (nested)
│ ├── index.ts # Fund router entry point
│ ├── accounting.ts # Fund accounting procedures
│ ├── portfolio.ts # Fund portfolio management
│ ├── investors.ts # Fund investor management
│ ├── operations.ts # Fund operations (EOD, reconciliations)
│ └── treasury.ts # Fund treasury management
├── index.ts # Main appRouter (merges all routers)
├── maintenance.ts # Maintenance and health check procedures
├── maintenance-old.ts # Legacy maintenance procedures
└── utils/ # Shared utilities and schemas
└── schemas/ # Shared Zod schemas
Router Organization
Frontend-Backend Route Mapping
The tRPC router structure provides 1:1 mapping between frontend routes and backend procedures:
| Frontend Route | tRPC Procedure | Scope | Description |
|---|---|---|---|
/$orgSlug/funds | trpc.org.funds.* | Organization | Fund CRUD and management |
/$orgSlug/users | trpc.org.users.* | Organization | User management |
/$orgSlug/organizations | trpc.org.organizations.* | Organization | Organization management |
/$orgSlug/investors | trpc.org.investors.* | Organization | Investor management |
/$orgSlug/stats | trpc.org.stats.* | Organization | Organization statistics |
/$orgSlug/f/$fundCode/accounting | trpc.org.fund.accounting.* | Fund | Fund accounting operations |
/$orgSlug/f/$fundCode/portfolio | trpc.org.fund.portfolio.* | Fund | Portfolio management |
/$orgSlug/f/$fundCode/investors | trpc.org.fund.investors.* | Fund | Fund investor operations |
/$orgSlug/f/$fundCode/operations | trpc.org.fund.operations.* | Fund | Fund operations |
/$orgSlug/f/$fundCode/treasury | trpc.org.fund.treasury.* | Fund | Treasury operations |
Benefits of This Architecture
- Intuitive Navigation: Developers can easily find backend procedures by matching frontend routes
- Clear Separation: Organization vs fund operations are clearly separated
- Type Safety: Full end-to-end type safety with automatic inference
- Access Control: Authorization is handled at namespace boundaries
- Scalability: Easy to add new procedures in appropriate namespaces
- Consistency: All procedures follow the same patterns and conventions
Router Implementation
Main App Router
// worker/trpc/routers/index.ts
import { router } from "./trpc";
import { orgRouter } from "./org";
import { healthRouter } from "./health";
export const appRouter = router({
org: orgRouter,
health: healthRouter,
});
export type AppRouter = typeof appRouter;
Organization Router Structure
// worker/trpc/routers/org/index.ts
import { router } from "../trpc";
import { fundsRouter } from "./funds";
import { usersRouter } from "./users";
import { organizationsRouter } from "./organizations";
import { investorsRouter } from "./investors";
import { statsRouter } from "./stats";
import { fundRouter } from "./fund";
export const orgRouter = router({
// Organization-level operations
funds: fundsRouter,
users: usersRouter,
organizations: organizationsRouter,
investors: investorsRouter,
stats: statsRouter,
// Nested fund namespace
fund: fundRouter,
});
Fund Router (Nested Namespace)
// worker/trpc/routers/org/fund/index.ts
import { router } from "../../trpc";
import { accountingRouter } from "./accounting";
import { portfolioRouter } from "./portfolio";
import { investorsRouter } from "./investors";
import { operationsRouter } from "./operations";
import { treasuryRouter } from "./treasury";
export const fundRouter = router({
// Fund-scoped operations
accounting: accountingRouter,
portfolio: portfolioRouter,
investors: investorsRouter,
operations: operationsRouter,
treasury: treasuryRouter,
});
Procedure Examples
Organization-Level Procedures
// worker/trpc/routers/org/funds.ts
import { publicProcedure } from "../trpc";
import { z } from "zod";
export const fundsRouter = router({
// List all funds in organization
list: publicProcedure
.input(
z.object({
orgSlug: z.string(),
pagination: z
.object({
page: z.number().int().positive().default(1),
limit: z.number().int().min(1).max(100).default(50),
})
.optional(),
}),
)
.query(async ({ input, ctx }) => {
const { orgSlug, pagination } = input;
// Get organization by slug
const orgResult =
await ctx.domainServices.organization.getOrgBySlug(orgSlug);
if (orgResult.isErr()) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
});
}
// List funds for organization
const fundsResult =
await ctx.domainServices.fund.getFundsByOrganizationId(
orgResult.value.id,
pagination,
);
if (fundsResult.isErr()) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: fundsResult.error.message,
});
}
return fundsResult.value;
}),
// Create new fund
create: publicProcedure
.input(
z.object({
orgSlug: z.string(),
fund: z.object({
name: z.string().min(1).max(200),
code: z.string().min(1).max(20),
fundType: z.string().optional(),
baseCurrency: z.string().default("BDT"),
}),
}),
)
.mutation(async ({ input, ctx }) => {
const { orgSlug, fund } = input;
// Verify organization access
const orgResult =
await ctx.domainServices.organization.getOrgBySlug(orgSlug);
if (orgResult.isErr()) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
});
}
// Create fund
const fundResult = await ctx.domainServices.fund.createFund({
organizationId: orgResult.value.id,
name: fund.name,
code: fund.code,
fundType: fund.fundType,
baseCurrency: fund.baseCurrency,
});
if (fundResult.isErr()) {
throw new TRPCError({
code: "BAD_REQUEST",
message: fundResult.error.message,
});
}
return fundResult.value;
}),
});
Fund-Scoped Procedures
// worker/trpc/routers/org/fund/accounting.ts
import { publicProcedure } from "../../trpc";
import { z } from "zod";
export const accountingRouter = router({
// Create journal entry
createJournalEntry: publicProcedure
.input(
z.object({
orgSlug: z.string(),
fundCode: z.string(),
entry: z.object({
description: z.string().min(1).max(500),
lines: z
.array(
z.object({
accountId: z.string(),
amount: z.number(),
description: z.string().optional(),
}),
)
.min(2),
}),
}),
)
.mutation(async ({ input, ctx }) => {
const { orgSlug, fundCode, entry } = input;
// Verify access permissions (handled by middleware)
const fundResult = await verifyFundAccess(ctx, orgSlug, fundCode);
if (fundResult.isErr()) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Fund not found or access denied",
});
}
// Create journal entry
const journalResult =
await ctx.domainServices.accounting.createJournalEntry({
fundId: fundResult.value.id,
description: entry.description,
lines: entry.lines,
createdBy: ctx.user?.id,
date: new Date().toISOString(),
});
if (journalResult.isErr()) {
throw new TRPCError({
code: "BAD_REQUEST",
message: journalResult.error.message,
});
}
return journalResult.value;
}),
});
Middleware and Context
TRPC Context
// worker/trpc/routers/middleware/context.ts
import type { DomainServices, ApplicationServices } from "../../../factory";
import type { Env } from "../../../types";
export interface TRPCContext {
domainServices: DomainServices;
applicationServices: ApplicationServices;
env: Env;
user?: {
id: string;
email: string;
orgId: string;
role: string;
};
}
export const createTRPCContext = (opts: CreateContextOptions): TRPCContext => {
return {
domainServices: opts.services.domain,
applicationServices: opts.services.application,
env: opts.env,
user: opts.user,
};
};
Authentication Middleware
// worker/trpc/routers/middleware/auth.ts
import { TRPCError } from "@trpc/server";
import { publicProcedure, type TRPCContext } from "./trpc";
export const authenticatedProcedure = publicProcedure.use(
async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in to perform this action",
});
}
return next({
ctx: {
...ctx,
user: ctx.user, // Type assertion that user exists
},
});
},
);
Authorization Middleware
// worker/trpc/routers/middleware/authorization.ts
import { TRPCError } from "@trpc/server";
import { authenticatedProcedure, type TRPCContext } from "./trpc";
export const requireOrgAccess = authenticatedProcedure.use(
async ({ ctx, next }) => {
// User must belong to an organization
if (!ctx.user?.orgId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Organization access required",
});
}
return next({
ctx: {
...ctx,
orgId: ctx.user.orgId,
},
});
},
);
export const requireFundAccess = requireOrgAccess.use(async ({ ctx, next }) => {
// Fund access will be verified at procedure level
// This middleware ensures user has org access
return next({
ctx: {
...ctx,
orgId: ctx.orgId!,
},
});
});
Frontend Integration
Type-Safe Client
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../../../worker/trpc/routers";
export const trpc = createTRPCReact<AppRouter>();
// Create client with proper context
export const createTRPCClient = (env: Env) => {
return trpc.createClient({
links: [
httpBatchLink({
url: env.API_URL || "http://localhost:8787/trpc",
headers() {
return {
// Add authentication headers if needed
authorization: env.AUTH_TOKEN
? `Bearer ${env.AUTH_TOKEN}`
: undefined,
};
},
}),
],
});
};
Using the Client
// src/components/funds/FundList.tsx
import { trpc } from "@/lib/trpc";
import { useParams } from "@tanstack/react-router";
export function FundList() {
const { orgSlug } = useParams({ from: "/$orgSlug/funds" });
const {
data: funds,
isLoading,
error,
} = trpc.org.funds.list.useQuery({
orgSlug,
pagination: { page: 1, limit: 20 },
});
if (isLoading) return <div>Loading funds...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h2>Funds for {orgSlug}</h2>
{funds?.map((fund) => (
<div key={fund.id}>
<h3>{fund.name}</h3>
<p>Code: {fund.code}</p>
<Link to="/$orgSlug/f/$fundCode" params={{ orgSlug, fundCode: fund.code }}>
View Fund →
</Link>
</div>
))}
</div>
);
}
Best Practices
1. Input Validation
Always use Zod schemas for input validation:
export const createFundProcedure = publicProcedure
.input(
z.object({
orgSlug: z.string().min(1),
fund: z.object({
name: z.string().min(1).max(200),
code: z
.string()
.min(1)
.max(20)
.regex(/^[A-Z0-9]+$/),
fundType: z
.enum(["equity", "balanced", "debt", "money-market"])
.optional(),
}),
}),
)
.mutation(async ({ input, ctx }) => {
// Implementation
});
2. Error Handling
Convert domain errors to appropriate tRPC errors:
const handleError = (error: DomainError): TRPCError => {
switch (error.type) {
case "ValidationError":
return new TRPCError({
code: "BAD_REQUEST",
message: error.message,
});
case "NotFoundError":
return new TRPCError({
code: "NOT_FOUND",
message: `${error.entity} with ID ${error.id} not found`,
});
case "InsufficientFunds":
return new TRPCError({
code: "BAD_REQUEST",
message: `Insufficient funds: ${error.available} available, ${error.required} required`,
});
default:
return new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "An unexpected error occurred",
});
}
};
3. Access Control
Use middleware for consistent access control:
export const fundProcedure = authenticatedProcedure
.input(
z.object({
orgSlug: z.string(),
fundCode: z.string(),
}),
)
.use(async ({ ctx, input, next }) => {
// Verify organization access
const orgResult = await ctx.domainServices.organization.getOrgBySlug(
input.orgSlug,
);
if (orgResult.isErr() || orgResult.value.id !== ctx.user?.orgId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Organization access denied",
});
}
// Verify fund access
const fundResult = await ctx.domainServices.fund.getFundByCodeAndOrg(
input.fundCode,
orgResult.value.id,
);
if (fundResult.isErr()) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Fund not found",
});
}
return next({
ctx: {
...ctx,
fund: fundResult.value,
},
});
});
4. Response Formatting
Consistent response formats across procedures:
// Successful response
return {
success: true,
data: result,
pagination: {
page: 1,
limit: 50,
total: 100,
hasNext: true,
},
};
// Error response (handled by tRPC)
// tRPC automatically formats errors with consistent structure
5. Procedure Organization
Group related procedures in logical routers:
// Accounting operations
export const accountingRouter = router({
// Query operations
getTrialBalance: publicProcedure.query(...),
getJournalEntries: publicProcedure.query(...),
getNetAssetValue: publicProcedure.query(...),
// Mutation operations
createJournalEntry: authenticatedProcedure.mutation(...),
reverseJournalEntry: authenticatedProcedure.mutation(...),
closePeriod: authenticatedProcedure.mutation(...),
});
Performance Considerations
1. Batching
Enable HTTP batching for improved performance:
// Client configuration
const client = trpc.createClient({
links: [
httpBatchLink({
url: "/trpc",
maxURLLength: 2048, // Batch until URL gets too long
}),
],
});
2. Caching
Use React Query for intelligent caching:
const { data } = trpc.org.funds.list.useQuery(
{ orgSlug },
{
staleTime: 60 * 1000, // 1 minute
cacheTime: 5 * 60 * 1000, // 5 minutes
},
);
3. Pagination
Implement consistent pagination patterns:
const paginationSchema = z.object({
page: z.number().int().positive().default(1),
limit: z.number().int().min(1).max(100).default(50),
});
export const listFunds = publicProcedure
.input(
z.object({
orgSlug: z.string(),
pagination: paginationSchema.optional(),
}),
)
.query(async ({ input, ctx }) => {
const pagination = input.pagination || { page: 1, limit: 50 };
// Implementation
});
Testing
Procedure Testing
import { createCallerFactory } from "@trpc/server";
import { appRouter } from "../../../worker/trpc/routers";
import { createTestContext } from "../../helpers/trpc";
const createCaller = createCallerFactory<AppRouter>();
describe("Funds Router", () => {
it("should list funds for organization", async () => {
const ctx = createTestContext();
const caller = createCaller(ctx);
const result = await caller.org.funds.list({
orgSlug: "test-org",
pagination: { page: 1, limit: 10 },
});
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
});
it("should create fund with valid input", async () => {
const ctx = createTestContext();
const caller = createCaller(ctx);
const fundData = {
orgSlug: "test-org",
fund: {
name: "Test Fund",
code: "TEST01",
fundType: "equity",
},
};
const result = await caller.org.funds.create(fundData);
expect(result.id).toBeDefined();
expect(result.name).toBe(fundData.fund.name);
expect(result.code).toBe(fundData.fund.code);
});
});
Integration Testing
describe("tRPC Integration", () => {
it("should handle complete fund setup workflow", async () => {
const ctx = await createIntegrationTestContext();
const caller = createCaller(ctx);
// Create organization
const org = await caller.org.create({
name: "Test Organization",
slug: "test-org",
});
// Create fund
const fund = await caller.org.funds.create({
orgSlug: org.slug,
fund: {
name: "Test Fund",
code: "TEST01",
},
});
// Verify fund access
const fundDetails = await caller.org.fund.accounting.getTrialBalance({
orgSlug: org.slug,
fundCode: fund.code,
});
expect(fundDetails).toBeDefined();
});
});
Conclusion
The tRPC router architecture provides:
- Type Safety: End-to-end type safety from frontend to backend
- Intuitive Structure: Route mirroring makes navigation and development intuitive
- Clear Separation: Organization vs fund operations are properly separated
- Consistent Patterns: All procedures follow the same conventions
- Easy Testing: Type-safe procedure testing with comprehensive tooling
- Performance: Built-in batching and optimization features
- Developer Experience: Auto-completion and type inference throughout
This architecture scales well as the application grows and provides a solid foundation for financial operations requiring type safety and reliability.
Last Updated: 2025-01-11 - Complete tRPC router architecture documentation with examples and best practices