Backend Development

Building Scalable Microservices with Node.js and tRPC

A comprehensive guide to architecting type-safe microservices using tRPC, Prisma ORM, and Node.js. Learn patterns from Asynq.ai and Modelia.ai's studio platform to achieve 40% performance improvements.

Harsh RastogiHarsh Rastogi
Jan 15, 2025Updated Mar 1, 202612 min
Node.jstRPCMicroservicesTypeScriptBackend

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:

typescript
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:

typescript
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:

typescript
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):

typescript
// 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:

typescript
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:

typescript
// 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:

typescript
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

Share this article

Harsh Rastogi - Full Stack Engineer

Harsh Rastogi

Full Stack Engineer

Full Stack Engineer building production AI systems at Modelia. Previously at Asynq and Bharat Electronics Limited. Published researcher.

Connect on LinkedIn

Follow me for more insights on software engineering, system design, and career growth.

View Profile