tRPC API Architecture
Overview
Wafra has fully migrated to tRPC for all API communication, providing end-to-end type safety, excellent developer experience, and simplified client-server communication. The REST API has been completely removed in favor of this modern, type-safe approach.
Current Architecture
Backend Implementation
Fastify Integration:
- tRPC Fastify Plugin: Direct integration with Fastify server
- Request Context: Rich context including user sessions and request metadata
- Error Handling: Centralized error handling with custom error types
- Middleware Chain: Authentication, validation, and logging middleware
Server Structure:
// apps/server/src/trpc/index.ts
export const appRouter = router({
auth: authRouter,
passkey: passkeyRouter,
balances: balancesRouter,
payment: paymentRouter,
wallet: walletRouter,
transactions: transactionsRouter,
kyc: kycRouter,
dev: devRouter, // Development only
});
export type AppRouter = typeof appRouter;Router Organization
Current Router Structure:
| Router | Purpose | Key Procedures |
|---|---|---|
| auth | Authentication flows | signIn, signUp, refresh |
| passkey | WebAuthn management | register, authenticate, approve |
| balances | Wallet balances | getBalances, getHistory |
| payment | Payment processing | createOrder, getQuotes |
| wallet | Wallet operations | setup, getInfo, backup |
| transactions | Transaction history | getTransactions, getDetails |
| kyc | KYC workflows | getRequirements, submitKYC |
| dev | Development tools | generateJWT, getUsers |
Type Safety Implementation
End-to-End Types:
// Shared types across client and server
import type { AppRouter } from "@/server/trpc";
// Client gets full type safety
const trpc = createTRPCReact<AppRouter>();
// All queries and mutations are fully typed
const { data, isLoading } = trpc.auth.getSession.useQuery();
const signIn = trpc.auth.signIn.useMutation();Request Context System
Context Structure
Current Context Implementation:
interface TRPCContext {
req: FastifyRequest;
res: FastifyReply;
session?: SessionContext;
db: PrismaClient;
services: {
auth: AuthService;
payment: PaymentService;
wallet: WalletService;
kyc: KYCService;
};
}
interface SessionContext {
user: {
id: string;
phone: string;
name?: string;
walletAddress?: string;
phoneVerified: Date | null;
onboardingCompleted: boolean;
country: string;
currency?: string;
};
passkeyId?: string;
}Context Creation
Request Processing:
export async function createContext({
req,
res,
}: {
req: FastifyRequest;
res: FastifyReply;
}): Promise<TRPCContext> {
// Extract JWT from Authorization header
const token = req.headers.authorization?.replace("Bearer ", "");
// Verify session if token exists
let session: SessionContext | undefined;
if (token) {
session = await verifySession(token);
}
return {
req,
res,
session,
db: prisma,
services: {
auth: new AuthService(),
payment: new PaymentService(),
wallet: new WalletService(),
kyc: new KYCService(),
},
};
}Procedure Types & Protection
Public Procedures
Open Access Endpoints:
// No authentication required
export const publicProcedure = t.procedure.use(loggingMiddleware);
// Examples:
auth.signIn; // Phone + password login
auth.signUp; // User registration
dev.generateJWT; // Development JWT (dev only)Protected Procedures
Authentication Required:
// Requires valid JWT and session
export const protectedProcedure = t.procedure
.use(loggingMiddleware)
.use(requireAuth);
// Examples:
balances.getBalances; // User wallet balances
wallet.setup; // Create new wallet
payment.createOrder; // Start payment flow
passkey.register; // Register new deviceMiddleware Chain
Current Middleware Stack:
const requireAuth = t.middleware(async ({ ctx, next }) => {
if (!ctx.session) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication required",
});
}
return next({
ctx: {
...ctx,
session: ctx.session, // Now guaranteed to exist
},
});
});Input Validation & Schemas
Zod Schema Integration
Runtime Validation:
// Example procedure with validation
export const createPaymentOrder = protectedProcedure
.input(
z.object({
amount: z.number().positive('Amount must be positive'),
currency: z.enum(['USD', 'EUR', 'GBP']),
provider: z.enum(['onramp', 'dtr']).optional(),
})
)
.output(
z.object({
orderId: z.string(),
status: z.enum(['pending', 'processing', 'completed']),
paymentUrl?: z.string(),
})
)
.mutation(async ({ input, ctx }) => {
// Input is fully typed and validated
return await ctx.services.payment.createOrder(ctx.session.user, input);
});Common Validation Patterns
Reusable Schemas:
// Common validation schemas
export const UserIdSchema = z.string().uuid();
export const AmountSchema = z.number().positive().max(1000000);
export const CurrencySchema = z.enum(["USD", "EUR", "GBP"]);
export const AddressSchema = z.string().regex(/^0x[a-fA-F0-9]{40}$/);
// Reused across multiple procedures
export const WalletAddressInput = z.object({
address: AddressSchema,
});Error Handling System
Custom Error Types
Application-Specific Errors:
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
public details?: any
) {
super(message);
}
}
export class BusinessLogicError extends AppError {
constructor(message: string, details?: any) {
super(400, message, details);
}
}
export class ValidationError extends AppError {
constructor(message: string, validationErrors: any[]) {
super(400, message, validationErrors);
}
}Global Error Handler
Centralized Error Processing:
export const errorHandler = (error: any, req: FastifyRequest) => {
if (error instanceof AppError) {
return {
code: "BAD_REQUEST",
message: error.message,
details: error.details,
};
}
if (error instanceof ZodError) {
return {
code: "BAD_REQUEST",
message: "Validation failed",
details: error.errors,
};
}
// Log unexpected errors
logger.error("Unexpected tRPC error:", { error, url: req.url });
return {
code: "INTERNAL_SERVER_ERROR",
message: "Internal server error",
};
};Response Patterns
Standardized Response Format
Consistent API Responses:
interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
details?: any;
}
// Success responses
return {
success: true,
data: result,
};
// Error responses
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid input",
cause: validationErrors,
});Pagination Support
List Endpoints with Pagination:
export const getTransactions = protectedProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(20),
cursor: z.string().optional(),
filter: z
.object({
type: z.enum(["deposit", "withdrawal"]).optional(),
status: z.enum(["pending", "completed", "failed"]).optional(),
})
.optional(),
})
)
.query(async ({ input, ctx }) => {
const transactions = await getTransactionsPaginated(
ctx.session.user.id,
input
);
return {
items: transactions,
nextCursor: getNextCursor(transactions),
hasMore: transactions.length === input.limit,
};
});Client Integration
React Query Integration
Automatic Caching & Synchronization:
// apps/app/src/utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/trpc';
export const trpc = createTRPCReact<AppRouter>();
// Provider setup with React Query
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
headers: () => ({
authorization: `Bearer ${getAuthToken()}`,
}),
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}Manual Refresh with Real-Time Awareness
Socket.IO Integration with Manual Control:
// Listen-only mode for real-time events (no automatic updates)
useEffect(() => {
const socket = io();
socket.on("balance-updated", (data) => {
// Log event but don't auto-update
console.log("💰 Balance update available - manual refresh needed");
});
socket.on("transaction-status-changed", (data) => {
// Log event but don't auto-update
console.log("📡 Transaction status changed:", data.status);
console.log("📡 Manual refresh needed to see changes");
});
return () => socket.disconnect();
}, []);
// Manual refresh function
const manualRefresh = useCallback(() => {
// User-triggered invalidation
queryClient.invalidateQueries({ queryKey: ["balances"] });
queryClient.invalidateQueries({ queryKey: ["transactions"] });
}, [queryClient]);Development Tools
Development Router
Development-Only Endpoints:
// Only available when NODE_ENV=development
export const devRouter = router({
generateJWT: publicProcedure
.input(z.object({ userId: z.string().optional() }))
.mutation(async ({ input }) => {
if (process.env.NODE_ENV !== "development") {
throw new TRPCError({ code: "FORBIDDEN" });
}
return generateDevJWT(input.userId);
}),
getUsers: publicProcedure.query(async () => {
if (process.env.NODE_ENV !== "development") {
throw new TRPCError({ code: "FORBIDDEN" });
}
return getAllUsers();
}),
});Type Generation
Automatic Type Extraction:
// Generate types for frontend consumption
export type RouterInputs = inferRouterInputs<AppRouter>;
export type RouterOutputs = inferRouterOutputs<AppRouter>;
// Usage in components
type AuthSignInInput = RouterInputs["auth"]["signIn"];
type UserBalances = RouterOutputs["balances"]["getBalances"];Performance Optimizations
Query Batching
Automatic Request Batching:
- Multiple simultaneous queries batched into single HTTP request
- Reduces network overhead and improves performance
- Configurable batch window for optimal bundling
Caching Strategy
React Query Integration with Manual Control:
- Query Caching: Long-term caching with manual invalidation
- Initial Auto-Load: Automatic data loading on first mount
- No Background Refetch: Eliminates automatic polling/refreshing after initial load
- Optimistic Updates: Immediate UI updates for mutations
- Manual Invalidation: User-controlled cache invalidation via refresh buttons
- Real-Time Awareness: Socket events provide update notifications without auto-refresh
Migration Benefits
Developer Experience
Improvements Over REST:
- Type Safety: Compile-time error detection
- Auto-Complete: Full IDE support for API calls
- Reduced Boilerplate: No manual API client code
- Integrated Validation: Zod schemas shared between client/server
Performance Benefits
Technical Improvements:
- Request Batching: Multiple queries in single request
- Automatic Caching: Built-in React Query integration
- Real-Time Updates: Efficient cache invalidation
- Reduced Bundle Size: Tree-shaking eliminates unused procedures
This tRPC architecture provides a modern, type-safe foundation for all API communication while maintaining excellent performance and developer experience.