When to use CQRS: split reads and writes only when needed
When to use CQRS depends on real pressure in your app: different read shapes, strict write rules, or scaling needs. Learn where it pays off.

The problem CQRS solves
A single data model often starts as the sensible choice. One set of tables, one API shape, one mental model. For a small app, that works well.
The trouble starts when the same model has to do two very different jobs. A billing change needs strict rules, permission checks, and clear state changes. An account history page or revenue dashboard needs joined data, totals, labels, and fast filtering.
Those jobs pull in opposite directions. Writes need control. They must reject invalid actions, record what changed, and keep the system consistent. Reads need speed and shape. People want pages that load quickly and return exactly the fields they need, even when that data comes from several places.
When one model tries to do both, the code gets awkward. Query logic leaks into business logic. Business rules bend around reporting needs. Developers add extra joins, cached fields, and one-off exceptions because one screen needs data in a shape the write flow never wanted.
A subscription app shows this clearly. Changing a plan may require checks for payment status, seat limits, trial rules, and who can approve the change. Showing the customer overview page is a different job. That page may need the current plan, invoice status, last payment date, active seats, and usage trends in one response. You can force one model to handle both, but it usually becomes harder to read and harder to trust.
That is why CQRS exists. It is not about splitting reads and writes because the diagram looks clean. It is about admitting that a separate read model and write model can help when query shape and write rules no longer fit comfortably in the same design.
If your team keeps arguing over whether the model should serve screens or state changes, the model is already giving you the answer. The problem is not abstract complexity. The problem is one structure trying to do two jobs with different rules.
What actually splits, and what does not
The idea behind CQRS is simple. Commands change state. Queries return data.
A command handles an action such as "create account," "change plan," or "cancel subscription." It checks rules, updates data, and records what happened. If the user is not allowed to do it, or the plan change breaks a billing rule, the command rejects it. That logic belongs on the write side.
A query does something else. It fetches data for a screen, an API response, or a report. It can join tables, reshape fields, and return exactly what the page needs. The query side cares about fast reads and clear output. It does not need to enforce write rules all over again.
That does not mean you must split everything at once. Many teams start by splitting code, not storage. They keep one database and one set of tables, but stop using the same service methods for both reads and writes. That alone can clean up a messy app.
Take a subscription change command. It may touch plans, invoices, limits, and audit logs. The account overview query may pull a denormalized view with the current plan, next renewal date, usage, and payment status. Same database, different paths, because the jobs are different.
CQRS also does not require event sourcing. You can store ordinary rows in ordinary tables and still use a command model for writes and a query model for reads. Event sourcing is a separate choice. Some teams combine them. Many do not.
Most apps should not use CQRS everywhere. A small settings page often works fine with plain CRUD. A billing flow, approval process, or admin dashboard may need a split because the write rules are strict and the read shape is awkward.
A practical setup is usually boring on purpose: separate handlers for commands and queries, one shared database at first, and a split only in the parts of the app that actually need it. If pressure grows later, you can add separate read stores, async updates, or more specialized models where they pay off.
Signs the split is worth it
CQRS starts to make sense when reads and writes want very different shapes. A common case is a product with simple write actions but heavy read screens: dashboards, reports, search results, account summaries, and usage totals. If those screens need joins, counts, filters, and precomputed numbers that the write side never needs, one shared model often turns into a pile of workarounds.
You can usually see that problem in the code before anyone names it. Mapping code grows. Special query objects multiply. Handlers pick up odd exceptions just to satisfy one page. The domain model starts carrying read concerns it does not care about. After a while, even small changes feel clumsy because every new query drags the write model into a new shape.
Strict write behavior is another strong signal. Commands such as "cancel subscription," "apply credit," or "change plan" often need clear rules and a reliable audit trail. Those rules should live in one place. That is where CQRS helps. The write side can focus on validation, state changes, and business rules, while the read side focuses on answering questions quickly.
Traffic patterns matter too. If your system gets far more reads than writes, splitting them can be practical. A customer portal may show account status and billing history thousands of times a day, while plan changes happen only a few times an hour. In that case, a read model tuned for speed can take pressure off the part of the system that protects data integrity.
The same signals tend to appear together. Read screens ask for summaries that do not belong in the write model. Write actions need strict checks, and you do not want those rules scattered across query code. Read traffic keeps growing while writes stay modest. New reporting requests make a once-clean model harder to understand.
There is also a product speed signal. When each new query makes a stable part of the app harder to change, the shared model is doing too many jobs. That is often the moment to consider CQRS. Not because the pattern sounds advanced, but because one model has stopped being simple.
When CRUD is the better choice
Plain CRUD is often the right answer when one data model handles both editing and display without awkward workarounds. If the same record can fill a form, power a list, and support a few reports, a split buys you very little.
That is common in early products and small internal tools. A customer table, subscription table, or orders table can usually cover create, update, detail views, and admin lists without drama.
The same goes for queries. If your screens need a few filters, one or two sorts, pagination, and maybe a join or two, keep it simple. A normal database with decent indexes handles that well.
The split between a read model and a write model starts to make sense only when reads and writes want very different shapes. If they do not, CQRS adds ceremony without solving a real problem.
Keep debugging simple
Small teams pay a real price for extra moving parts. When one developer can trace a bug from request to database in ten minutes, that speed matters more than elegant architecture diagrams.
CQRS adds handlers, projections, sync logic, retries, and more tests. None of that is free. You write more code, onboarding takes longer, and you spend more time chasing stale data and edge cases.
CRUD is usually enough when one model supports forms and lists cleanly, queries stay predictable, one database handles current traffic without strain, and eventual consistency would confuse users. A small SaaS back office is a good example. If support staff edit plans, search subscribers, sort by renewal date, and check payment status, one schema often does the job.
Extra parts should earn their keep. If queues, projections, and duplicate models save no time and solve no pain, they are just more things to maintain.
If you are unsure, start with CRUD unless the current shape clearly fights you. Split reads and writes later, and only in the places where query shape and write rules truly diverge.
A step-by-step way to decide
Most teams should not split reads and writes across the whole app. Start with the parts people use every day, then check whether the read side and write side are pulling in different directions.
A simple review works well.
- List the screens people open most often, then list the actions that change data. A dashboard, search page, billing form, and admin screen often put very different demands on the same tables.
- Write down the exact data each screen needs. Include totals, badges, filters, counts, sort order, and related records shown together. "Customer page" is too vague. "Customer page with current plan, last invoice, failed payment flag, and usage this month" is useful.
- Mark the rules each write must protect. Maybe an account can have only one active plan. Maybe a refund must always create an audit record. Maybe a user cannot cancel a plan that already expired.
- Look for places where one model creates awkward code. Repeated joins, growing mapping code, slow queries, and handlers full of screen-specific exceptions usually mean the model is doing two jobs.
- Split one painful flow first. Leave everything else alone until it hurts for a clear reason.
This keeps the discussion grounded in product work. You are not asking whether the entire system deserves CQRS. You are asking whether one part of the system needs a different read shape than the write logic can comfortably provide.
A support dashboard is a common example. The screen may need account status, recent invoices, open tickets, and risk flags in one fast query. The write side has a different job. It must enforce billing rules, protect state changes, and keep an audit trail clean. When those needs drift apart, a separate read model can make the code smaller, not bigger.
That is often the right moment to split: one slice hurts, and the pain comes from different query shape and write rules. If the split makes that flow easier to read, test, and change, keep it. If it adds layers without removing pain, back it out and keep CRUD.
Example: a subscription support page
A support agent opens an account page in a subscription app. They need answers quickly, not a tour of the database. One screen should show who the customer is, what they paid, how much they used, and whether they already have an open issue.
That page usually pulls data from several places at once: profile details from the customer record, recent invoices from billing, usage totals from metering, and open tickets from the support system.
This is mostly a read problem. The agent is not changing all that data. They just need one page that loads fast and makes sense.
A read model works well here because it shapes data for the screen people actually use. Instead of joining several sources every time the page opens, the app can keep a view that already matches the page layout. The result is simpler screen code and faster lookups for the support team.
Now look at the actions on that same page. The agent can change a plan or issue a refund. Those actions are very different from reading data. A refund needs rules. The app may need to check that the invoice was paid, the refund was not already sent, the amount is allowed, and the agent has permission. It should also store a clear audit record with the reason and time.
That write path should stay strict. It should not depend on a page view built for convenience. A command like IssueRefund needs its own checks and its own flow, even if the account page shows the context around it.
This is a good example of CQRS solving a real mismatch. The read side helps the support page answer many questions in one place. The write side protects money-related actions and keeps the rules tight. You could build the same feature with plain CRUD, but the account page would get messy, or the refund logic would end up too loose.
Mistakes that make CQRS painful
CQRS gets messy when teams split things too early and too broadly. They take a normal app, turn every endpoint into a separate read path and write path, and end up with twice the code before solving a single real problem.
That usually starts with good intentions. A team wants cleaner models, faster screens, or room to grow. Then they split simple CRUD flows that already worked. The result is extra handlers, extra tests, extra deployment steps, and more places for bugs to hide.
A common mistake is adding queues, buses, and background workers before the app needs them. If one database transaction can handle the job, use it. A message bus makes sense when reads and writes can drift a little, when workloads differ a lot, or when one action triggers several downstream updates. Without that pressure, it is just more moving parts.
Another trap is copying business rules into the read side. The write side should decide what is allowed: who can cancel, when a plan changes, whether a refund applies. The read side should shape data for screens and reports. Once both sides start enforcing rules, they drift apart. Then users see one thing on a dashboard and hit a different rule when they click save.
Read models create their own failure mode when nobody knows how to rebuild them. A projection that works only if it never misses an event is fragile. Teams need a clear way to replay data, backfill a new field, and recover after a bug. If rebuilding takes days or depends on tribal knowledge, the design is brittle.
The worst mistake is cultural. Some teams treat CQRS like proof of architectural maturity. They copy a conference diagram into a small app, split first, ask why later, and keep the complexity long after the original need is gone.
A small subscription app is a good example. If billing rules are strict but the customer dashboard only needs fast summaries, a split may help there. That does not mean support tools, admin pages, and internal forms also need CQRS. Good teams keep the split narrow. They solve one hard part and leave the rest alone.
Questions to ask before you commit
If you are still unsure, ask a few blunt questions before you add a second model, a sync path, and more moving parts. A split can clean things up. It can also give a small team twice as much to explain and maintain.
Start with the screens people actually use. If two or three important pages need the same data in wildly different shapes, a separate read model may help. If most pages show the same fields with a few joins and filters, plain CRUD is usually enough.
Then look at the write side. CQRS earns its keep when write rules keep leaking into controllers, services, and query code. If creating or changing one record needs approvals, checks, side effects, and careful state changes, a write model can make that logic easier to hold in one place.
Performance matters too, but be honest about the cause. Slow pages do not automatically mean you need CQRS. Many teams split reads and writes when the real fix was a better index, a simpler query, or cached results. Use CQRS when the screen shape itself is the problem, not when the database just needs ordinary tuning.
A short checklist helps:
- Do your busiest screens ask for data that looks nothing like the tables you write to?
- Do write rules feel scattered across several handlers and helper methods?
- Do slow reads stay slow even after you fix indexes and obvious query issues?
- Can your team explain both models clearly without mixing them up a month later?
- If the split fails, can you remove it without rewriting half the app?
That last question matters more than most teams admit. Keep the change local at first. Split one workflow, not the whole product. If the new read model makes one page faster and the write flow easier to reason about, expand from there. If it mostly adds sync bugs and team confusion, remove it early.
The best CQRS decision is usually a narrow one. Use it where read shape and write rules truly diverge, and leave the rest of the app alone.
What to do next
Do not split your whole app at once. Pick one place where reads and writes already fight each other, then change only that part. One read model for a slow dashboard, or one command flow with stricter write rules, is enough to learn from.
Keep the test small and boring. If the change works, you will feel it in day-to-day work, not in architecture diagrams. Teams usually notice fewer awkward workarounds, clearer code, and less time spent patching queries that never fit the write model well.
A simple approach works best. Choose one screen or workflow that causes repeated friction. Split either the read side or the write side first, not both. Measure query time, code churn, and bug rate before and after. Then write down what got easier and what got harder.
Those notes matter. CQRS can look smart on paper and still cost too much in real code. Keep a short log for each change: what problem you tried to fix, what extra code you added, who owns it, and whether the split still feels justified a few weeks later.
That is the most practical answer to the question. Use CQRS where the split removes real friction. Stop where it starts creating ceremony, duplicate logic, or more moving parts than the feature deserves.
If architecture debates keep slowing product work, an outside review can help. Oleg Sotnikov at oleg.is works with startups and small businesses as a Fractional CTO, helping teams sort out product architecture, infrastructure, and practical AI adoption without turning every design choice into a major rewrite.
The next good move is usually modest: one careful change, one set of numbers, and one honest review of whether it paid off.