Lately, I’ve been thinking a lot about the hidden costs of building modern applications. We often trade type safety for the flexibility of microservices, leading to runtime errors that are difficult to track down in a distributed system. This frustration led me to explore a different approach, combining tRPC, Prisma, and Docker to build a system where type safety isn’t lost at the service boundary.
Imagine calling a function in another service and having full IntelliSense, with your editor knowing the exact shape of the request and response. That’s the developer experience we’re building today. Why spend hours debugging API mismatches when your tools can prevent them from happening?
Let’s start with the foundation. We define shared types that every service can use, creating a single source of truth.
// In a shared package
export interface User {
id: string;
email: string;
name: string;
}
Each microservice gets its own tRPC router. The beauty here is that these routers are fully typed and can be combined or consumed by other services or a frontend client.
// In the user-service
import { publicProcedure, router } from '@shared/trpc';
import { z } from 'zod';
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
// Your database logic here
return { id: input.id, email: '[email protected]', name: 'John Doe' };
}),
});
How do we ensure our database operations are just as safe? This is where Prisma shines. Its type-generated client works seamlessly with our existing types, catching errors before they hit production.
// A sample Prisma query
const user = await prisma.user.findUnique({
where: { id: input.id },
select: { id: true, email: true, name: true },
});
// `user` is fully typed
But what about getting these services to talk to each other without losing type safety? We create a client for each router. When the order service needs user data, it uses a typed client instead of a traditional HTTP call.
// In the order-service, calling the user-service
const user = await userClient.getById.query({ id: userId });
// TypeScript knows the exact structure of `user`
Containerizing each service with Docker ensures consistency from development to production. Each service lives in its own container, with a Dockerfile
defining its environment.
# Example Dockerfile for a Node.js service
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 3000
CMD ["node", "dist/index.js"]
Orchestrating them is simple with Docker Compose. One command brings up the entire network of services, their databases, and any other dependencies.
# docker-compose.yml snippet
services:
user-service:
build: ./packages/user-service
ports:
- "3001:3000"
order-service:
build: ./packages/order-service
ports:
- "3002:3000"
This approach transforms how we build and maintain complex systems. The initial setup pays for itself many times over by reducing bugs and improving developer velocity. Have you considered how much time your team spends debugging type-related issues between services?
Building with these tools feels like gaining a superpower. The confidence that comes from end-to-end type safety allows for faster iteration and more robust deployments. It’s not just about writing code; it’s about creating a system that is maintainable and enjoyable to work with.
I hope this guide provides a clear path for your next project. If you found it helpful, please share it with your network and let me know your thoughts in the comments. What has been your biggest challenge with microservices?