I’ve been thinking about this topic a lot lately because I’ve seen too many teams struggle with microservices communication. The debugging nightmares, the runtime errors that should have been caught during development, the hours spent tracing messages through distributed systems—it all comes down to one fundamental issue: we’re not being strict enough with our types across service boundaries.
What if we could catch message contract violations before they even reach production? That’s exactly what we’re going to build today.
Let me show you how to create microservices that communicate with the same type safety you enjoy within a single application. We’ll use NestJS for its excellent dependency injection and built-in messaging patterns, RabbitMQ for reliable message delivery, and Prisma for type-safe database operations. The result? A system where your compiler catches inter-service communication errors during development.
First, let’s establish our shared type definitions. This is the foundation that ensures all our services speak the same language:
// Shared event types
export interface UserCreatedEvent {
type: 'user.created';
data: {
userId: string;
email: string;
firstName: string;
lastName: string;
};
}
export interface OrderCreatedEvent {
type: 'order.created';
data: {
orderId: string;
userId: string;
items: Array<{
productId: string;
quantity: number;
price: number;
}>;
totalAmount: number;
};
}
Notice how we’re defining strict interfaces for every event? This prevents the common pitfall of services sending slightly different data structures that break downstream consumers.
Now, let’s set up our User Service to publish events. Here’s where NestJS really shines with its built-in microservices package:
// user.service.ts
@Injectable()
export class UserService {
constructor(
private readonly client: ClientProxy,
private readonly prisma: PrismaService
) {}
async createUser(createUserDto: CreateUserDto) {
const user = await this.prisma.user.create({
data: createUserDto
});
// Publish event with typed payload
this.client.emit('user.created', {
type: 'user.created',
data: {
userId: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName
}
} as UserCreatedEvent);
return user;
}
}
Did you notice how we’re casting our event payload to the UserCreatedEvent type? This gives us compile-time validation that we’re sending the correct data structure.
But what happens when services need to react to these events? Let’s look at the Order Service:
// order.controller.ts
@Controller()
export class OrderController {
@EventPattern('user.created')
async handleUserCreated(data: UserCreatedEvent['data']) {
// TypeScript knows the exact structure of 'data'
await this.orderService.createUserProfile({
userId: data.userId,
email: data.email,
// Type error if we try to access non-existent properties
});
}
}
Here’s an interesting question: how do we ensure that our message broker configuration matches our type definitions? Let me show you our RabbitMQ setup:
// rabbitmq.config.ts
@Module({
imports: [
ClientsModule.register([
{
name: 'EVENT_BUS',
transport: Transport.RMQ,
options: {
urls: ['amqp://localhost:5672'],
queue: 'domain_events',
queueOptions: {
durable: true,
},
},
},
]),
],
})
export class RabbitMQModule {}
Now, what about database operations across multiple services? This is where Prisma’s type safety becomes invaluable. Each service has its own database schema, but they share common identifiers:
// order.service.ts - Creating an order
async createOrder(createOrderDto: CreateOrderDto) {
const order = await this.prisma.order.create({
data: {
userId: createOrderDto.userId, // Foreign key to user service
items: {
create: createOrderDto.items.map(item => ({
productId: item.productId,
quantity: item.quantity,
price: item.price
}))
},
totalAmount: createOrderDto.totalAmount,
status: 'pending'
}
});
// Emit order created event
this.client.emit('order.created', {
type: 'order.created',
data: {
orderId: order.id,
userId: order.userId,
items: order.items,
totalAmount: order.totalAmount
}
} as OrderCreatedEvent);
return order;
}
But what happens when things go wrong? Error handling in event-driven systems requires careful consideration. Let’s implement a dead letter queue pattern:
// notification.service.ts - With error handling
@EventPattern('order.created')
async handleOrderCreated(data: OrderCreatedEvent['data']) {
try {
await this.notificationService.sendOrderConfirmation({
userId: data.userId,
orderId: data.orderId,
totalAmount: data.totalAmount
});
} catch (error) {
// Move failed message to DLQ
await this.dlqService.storeFailedMessage({
originalEvent: 'order.created',
payload: data,
error: error.message,
timestamp: new Date()
});
// Implement retry logic or alert developers
this.logger.error(`Failed to process order.created event`, error);
}
}
Here’s something I often wonder: how can we test these event flows without spinning up our entire infrastructure? Let me share a testing strategy that has served me well:
// order.service.spec.ts
describe('OrderService', () => {
it('should publish order.created event when order is created', async () => {
const clientProxy = { emit: jest.fn() };
const service = new OrderService(clientProxy as any, prismaService);
await service.createOrder(testOrderData);
expect(clientProxy.emit).toHaveBeenCalledWith(
'order.created',
expect.objectContaining({
type: 'order.created',
data: expect.objectContaining({
userId: testOrderData.userId,
totalAmount: testOrderData.totalAmount
})
})
);
});
});
The beauty of this approach is that we’re not just catching type errors—we’re creating a self-documenting system. Any developer looking at our event types immediately understands the contracts between services.
As we scale, we can extend this pattern to include schema validation at runtime, but the compile-time checks already eliminate most common errors. The combination of TypeScript’s static analysis and NestJS’s dependency injection creates a development experience that feels like working on a monolith, with all the scalability benefits of microservices.
What patterns have you found effective for maintaining type safety across service boundaries? I’d love to hear about your experiences in the comments below.
If this approach resonates with you, consider sharing it with your team. The shift to type-safe event-driven architecture has dramatically reduced our production incidents and improved developer confidence when working across service boundaries. Your thoughts and feedback in the comments would be greatly appreciated—let’s continue this conversation about building more reliable distributed systems together.