Why Type-Safe Microservices Matter
When I joined Asynq.ai as a Software Development Engineer, one of the first challenges I tackled was building a microservices architecture that could scale without sacrificing developer experience. The codebase was growing fast — we were building an Agentic AI platform for intelligent hiring — and every week brought new integration points between services. Traditional REST APIs with manual type definitions weren't cutting it. Mismatched request/response shapes caused silent bugs that only surfaced in production.
The answer was tRPC — a framework that gives you end-to-end type safety from your database to your frontend. No code generation, no schema files, no runtime overhead. Just TypeScript doing what TypeScript does best.
Now at Modelia.ai, I apply these patterns on the studio platform — building high-usage features like Modelia Workflows, parallel processing, and payment systems with Node.js microservices. On the Shopify side, I use Remix, React, GraphQL, and Shopify Admin APIs to build merchant-facing apps with AI image and video generation. The stakes are high — Shopify merchants expect sub-200ms responses for AI-powered features.
The Architecture
The microservices pattern we use at Modelia.ai follows a clear separation of concerns that I refined over two companies:
- ›API Gateway — A single entry point that handles authentication, rate limiting, and routes requests to the appropriate service. We use a lightweight Express server with tRPC adapters.
- ›Service Layer — Individual tRPC routers handling specific business domains: user management, AI model orchestration, Shopify integration, and analytics.
- ›Data Layer — Prisma ORM connecting to PostgreSQL with connection pooling via PgBouncer. Each service owns its own database schema.
- ›Cache Layer — Redis for session management, API response caching, and frequently accessed product catalog data.
- ›Message Queue — For async operations like AI model inference, image processing, and notification delivery.
This architecture allows each service to be developed, deployed, and scaled independently. When our Shopify extension handles a traffic spike during a merchant's flash sale, we can scale the product service without touching the AI inference pipeline.
Setting Up tRPC with Node.js
The foundation starts with a well-structured tRPC router. Here's the pattern I built at Asynq.ai and now use on Modelia.ai's studio platform:
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
import { prisma } from './db';
import { redis } from './cache';
interface Context {
user: { id: string; role: string } | null;
prisma: typeof prisma;
redis: typeof redis;
}
const t = initTRPC.context<Context>().create();
const isAuthenticated = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(isAuthenticated);
export const appRouter = t.router({
user: userRouter,
analytics: analyticsRouter,
notifications: notificationRouter,
shopify: shopifyRouter,
ai: aiModelRouter,
});
export type AppRouter = typeof appRouter;The beauty of this setup is that the AppRouter type is automatically inferred from your router definition. Your frontend client knows exactly what procedures are available, what inputs they accept, and what outputs they return — all at compile time.
Input Validation with Zod
Every procedure gets runtime validation that matches its TypeScript types:
const productRouter = t.router({
getById: publicProcedure
.input(z.object({
id: z.string().uuid(),
includeVariants: z.boolean().default(false),
}))
.query(async ({ input, ctx }) => {
const cacheKey = `product:${input.id}`;
const cached = await ctx.redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const product = await ctx.prisma.product.findUnique({
where: { id: input.id },
include: input.includeVariants ? { variants: true } : undefined,
});
if (product) {
await ctx.redis.set(cacheKey, JSON.stringify(product), 'EX', 3600);
}
return product;
}),
create: protectedProcedure
.input(z.object({
name: z.string().min(1).max(255),
price: z.number().positive(),
shopifyProductId: z.string().optional(),
tags: z.array(z.string()).max(10),
}))
.mutation(async ({ input, ctx }) => {
return ctx.prisma.product.create({
data: { ...input, createdBy: ctx.user.id },
});
}),
});Performance Optimization Patterns
At Asynq.ai, we achieved a 40% reduction in API response times by implementing three core patterns that I now consider essential for any microservices architecture:
1. Query Batching with DataLoader
The N+1 query problem is the most common performance killer. When you fetch a list of candidates and then loop through to get each candidate's assessment scores, you end up with hundreds of database queries. DataLoader batches these into a single query:
import DataLoader from 'dataloader';
const assessmentLoader = new DataLoader(async (candidateIds: readonly string[]) => {
const assessments = await prisma.assessment.findMany({
where: { candidateId: { in: [...candidateIds] } },
});
const grouped = new Map<string, Assessment[]>();
assessments.forEach(a => {
const existing = grouped.get(a.candidateId) || [];
grouped.set(a.candidateId, [...existing, a]);
});
return candidateIds.map(id => grouped.get(id) || []);
});2. Connection Pooling with PgBouncer
On Modelia.ai's studio platform, the Node.js microservices could open 20+ database connections each. With multiple services running replicas, we were exhausting PostgreSQL's connection limit. PgBouncer sits between our services and the database, maintaining a pool of reusable connections. This reduced our total connection count from 200+ to 20 while actually improving throughput.
3. Selective Field Queries
Prisma's select API lets you fetch only the columns you need. For our product listing endpoint at Modelia.ai, this meant the difference between fetching 15KB per product (with all variant data, AI metadata, and image URLs) versus 800 bytes (just name, price, and thumbnail):
// Instead of this (fetches everything):
const products = await prisma.product.findMany({ take: 50 });
// Do this (fetches only what the listing needs):
const products = await prisma.product.findMany({
take: 50,
select: {
id: true,
name: true,
price: true,
thumbnail: true,
_count: { select: { variants: true } },
},
});Error Handling Across Services
One lesson I learned the hard way at Asynq.ai is that error handling in microservices needs to be consistent across all services. We built a shared error middleware:
import { TRPCError } from '@trpc/server';
export function handleServiceError(error: unknown): never {
if (error instanceof TRPCError) throw error;
if (error instanceof PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
throw new TRPCError({
code: 'CONFLICT',
message: 'A record with this value already exists',
});
}
if (error.code === 'P2025') {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Record not found',
});
}
}
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'An unexpected error occurred',
});
}Real-World Results
When we deployed these patterns at scale across both companies:
- ›Average response time dropped from 250ms to 150ms at Asynq.ai
- ›Database connection count reduced by 60% at Modelia.ai
- ›Memory usage decreased by 35% per service instance
- ›Zero type-related production bugs in 6 months after adopting tRPC
- ›Developer onboarding time reduced from 2 weeks to 3 days — new engineers can explore the entire API surface through TypeScript autocomplete
Inter-Service Communication
When one service needs to call another (e.g., the Shopify webhook handler needs to trigger AI inference), we use tRPC's caller API for synchronous calls and Redis pub/sub for async events:
// Synchronous: Shopify service calls AI service
const aiCaller = aiRouter.createCaller(context);
const recommendation = await aiCaller.generateStyleRecommendation({
productId: shopifyProduct.id,
customerProfile: customer,
});
// Async: Publish event for non-critical processing
await redis.publish('events:product-viewed', JSON.stringify({
productId: shopifyProduct.id,
customerId: customer.id,
timestamp: Date.now(),
}));Monitoring and Observability
Microservices without observability are a nightmare to debug. We instrument every tRPC procedure with timing and error tracking:
const loggingMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
console.log({
path,
type,
duration,
ok: result.ok,
timestamp: new Date().toISOString(),
});
if (duration > 500) {
console.warn(`Slow procedure: ${path} took ${duration}ms`);
}
return result;
});Lessons from Production
Building microservices for Modelia.ai's Generative AI platform and Asynq.ai's Agentic AI hiring system taught me that type safety isn't just about catching bugs early — it's about enabling faster iteration. When your API contracts are enforced at compile time, you can refactor with confidence. You can split a monolithic router into separate services knowing that every consumer will get a compile error if the contract changes.
The discipline I learned at Bharat Electronics Limited (BEL) — documenting every interface, testing every edge case — applies directly to microservices design. Defence systems can't afford undefined behavior, and neither can production AI platforms serving paying customers.
Key Takeaways
- ›Start with a monolithic tRPC router, then split into microservices as your team and domain complexity grows
- ›Always use Zod validation at service boundaries — never trust input, even from internal services
- ›Invest in connection pooling early — PgBouncer paid for itself in the first week at Modelia.ai
- ›Type safety between services eliminates an entire class of production bugs and makes refactoring fearless
- ›Use DataLoader to batch database queries — the N+1 problem is the most common microservices performance killer
- ›Monitor everything from day one — you can't optimize what you can't measure
- ›Error handling must be consistent across all services — build shared middleware
- ›Async communication (Redis pub/sub or message queues) for non-critical cross-service operations
