Picture this: you’re a chef in a bustling kitchen. Would you let your waiters chop vegetables while taking orders? Of course not! That’s exactly why we need Command Query Responsibility Segregation (CQRS) in our Go applications. Let’s slice through the complexity with the precision of a sushi master.
The CQRS Butcher Shop: Separating Reads from Writes
In the traditional CRUD model, our codebase often ends up looking like my college dorm fridge - everything mixed together in questionable combinations. CQRS solves this by introducing surgical separation:
type Command interface {
Validate() error
}
type Query interface {
Execute(ctx context.Context) (interface{}, error)
}
Our first architectural decision: commands change state but return nothing but errors (like telling someone they’ve put milk before cereal), while queries return data but leave state untouched (like politely asking for the time).
Building the CQRS Kitchen
Let’s set up our project structure:
/cmd
/pkg
/cqrs
command/
handler.go
bus.go
query/
handler.go
bus.go
/domain
user/
commands.go
queries.go
projections.go
Pro tip: If this looks like overkill for your pet project, remember - even rocket ships start with good blueprinting!
The Command Side: Where Changes Happen
Let’s implement user registration:
type RegisterUserCommand struct {
Email string
Password string
}
func (c *RegisterUserCommand) Validate() error {
if !strings.Contains(c.Email, "@") {
return errors.New("email must contain @")
}
return nil
}
type RegisterUserHandler struct {
repo UserRepository
}
func (h *RegisterUserHandler) Handle(ctx context.Context, cmd *RegisterUserCommand) error {
// Implementation magic here
return nil
}
Notice how our command doesn’t return the created user - that’s the query side’s job! It’s like cooking a steak without immediately plating it.
Query Side: The Art of Data Retrieval
Now let’s fetch some data:
type GetUserByIDQuery struct {
UserID uuid.UUID
}
func (q *GetUserByIDQuery) Execute(ctx context.Context) (*UserDTO, error) {
// Cache? Database? MongoDB? Yes!
return &UserDTO{
ID: q.UserID,
Email: "[email protected]",
JoinDate: time.Now().Add(-24 * time.Hour),
}, nil
}
The beauty here? Our read model can be completely different from the write model. Need to combine data from three microservices? Go nuts!
Message Bus: The CQRS Post Office
Let’s wire everything together with a message bus:
This async flow lets us scale reads and writes independently - like having separate lunch and dinner services in a restaurant.
Projections: The Real-time Data Chefs
Keeping read models updated requires projection magic:
func ProjectUserDetails(ctx context.Context, eventStream <-chan Event) {
for {
select {
case event := <-eventStream:
switch e := event.(type) {
case *UserRegisteredEvent:
updateReadModel(e.UserID, e.Email)
case *UserEmailChangedEvent:
updateReadModel(e.UserID, e.NewEmail)
}
case <-ctx.Done():
return
}
}
}
Remember: eventual consistency is like good cheese - it gets better with time!
When to Use This Pattern
CQRS shines when:
- You need different scales for reads vs writes (100:1 ratio is common)
- Complex business logic needs separation from reporting
- Multiple data representations are needed
- You want to implement event sourcing But beware! Like adding habaneros to a dish, CQRS adds complexity. Start simple, then introduce these patterns when needed.
The CQRS Zen Garden
Here’s our final architecture overview:
And remember - the true power of CQRS comes from understanding that sometimes, the best way to manage complexity is to divide and conquer. Now go forth and write (and read) responsibly!