When CRUD stops describing the work
· 6 min readengineering architecture #architecture #cqrs #cqs #dotnet #csharp
Most web applications begin with a sensible loop: load data, show a form, let the user edit it, then save the changed object.
It is quick to build, easy to explain, and often enough. A user profile, a team setting, or a small internal lookup table does not need a more complicated architecture.
The problem begins when an edit means more than a changed field.
A changed DTO can hide the action
A conventional web application often works like this:
The UI asks for a DTO, displays its fields, and sends the changed DTO back when the user clicks Save. The server maps that data into its own model, validates what it can, and persists the result. Greg Young uses this DTO "up/down" interaction as the baseline for his discussion of CQRS.[1]
There is nothing automatically wrong with this shape. Its generic nature is useful. Teams know where to put a screen, an endpoint, and an update, and frameworks make the routine parts cheap.
But a generic update is not always an expressive one.
Imagine an inventory screen with an editable Status field. A user chooses Deactivated, enters a reason, and clicks Save.
The server receives an updated InventoryItemDto and has to work out what the change means:
- Is this a temporary hold or a permanent deactivation?
- Is a reason required?
- Is the current user allowed to do it?
- Can an item with outstanding orders be deactivated?
- Should someone be notified afterward?
The object shows the fields as they are now. It does not clearly state what the user asked the system to do.
That intent still has to be somewhere. It often ends up in the UI, in an update service full of conditionals, or in a workflow document that tells people which fields must change together. Young calls out this problem directly: when a client posts changed data rather than an action, the application's domain language tends to collapse into Create, Read, Update, and Delete (CRUD operations).[1]
The code then has to recover meaning from a before-and-after comparison.
Reads and writes need different shapes
The same model is often expected to answer queries too.
A write model needs rules. It may need to load enough related data to decide whether a change is allowed and keep the transaction consistent. An order cannot ship before it is paid, regardless of which screen or API sends the request.
A list page needs something else: order number, customer name, total, status, and perhaps a badge showing whether payment is overdue. It does not need a fully loaded order object or every rule used to change one.
Using one model for both jobs creates extra steps. Read code pulls domain objects into projection work. Write code collects fields because a report needs them. The model becomes a compromise between changing state correctly and showing data conveniently.
Microsoft's CQRS guidance describes this as a common reason to separate reads from writes: the representations and performance needs of an update are often different from those of a query.[3]
Start with CQS
Command–Query Separation (CQS) begins with a small API rule:
- A query returns information and does not change observable state.
- A command changes state and does not return a value.[2]
CQS does not require a new database or a new service. It can start with a clearer method name:
DeactivateInventoryItem(itemId, reason);
The point is not to replace every UpdateCustomer endpoint with a pattern name. Use the explicit form when the application needs to understand a business action; keep a simple update when it only needs to store a value.
The request is explicit. The application can validate the reason, check permissions, reject the transition, and trigger its consequences in one place.
Stack.Pop is allowed
CQS is a default, not a purity test. Stack.Pop changes a stack and returns the removed value. Splitting it into separate calls would often make ordinary code worse, so Fowler treats it as a reasonable exception.[2]
The exception gives the rule a boundary: use CQS when it makes an operation clearer, and break it when the combined operation is clearer.
CQRS makes the split structural
Command Query Responsibility Segregation (CQRS) applies the same distinction to the application's structure. The write path handles commands and business rules. The read path returns data shaped for a screen, report, or API consumer.[3]
This does not require two databases, a message broker, Event Sourcing, or microservices. The first useful CQRS boundary can be separate command and query code paths in one application using one database.[3]
A settings form that changes a display name or preferred locale may still be a simple update. CRUD is often the smallest correct solution for that work.
CQS or CQRS becomes useful when the application needs to understand an action, enforce rules around it, or serve reads that no longer resemble the data needed to process writes.