When you're building an application that needs to handle complex business logic, scale to millions of users, or maintain separation of concerns, you'll eventually hear about CQRS — and it might sound intimidating. But here's the truth: CQRS is a deceptively simple pattern that can make your application more maintainable, scalable, and easier to reason about.
There's one crucial thing to understand upfront: CQRS does not require eventual consistency. It's a common misconception that CQRS always means event sourcing and asynchronous read model updates. In reality, CQRS is fundamentally about separation of concerns, and you can implement it with either strongly consistent or eventually consistent read models.
Let me break down both approaches.
What is CQRS?
CQRS stands for Command Query Responsibility Segregation. At its core, it's a pattern that separates read operations (queries) from write operations (commands) into different models or data structures.
In traditional architectures, we often have a single model that handles both reading and writing data:
┌─────────────────┐
│ Traditional │
│ Model │
├─────────────────┤
│ • Create │
│ • Update │
│ • Delete │
│ • Read │
│ • Filter │
│ • Search │
└─────────────────┘
CQRS separates these concerns:
┌──────────────┐ ┌──────────────┐
│ Commands │ │ Queries │
├──────────────┤ ├──────────────┤
│ • Create │ │ • Read │
│ • Update │ │ • Filter │
│ • Delete │ │ • Search │
└──────────────┘ └──────────────┘
↓ ↓
Write Model Read Model
Commands are actions that modify state — creating, updating, or deleting data.
Queries are actions that retrieve data without side effects.
The read and write models can share the same database, the same data structure, or be completely different. This flexibility is where the power of CQRS comes in.
Why Would You Use CQRS?
CQRS isn't just for massive, distributed systems. The core benefit applies at any scale: separation of concerns.
Let me paint a scenario: You're building an e-commerce platform. The write side needs to handle order processing with complex business rules, validations, and side effects. The read side needs to display product listings, reviews, and recommendations.
These two use cases have vastly different optimization strategies:
- Writing: Ensure data integrity, apply business rules, trigger side effects
- Reading: Execute queries efficiently, pre-compute results, denormalize when needed
CQRS lets you optimize each independently.
Key Benefits
Separation of Concerns: Commands and Queries have single responsibilities. Your business logic doesn't get tangled with query optimization concerns.
Flexibility: You're free to use different data structures, different databases, or even different update mechanisms for each model.
Scalability: Scale read and write independently. If you have 100 reads for every write (common in most apps), you can provision resources accordingly.
Testability: Test your business logic in isolation from query logic. Mock simple queries easily.
Domain Clarity: Your code better reflects your domain. Commands represent actions (CreateOrder, ApprovePayment), while queries represent different perspectives on your data.
Performance Options: You can choose to optimize reads with denormalization, caching, or even pre-computation—without complicating your write model.
Two Approaches to CQRS
Before we dive into code, let's understand the two main implementation patterns:
Approach 1: Strongly Consistent (Immediate Consistency)
The read model is updated synchronously as part of the same transaction as the write. Queries always see the latest data. Perfect for most business applications where consistency is crucial.
Approach 2: Eventually Consistent (Event-Driven)
The write model updates first, publishes an event, and the read model updates asynchronously. Queries might see slightly stale data briefly. Best when you need extreme scalability or event traceability.
Both are valid CQRS implementations. The difference isn't about the pattern—it's about consistency guarantees.
A Practical Example: Blog Platform
Let's build a simplified blog platform showing both approaches.
The Command Side (Writing)
// Create a blog post command
public record CreatePostCommand(
String title,
String content,
String authorId
) {}
// Command handler - business logic lives here
// This version uses STRONGLY CONSISTENT read model updates
public class CreatePostHandler implements CommandHandler<CreatePostCommand> {
private final PostRepository writeRepository;
private final PostViewRepository readRepository;
public CreatePostHandler(PostRepository writeRepository, PostViewRepository readRepository) {
this.writeRepository = writeRepository;
this.readRepository = readRepository;
}
@Override
public Post execute(CreatePostCommand command) {
// Validation
if (command.title() == null || command.title().isEmpty()) {
throw new IllegalArgumentException("Title is required");
}
// Business logic - complex rules here
Post post = new Post(
command.title(),
command.content(),
command.authorId()
);
// Persist to write model
Post savedPost = writeRepository.save(post);
// Update read model IMMEDIATELY (strong consistency)
PostView postView = new PostView(
savedPost.getId(),
savedPost.getTitle(),
savedPost.getContent(),
savedPost.getAuthor().getName(),
savedPost.getCreatedAt(),
savedPost.getAuthor().getImageUrl(),
0 // viewCount
);
readRepository.save(postView);
return savedPost;
}
}
The Query Side (Reading)
// Query to fetch posts for display
public record GetPostsQuery(
LocalDateTime since
) {
public GetPostsQuery {
if (since == null) {
since = LocalDateTime.of(1970, 1, 1, 0, 0);
}
}
}
// Query handler - optimized for reading
// Queries a dedicated read model (may be denormalized differently than write model)
public class GetPostsHandler implements QueryHandler<GetPostsQuery, List<PostView>> {
private final PostViewRepository readRepository;
public GetPostsHandler(PostViewRepository readRepository) {
this.readRepository = readRepository;
}
@Override
public List<PostView> execute(GetPostsQuery query) {
// Simple, efficient query against denormalized read model
// No complex joins, no business logic - just retrieval
return readRepository.findPublishedPosts(
createdAfter = query.since(),
limit = 20,
sortBy = "createdAt DESC"
);
}
}
Notice the asymmetry? The command handler deals with business rules and events. The query handler simply retrieves pre-processed data.
The API Layer
// Request DTO
public record CreatePostRequest(
String title,
String content
) {}
@RestController
@RequestMapping("/posts")
public class PostController {
private final CommandBus commandBus;
private final QueryBus queryBus;
private final SecurityContext securityContext;
public PostController(CommandBus commandBus, QueryBus queryBus, SecurityContext securityContext) {
this.commandBus = commandBus;
this.queryBus = queryBus;
this.securityContext = securityContext;
}
// Write endpoint
@PostMapping
public ResponseEntity<Post> createPost(@RequestBody CreatePostRequest request) {
try {
CreatePostCommand command = new CreatePostCommand(
request.title(),
request.content(),
securityContext.getCurrentUserId()
);
Post post = commandBus.execute(command);
return ResponseEntity.status(HttpStatus.CREATED).body(post);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
}
// Read endpoint
@GetMapping
public ResponseEntity<List<PostView>> getPosts(
@RequestParam(required = false) LocalDateTime since) {
GetPostsQuery query = new GetPostsQuery(since);
List<PostView> posts = queryBus.execute(query);
return ResponseEntity.ok(posts);
}
}
Strongly Consistent CQRS (Recommended for Most Apps)
The approach we showed above is the simplest: within the same command handler, we update both the write model and the read model synchronously. Both updates complete before returning to the client.
Advantages:
- ✅ Simple to implement and understand
- ✅ Instant consistency - queries always see the latest data
- ✅ Easy to debug and test
- ✅ Suitable for transactional semantics
Disadvantages:
- ❌ Write operations slightly slower (updating two models)
- ❌ Less flexibility for independent scaling
- ❌ Harder to use different persistence layers for reads/writes
This is perfect for most business applications.
Eventually Consistent CQRS (Event-Driven)
If you need extreme scalability or want a complete audit trail, you can make the read model eventually consistent using events:
// Command handler with EVENT PUBLISHING
// (instead of direct read model update)
public class CreatePostHandler implements CommandHandler<CreatePostCommand> {
private final PostRepository writeRepository;
private final EventBus eventBus;
public CreatePostHandler(PostRepository writeRepository, EventBus eventBus) {
this.writeRepository = writeRepository;
this.eventBus = eventBus;
}
@Override
public Post execute(CreatePostCommand command) {
// Validation
if (command.title() == null || command.title().isEmpty()) {
throw new IllegalArgumentException("Title is required");
}
// Business logic
Post post = new Post(
command.title(),
command.content(),
command.authorId()
);
// Persist to write model only
Post savedPost = writeRepository.save(post);
// Publish event (read model updates asynchronously)
eventBus.publish(new PostCreatedEvent(savedPost));
return savedPost;
}
}
// Event listener updates read model asynchronously
@Component
public class PostCreatedEventHandler {
private final PostViewRepository readRepository;
public PostCreatedEventHandler(PostViewRepository readRepository) {
this.readRepository = readRepository;
}
@EventListener
public void handle(PostCreatedEvent event) {
// This runs AFTER the write completes, possibly on a different thread
PostView postView = new PostView(
event.postId(),
event.title(),
event.content(),
event.authorName(),
event.createdAt(),
event.authorImageUrl(),
0 // viewCount
);
readRepository.save(postView);
}
}
// Event definition (using Java Record)
public record PostCreatedEvent(
String postId,
String title,
String content,
String authorName,
LocalDateTime createdAt,
String authorImageUrl
) {
public PostCreatedEvent(Post post) {
this(
post.getId(),
post.getTitle(),
post.getContent(),
post.getAuthor().getName(),
post.getCreatedAt(),
post.getAuthor().getImageUrl()
);
}
}
The read model is eventually consistent — it updates after the write completes, possibly with a slight delay.
Advantages:
- ✅ Write operations very fast (only write model persisted)
- ✅ Independent scaling of read and write paths
- ✅ Complete audit trail via events
- ✅ Can rebuild read model from events
- ✅ Decoupled components
Disadvantages:
- ❌ Queries might see slightly stale data (milliseconds to seconds)
- ❌ More complex operational setup
- ❌ Requires careful handling of eventual consistency edge cases
When to Use CQRS
CQRS isn't a silver bullet, but it applies to more cases than you might think.
✅ Use CQRS when:
- Your application has distinct read and write patterns
- Your domain has complex business logic
- Your queries don't match your domain model shape
- You need to support multiple query representations of the same data
- You want clearer separation between command logic and query logic
- You'd benefit from different optimization strategies for reads vs writes
You can use CQRS with either strong or eventual consistency—choose based on your needs, not because CQRS requires it.
❌ Skip CQRS when:
- You're building a simple CRUD application with single-model operations
- Your team prefers keeping everything simple and traditional
- You don't have performance requirements that would benefit from optimization
Common Challenges
Choosing the Right Consistency Model: Strongly consistent is simpler; eventually consistent is more powerful. Start with strong consistency and evolve if needed.
Complexity: CQRS adds conceptual overhead. You need to think about two models instead of one.
Debugging: Understanding data flow across commands and queries takes discipline.
Testing: You now need to test commands, queries, and their interactions in isolation.
Keeping Models in Sync: Both models need to reflect the same reality. This is easier with strong consistency (one transaction) and harder with eventual consistency (async updates).
The solution? Start simple. Use strong consistency first. You don't need event sourcing or message queues on day one. Begin with synchronous read model updates and evolve to async events only when you measure clear benefits.
Starting Your CQRS Journey
Here's a recommended implementation path (start simple, evolve as needed):
Phase 1: Strongly Consistent CQRS (Start Here)
- Separate your models: Create distinct structures for commands and queries
-
Create handlers: Build
CommandHandlerandQueryHandlerclasses - Update both models: In each command handler, update write then read model (synchronously)
- Test independently: Verify command logic separately from query logic
Phase 2: Add Performance (When Needed)
- Denormalize the read model: Shape the read model for query efficiency, not domain logic
- Add caching: Cache frequently accessed queries
- Separate databases: Use different persistence layers if it helps
Phase 3: Event-Driven (When Scaling)
- Publish events: Have handlers publish events instead of direct updates
- Async listeners: Process events asynchronously to update read model
- Accept eventual consistency: Handle the brief window when data isn't immediately consistent
// A minimal CQRS setup using Spring
// This demonstrates strongly consistent CQRS
@Component
public class CommandBus {
private final Map<Class<?>, Object> handlers = new ConcurrentHashMap<>();
public <T> void register(Class<T> commandType, CommandHandler<T> handler) {
handlers.put(commandType, handler);
}
@SuppressWarnings("unchecked")
public <T, R> R execute(T command) {
CommandHandler<T> handler = (CommandHandler<T>) handlers.get(command.getClass());
if (handler == null) {
throw new IllegalArgumentException(
"No handler found for: " + command.getClass().getSimpleName()
);
}
return (R) handler.execute(command);
}
}
@Component
public class QueryBus {
private final Map<Class<?>, Object> handlers = new ConcurrentHashMap<>();
public <T> void register(Class<T> queryType, QueryHandler<T, ?> handler) {
handlers.put(queryType, handler);
}
@SuppressWarnings("unchecked")
public <T, R> R execute(T query) {
QueryHandler<T, R> handler = (QueryHandler<T, R>) handlers.get(query.getClass());
if (handler == null) {
throw new IllegalArgumentException(
"No handler found for: " + query.getClass().getSimpleName()
);
}
return handler.execute(query);
}
}
// Handler interfaces
public interface CommandHandler<T> {
Object execute(T command);
}
public interface QueryHandler<T, R> {
R execute(T query);
}
// Configuration - using Spring to wire everything
@Configuration
public class CqrsConfiguration {
@Bean
public CommandBus commandBus(
PostRepository postRepository,
PostViewRepository postViewRepository) {
CommandBus bus = new CommandBus();
// Handler updates both write and read models synchronously
bus.register(CreatePostCommand.class,
new CreatePostHandler(postRepository, postViewRepository));
return bus;
}
@Bean
public QueryBus queryBus(PostViewRepository postViewRepository) {
QueryBus bus = new QueryBus();
// Queries only read from the read model
bus.register(GetPostsQuery.class,
new GetPostsHandler(postViewRepository));
return bus;
}
}
Conclusion
Here's the key takeaway: CQRS is about separation of concerns, not about eventual consistency.
You can use CQRS with immediate consistency and still get all the benefits:
- Clearer code that separates command logic from query logic
- Independent optimization of reads and writes
- Better testability and maintainability
- Foundation for future scaling
Many developers avoid CQRS thinking it requires complex async infrastructure. Don't. Start with strongly consistent CQRS and evolve to event-driven only when you measure real benefits.
The separation of reads and writes is fundamentally sound design. It makes your code easier to understand, test, and maintain—regardless of consistency strategy.
Key Takeaways:
- ✅ CQRS ≠ Eventual Consistency
- ✅ Start with strongly consistent CQRS
- ✅ Evolve to events and async updates only when needed
- ✅ Separate concerns first, optimize infrastructure second
Have you used CQRS in production? What consistency approach did you choose? Share your experience in the comments below!
Top comments (0)