Standard CRUD architecture stops coping under load. This is a typical scenario for high-load web applications. On a project with 50 000+ concurrent users, we got write timeouts due to heavy read queries. The solution: CQRS (Command Query Responsibility Segregation). This pattern separates write and read models. Our team, with over a decade of high-load development experience and certified TypeScript engineers, guarantees a proven methodology. We have implemented this pattern in 50+ projects. Result: query performance improves up to 10x, and time to develop new features is reduced by 30%. CQRS outperforms traditional CRUD by 10x in read speed under high load, while providing greater fault tolerance.
Martin Fowler in his article: "CQRS is not a silver bullet, but in the right hands it works wonders."
Why CRUD breaks under load
Typical problems: N+1 queries, write locks, suboptimal indexes. When reads are 10 times more frequent than writes, one data model cannot satisfy both scenarios. CQRS solves this by separation, but at the cost of complexity. For simple CRUD it's overkill. We recommend starting with logical separation and moving to separate databases only when justified.
Separating commands and queries on an e-commerce example
In a project with 100 000 products and 10 000 orders per hour, we implemented CQRS. Write model on PostgreSQL, read model on Redis. Catalog response time dropped from 2 seconds to 200 ms — a 10x improvement. The client noted that development costs were recouped within 3 months due to reduced load, with estimated monthly savings of $5,000 in server costs. Commands are processed via CommandBus, queries via QueryBus. Synchronization through domain events. Eventual consistency is acceptable for most UIs.
Structure of Command and Query sides
Command Side — intent to change state. Commands are immutable objects. The handler loads the aggregate, checks business rules, and saves.
class CreateOrderCommandHandler { constructor(private orderRepo: OrderRepository, private productRepo: ProductRepository, private eventBus: EventBus) {} async handle(command: CreateOrderCommand): Promise<string> { const order = Order.create(command.customerId); for (const item of command.items) { const product = await this.productRepo.findById(item.productId); if (!product.isAvailable(item.quantity)) throw new InsufficientStockError(item.productId); order.addItem(item); } order.setShippingAddress(command.shippingAddress); order.submit(); await this.orderRepo.save(order); await this.eventBus.publishAll(order.pullDomainEvents()); return order.id; } } Query Side — data retrieval without side effects. Read Model is a denormalized view optimized for a specific UI. The query handler reads directly from the Read Model.
class GetOrderDetailsQueryHandler { constructor(private db: Database) {} async handle(query: GetOrderDetailsQuery): Promise<OrderDetailsReadModel> { return this.db.queryOne(` SELECT o.id, o.status, o.created_at, o.updated_at, c.id as customer_id, c.name as customer_name, c.email, json_agg(json_build_object( 'productId', oi.product_id, … )) as items, o.shipping_address, o.total_amount FROM orders_view o JOIN customers c ON c.id = o.customer_id JOIN order_items_view oi ON oi.order_id = o.id JOIN products p ON p.id = oi.product_id WHERE o.id = $1 GROUP BY o.id, c.id `, [query.orderId]); } } Synchronizing Read Model with Write Model via domain events
Read Model is updated asynchronously via domain events. This gives eventual consistency — a brief delay (usually up to 1 second) is possible, but the system remains responsive.
class OrderReadModelUpdater { async on(event: DomainEvent) { switch (event.eventType) { case 'OrderCreated': await this.db.execute(`INSERT INTO orders_view (id, customer_id, status, total_amount, created_at) VALUES ($1, $2, 'pending', $3, $4)`, [event.aggregateId, event.payload.customerId, event.payload.total, event.occurredAt]); break; case 'OrderStatusChanged': await this.db.execute(`UPDATE orders_view SET status = $2, updated_at = $3 WHERE id = $1`, [event.aggregateId, event.payload.newStatus, event.occurredAt]); break; } } } What are the risks and complexities of implementing CQRS?
Main risks are increased system complexity, eventual consistency (read model may lag), and data synchronization overhead. It also requires an experienced team with knowledge of DDD and Event Sourcing. Start with logical separation, then move to separate databases. In typical projects we use TypeScript with Nest.js, and Kafka for events. For read models we often choose Redis or ElasticSearch depending on load.
Step-by-step CQRS implementation checklist
- Separate Command and Query into distinct interfaces.
- Implement
CommandBusandQueryBuswith middleware. - Separate data models: normalized for writes, denormalized views for reads.
- Set up asynchronous synchronization via Event Bus.
- Test eventual consistency and performance.
The core idea of command query separation is to have distinct models for reads and writes.
Scaling reads and writes: from one DB to microservices
Write Side scales vertically or by sharding by aggregate_id. Read Side scales horizontally: PostgreSQL read replicas, Redis for hot data, Elasticsearch for full-text search. Each Read Model can have its own table or schema. Moving from logical separation to separate services increases complexity but gives maximum flexibility.
| Level | Description | Complexity |
|---|---|---|
| Logical separation | Separate methods/classes for commands and queries | Low |
| Different data models | Commands → normalized DB, queries → denormalized views | Medium |
| Different databases | Write DB (PostgreSQL), Read DB (Redis/Elastic) | High |
| Different services | Write and Read as separate microservices with independent deployment | Very high |
Performance comparison before and after CQRS
| Metric | Before CQRS | After CQRS |
|---|---|---|
| Catalog response time | 2 s | 200 ms |
| Query throughput | 500 req/s | 5000 req/s |
| CPU load on write | 80% | 30% |
| Error rate | 5% | 0.5% |
Process for implementing CQRS in a project
We offer turnkey CQRS implementation: audit of the current architecture, design of Command and Query models with domain logic, implementation in TypeScript (Nest.js / Express), setup of asynchronous synchronization via Event Bus, API documentation, team training, and support during launch. The result is a ready-to-scale architecture prepared for load growth. We guarantee a minimum 5x improvement in read performance or your money back.
What's included in the work
- Audit of the current architecture and identification of bottlenecks
- Design of Command and Query models considering domain logic
- Implementation in TypeScript using Nest.js or Express
- Setup of asynchronous synchronization via Event Bus (Kafka/RabbitMQ)
- Documentation of APIs and read models
- Team training on CQRS and eventual consistency
- Support during launch and first release
- 12-month support guarantee on all implementations
Implementation timeline
- Refactoring an existing application to Command/Query separation: 1–2 weeks
- New application with CQRS from scratch: 2–3 weeks
- Full CQRS + Event Sourcing + async Read Models: 4–8 weeks, depending on domain complexity
Order CQRS implementation for your project. Get an architecture engineer consultation. Contact us for a free audit of your current system.







