Skip to main content

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

  1. Route Mirroring: Backend tRPC routes directly mirror frontend URL structure
  2. Namespace Separation: Clear distinction between org and fund scoped operations
  3. Type Safety: End-to-end type safety from frontend to backend
  4. Access Control: Middleware handles authorization at namespace boundaries
  5. 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 RoutetRPC ProcedureScopeDescription
/$orgSlug/fundstrpc.org.funds.*OrganizationFund CRUD and management
/$orgSlug/userstrpc.org.users.*OrganizationUser management
/$orgSlug/organizationstrpc.org.organizations.*OrganizationOrganization management
/$orgSlug/investorstrpc.org.investors.*OrganizationInvestor management
/$orgSlug/statstrpc.org.stats.*OrganizationOrganization statistics
/$orgSlug/f/$fundCode/accountingtrpc.org.fund.accounting.*FundFund accounting operations
/$orgSlug/f/$fundCode/portfoliotrpc.org.fund.portfolio.*FundPortfolio management
/$orgSlug/f/$fundCode/investorstrpc.org.fund.investors.*FundFund investor operations
/$orgSlug/f/$fundCode/operationstrpc.org.fund.operations.*FundFund operations
/$orgSlug/f/$fundCode/treasurytrpc.org.fund.treasury.*FundTreasury operations

Benefits of This Architecture

  1. Intuitive Navigation: Developers can easily find backend procedures by matching frontend routes
  2. Clear Separation: Organization vs fund operations are clearly separated
  3. Type Safety: Full end-to-end type safety with automatic inference
  4. Access Control: Authorization is handled at namespace boundaries
  5. Scalability: Easy to add new procedures in appropriate namespaces
  6. 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:

  1. Type Safety: End-to-end type safety from frontend to backend
  2. Intuitive Structure: Route mirroring makes navigation and development intuitive
  3. Clear Separation: Organization vs fund operations are properly separated
  4. Consistent Patterns: All procedures follow the same conventions
  5. Easy Testing: Type-safe procedure testing with comprehensive tooling
  6. Performance: Built-in batching and optimization features
  7. 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