CQRS gets recommended for every performance problem in SaaS backends. Most of the time it is the wrong answer. Here is when it actually helps, how to implement it correctly in Go, and what we have learned applying it in production SaaS systems.
CQRS gets recommended for every performance problem in SaaS backends. Most of the time it is the wrong answer. Here is when it actually helps, how to implement it correctly in Go, and what we have learned applying it in production SaaS systems for clients in Lebanon and the MENA region.
What CQRS actually means in practice
CQRS stands for Command Query Responsibility Segregation. The idea is that operations that change state (commands) and operations that read state (queries) have fundamentally different characteristics and should be handled by different code paths, and possibly different data stores.
In a typical SaaS backend without CQRS, a single model serves both purposes:
// Same Order struct used for writes and reads
type Order struct {
ID int64
TenantID int64
CustomerID int64
Status string
LineItems []LineItem
Total decimal.Decimal
CreatedAt time.Time
UpdatedAt time.Time
}
This model must be normalized enough for writes to be consistent, and denormalized enough for reads to be efficient. When the write model and read model have conflicting requirements, you start adding JOIN-heavy queries to satisfy reads, or you denormalize the write model and add complexity to maintain consistency.
CQRS separates these: the command side works with a normalized write model optimized for consistency. The query side works with a read model optimized for how data is actually presented to users.
When CQRS solves real problems
For most SaaS products, CQRS is not needed. A well-indexed PostgreSQL database handles both reads and writes at the scale most products will ever reach.
The cases where CQRS genuinely helps:
Dashboard and analytics reads that are expensive to compute from the write model. An order management system stores orders with line items, discounts, and shipping. The dashboard shows daily revenue, top customers by order volume, and pending fulfillment counts. Computing these from the normalized write schema on every page load requires aggregation queries across millions of rows. A read model that pre-computes these aggregates is dramatically faster.
Search requirements that do not map to relational queries. A product catalog that needs full-text search, faceted filtering, and relevance ranking does not perform well in PostgreSQL. The command side writes to PostgreSQL. The query side uses Elasticsearch or a PostgreSQL full-text index optimized for search.
Write-heavy workloads where read performance is degrading due to read replicas not keeping up. In high-write SaaS systems, read replica lag can make reads from the replica unreliable for queries that need recent data. A separate read model updated via events can be kept current without the constraints of replication lag.
Different authorization models for reads vs writes. The write model needs to validate business rules: an order can only be cancelled before it ships, a refund cannot exceed the original payment. The read model just needs to return data to authorized callers. Separating these avoids tangling authorization logic with query logic.
The simplest CQRS implementation that is still useful
Start with the simplest form: same database, separate code paths.
// Command handler: works with the normalized write model
type CommandHandler struct {
db *sql.DB
}
func (h *CommandHandler) PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) (int64, error) {
tx, err := h.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
// Validate business rules
if err := h.validateOrder(ctx, tx, cmd); err != nil {
return 0, err
}
// Write to normalized tables
orderID, err := h.insertOrder(ctx, tx, cmd)
if err != nil {
return 0, err
}
for _, item := range cmd.LineItems {
if err := h.insertLineItem(ctx, tx, orderID, item); err != nil {
return 0, err
}
}
return orderID, tx.Commit()
}
// Query handler: works with read-optimized views or denormalized tables
type QueryHandler struct {
db *sql.DB
}
type OrderListItem struct {
ID int64
CustomerName string
Total decimal.Decimal
Status string
ItemCount int
CreatedAt time.Time
}
func (h *QueryHandler) ListOrders(
ctx context.Context,
tenantID int64,
cursor *Cursor,
limit int,
) ([]OrderListItem, error) {
// Query from a view or denormalized table optimized for list display
return h.db.QueryContext(ctx, `
SELECT o.id, c.full_name, o.total, o.status, o.item_count, o.created_at
FROM order_list_view o
JOIN customers c ON c.id = o.customer_id
WHERE o.tenant_id = $1
AND ($2::timestamptz IS NULL OR o.created_at < $2)
ORDER BY o.created_at DESC
LIMIT $3`,
tenantID, cursor, limit)
}
The order_list_view is a PostgreSQL materialized view or a denormalized table that pre-computes item_count and joins in the customer name. The write path does not change. The read path uses a purpose-built representation.
Building the read model with PostgreSQL materialized views
For dashboard aggregates, PostgreSQL materialized views are the simplest read model that does not require a second database:
CREATE MATERIALIZED VIEW order_daily_summary AS
SELECT
tenant_id,
date_trunc('day', created_at) AS day,
COUNT(*) AS order_count,
SUM(total) AS revenue,
COUNT(DISTINCT customer_id) AS unique_customers
FROM orders
WHERE status NOT IN ('cancelled', 'refunded')
GROUP BY tenant_id, date_trunc('day', created_at);
CREATE INDEX ON order_daily_summary(tenant_id, day DESC);
Refreshing the view:
REFRESH MATERIALIZED VIEW CONCURRENTLY order_daily_summary;
CONCURRENTLY allows reads to continue during the refresh. The tradeoff is slightly stale data: if the refresh runs every five minutes, the dashboard shows data up to five minutes old. For most business dashboards, five-minute staleness is acceptable.
For Go services, run the refresh from a scheduled background job:
func (s *Scheduler) RefreshDashboardViews(ctx context.Context) error {
views := []string{
"order_daily_summary",
"customer_lifetime_value",
"inventory_status_summary",
}
for _, view := range views {
if _, err := s.db.ExecContext(ctx,
"REFRESH MATERIALIZED VIEW CONCURRENTLY "+view); err != nil {
return fmt.Errorf("refresh %s: %w", view, err)
}
}
return nil
}
Event-driven read model updates
For real-time or near-real-time read models, replace scheduled refresh with event-driven updates. When an order is placed or updated, publish an event. The read model updater consumes the event and updates the denormalized read table:
type ReadModelUpdater struct {
db *sql.DB
}
func (u *ReadModelUpdater) HandleOrderPlaced(ctx context.Context, event OrderPlacedEvent) error {
_, err := u.db.ExecContext(ctx, `
INSERT INTO order_list_cache (
order_id, tenant_id, customer_id, customer_name,
total, status, item_count, created_at
)
SELECT
o.id, o.tenant_id, o.customer_id, c.full_name,
o.total, o.status,
(SELECT COUNT(*) FROM order_items WHERE order_id = o.id),
o.created_at
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.id = $1
ON CONFLICT (order_id) DO UPDATE SET
status = EXCLUDED.status,
customer_name = EXCLUDED.customer_name`,
event.OrderID,
)
return err
}
This keeps the read cache current within seconds of writes. The ON CONFLICT DO UPDATE handles updates to existing orders (status changes, modifications) without needing separate insert and update logic.
What not to do: over-engineering CQRS
The most common mistake is implementing full event sourcing alongside CQRS. Event sourcing means the write model stores a sequence of events instead of current state. It is a powerful pattern but it adds substantial complexity: projections must be built and maintained, event replay must be handled, and the code surface area roughly doubles.
For the vast majority of SaaS products, full event sourcing is unnecessary. You can get the benefits of CQRS with a simple separate read model and PostgreSQL materialized views, without any event sourcing infrastructure.
Another common mistake is introducing CQRS before there is a demonstrated performance problem. A product with 50 tenants and moderate traffic does not need read-write segregation. The simplest architecture that meets current requirements is the right architecture until it is not.
Deployment in MENA SaaS contexts
For SaaS products serving businesses in Lebanon and the MENA region, dashboard response time is a real user experience concern. Business operators checking daily sales, inventory status, or pending orders expect sub-second dashboard loads.
A normalized schema that requires aggregating millions of rows on every dashboard load will eventually degrade to 3-5 second page loads. A materialized view or read cache that pre-computes those aggregates keeps dashboards fast regardless of data volume.
The five-minute refresh cycle for materialized views is usually the right starting point. If operators need real-time data (for example, a live kitchen display showing order queue), event-driven updates are appropriate for that specific read model while the rest of the dashboard uses scheduled refreshes.
Key lessons from production
CQRS in Go SaaS backends comes down to a few practical realities:
- Start with the simplest form: same database, separate command and query handlers.
- Use PostgreSQL materialized views for aggregates before reaching for a separate read database.
- Event-driven read model updates are appropriate when five-minute staleness is too much for a specific feature.
- Do not introduce event sourcing alongside CQRS unless the domain requires event replay.
- Apply CQRS where there is a demonstrated mismatch between write model requirements and read model requirements, not preemptively.
The value of CQRS is clarity. Command handlers are responsible for enforcing business rules and writing consistent state. Query handlers are responsible for returning data efficiently. When those responsibilities are separate, each can evolve independently.
Enjoying this article?
Enter your email and get a clean, formatted PDF of this article - free, no spam.
Not sure where to start?
Voxire designs Go SaaS backends for companies across Lebanon and the MENA region. If your backend is struggling with dashboard performance or complex read requirements, we can help you design the right architecture.
https://voxire.com/get-a-quote/



