How I Ended Up Doing CQRS in a Node.js Monolith (Without Planning It)
Source: Dev.to
Background
This is part 1 of a series on building an event‑driven architecture in a Node.js monolith—no microservices, no Kafka, just patterns that work at scale. I’m a solo developer building a sync platform for e‑commerce stores using Node.js, Express, and Firestore.
The Search Problem
Firestore excels at writes: atomic operations, real‑time listeners, and effortless scaling. However, the frontend soon required a true search experience:
- Search products by name
- Filter by three categories
- Sort by price
- Show result counts per status
Implementing this in Firestore meant dealing with composite indexes for every combination, client‑side filtering, and queries that barely met the requirements. After two days of struggling, I accepted that Firestore alone wasn’t enough and introduced Meilisearch as a dedicated search engine.
Two Data Stores
- Firestore – source of truth for product data (writes)
- Meilisearch – optimized for frontend queries (reads)
Initially the codebase was a mess: some modules read from Firestore, others from Meilisearch, and the decision “where do I get this data?” was scattered throughout the project.
Introducing CQRS
To bring order, I defined two simple JavaScript objects that act as contracts—no TypeScript, no abstract classes, just methods that throw Not implemented.
Write Side – ProductRepository
// ProductRepositoryInterface.js
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');
},
// …8 more write methods (12 total)
};
Read Side – ProductReadModel
// ProductReadModelInterface.js
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');
},
// …1 more read method (5 total)
};
12 write methods vs. 5 read methods → Zero overlap. That’s CQRS in practice.
How It Looks in Code
- Mutating data →
ProductRepository→ Firestore - Querying data →
ProductReadModel→ Meilisearch
Write Side (Firestore)
| Aspect | Details |
|---|---|
| Good at | Atomic writes, real‑time |
| Data shape | Normalized, nested |
| Consistency | Strong |
Read Side (Meilisearch)
| Aspect | Details |
|---|---|
| Good at | Full‑text search, facets |
| Data shape | Denormalized, flat |
| Consistency | Eventual |
The write side never imports Meilisearch, and the read side never imports Firestore. They are completely decoupled—that’s the whole point.
Projection System
When a product is saved to Firestore, it doesn’t appear in search results instantly. A projection process listens for changes, transforms the data, and syncs it to Meilisearch. In practice this takes a few seconds—acceptable for an admin management UI but potentially problematic for a customer‑facing checkout page. Knowing the latency requirements of your context is essential.
Discovering CQRS
I only learned the term “CQRS” months after implementing the split. Seeing the pattern in my own code made the concept click far better than reading about it first and trying to force it into an existing codebase. The most useful patterns often feel like common sense rather than a formal methodology.
What’s Next
In the next post I’ll dive deeper into the throw‑new‑Error interfaces:
- Why I chose plain JavaScript objects over TypeScript interfaces
- How I validate the contracts at startup
- The benefits for my specific use case
If you’ve ever split reads and writes—whether intentionally or by accident—share what triggered the change. Was it search, performance, or something else?