📌 This is part 1 of a series on building event-driven architecture in a Node.js monolith. No microservices, no Kafka — just patterns that actually work at scale.
I didn't set out to implement CQRS. I'm a solo developer building a sync platform for e-commerce stores. Node.js, Express, Firestore. Nothing fancy.
But at some point, I needed search. Real search. With filters, facets, and sorting. And Firestore just... couldn't.
So I made a decision that changed my entire architecture without me even realizing it.
The moment it broke
Here's the thing about Firestore — it's great for writes. Atomic operations, real-time listeners, scales without thinking. I was happy with it.
Then the frontend came along and asked for this: "I need to search products by name, filter by three categories, sort by price, and show me how many results are in each status."
If you've tried doing this in Firestore, you know the pain. Composite indexes for every combination. Client-side filtering. Queries that kinda work but not really. I spent two days trying to make it happen before accepting it wasn't going to.
I needed a search engine. I went with Meilisearch.
Two stores, one problem
The moment I added Meilisearch, I had two data stores. Products lived in Firestore (my source of truth) and also in Meilisearch (for the frontend to query).
At first it was messy. Some parts of the code read from Firestore, some from Meilisearch. The logic for "where do I get this data?" was scattered everywhere.
So I did what any developer does when things get messy — I drew a line.
Drawing the line
I created two interfaces. Simple JavaScript objects with methods that throw 'Not implemented'. No TypeScript, no abstract classes. Just contracts.
The write side — ProductRepository:
const ProductRepositoryInterface = {
saveProduct: async (shopId, productData, options) => {
throw new Error('Not implemented');
},
updateStock: async (shopId, productId, stockData, options) => {
throw new Error('Not implemented');
},
updatePrice: async (shopId, productId, priceData, options) => {
throw new Error('Not implemented');
},
deleteProduct: async (shopId, productId, options) => {
throw new Error('Not implemented');
},
// 12 methods total
};
The read side — ProductReadModel:
const ProductReadModelInterface = {
listProducts: async (shopId, query) => {
throw new Error('Not implemented');
},
searchProducts: async (shopId, query, options) => {
throw new Error('Not implemented');
},
getProductStats: async (shopId) => {
throw new Error('Not implemented');
},
checkSKUExists: async (shopId, sku, excludeProductId) => {
throw new Error('Not implemented');
},
// 5 methods total
};
12 write methods. 5 read methods. Zero overlap.
That's it. That's CQRS.
Why it matters (practically)
This isn't about architecture buzzwords. It's about not going crazy when your codebase grows.
Before the split, I had this constant question: "where does this data come from?" After the split, it's obvious:
- Mutating something? →
ProductRepository→ Firestore - Querying something? →
ProductReadModel→ Meilisearch
Each store gets to be good at what it's good at:
| Write Side (Firestore) | Read Side (Meilisearch) | |
|---|---|---|
| Good at | Atomic writes, real-time | Full-text search, facets |
| Data shape | Normalized, nested | Denormalized, flat |
| Consistency | Strong | Eventual |
The write side doesn't import Meilisearch. The read side doesn't import Firestore. They don't know about each other. And that's the whole point.
The catch: eventual consistency
I won't pretend this is free. There's a cost.
When I save a product to Firestore, it doesn't appear in search results immediately. There's a projection system that picks up the change and syncs it to Meilisearch. Usually takes a few seconds.
For a management platform where admins are editing products, a few seconds is fine. For a customer-facing checkout page? Probably not. Know your context.
I'll go deeper into how the projection system works in a later post. For now, just know it exists and it's the glue between the two sides.
The thing nobody tells you
I read about CQRS months after I implemented it. I was looking at my codebase one day and thought "wait, this is that pattern from the DDD book."
I think that's actually the best way to learn architecture patterns. Not by reading about them first and trying to force them into your code. But by solving a real problem and then discovering the pattern has a name.
The best patterns don't feel like patterns. They feel like common sense.
What's next
In the next post, I'll talk about those throw new Error('Not implemented') interfaces. Why I use plain JavaScript objects as port definitions, how I validate them at startup, and why I chose this over TypeScript interfaces for my use case.
If you've ever split your reads and writes — on purpose or by accident — I'd love to hear what triggered it. Was it search? Was it performance? Something else?
Top comments (0)