What is the number that decides whether you need the CQRS pattern?
Below 10 to 1 CQRS is not needed; between 10 and 100 to 1 with at least three read shapes, separate models on the same database pay off; above 100 to 1 a separate read database may be needed. In a company of 45 people at 56 to 1, the first two steps cost 12,960 euros with a net return of about 9,456 euros a year and payback in about 16 months.

It is ten o'clock on a Tuesday and in a 45-person distribution company the phone in the order office never stops ringing. Operators enter orders, and at every submit the screen freezes for four seconds. The reason is always the same: at ten the management control report starts, and it reads the same tables the operators are writing to.
The head of IT, who has read somewhere that there is a pattern for this, asks the team: "Shouldn't we do CQRS?". The team answers with three acronyms and a quote for forty person-days.
This article tells you what the CQRS pattern is, when it pays off in a .NET business application and when it is just complexity bought at a high price. With the sums done, in euros, and with the number that decides which step to start from.
You will find the definition without jargon, the problem the pattern really solves, a typical company with its numbers, the threshold that separates "not needed" from "needed", the four steps with their costs, the C# code for the first three, the difference from event sourcing and microservices, the mistakes I see most often and the cases where the right answer is an index on the database.
An honest premise. Since 1999 I have been designing and maintaining software for companies, almost all of it in .NET. I built LegalDesk, a multi-tenant program for law firms, and I have seen CQRS used well in three systems and used badly in many more. I do not sell a framework or a course on this: I tell you when it pays off and when it does not.
The case I use throughout the article is a typical company, built from orders of magnitude and sector averages: 45 people, 14 in the office, a .NET business application on SQL Server eleven years old, a customer portal and an app for the sales agents. It is not a client, it is a method. You redo it with your own numbers in an afternoon.
What is the CQRS pattern, explained without jargon?
CQRS stands for Command Query Responsibility Segregation, that is, separation of responsibilities between commands and queries. Behind the weighty name there is a one-line idea.
The code that changes data and the code that shows it are two different things, and they have different models.
A command is a request to do something: create the order, cancel the shipment, apply the discount. It changes the state of the system and it can be refused. A query is a question: give me this customer's open orders. It changes nothing and returns a result.
In most business applications the same class does both. The Order entity has forty fields, validation rules, links to customer, lines and shipments, and you use it both to save a new order and to fill the list grid. It works as long as the application is small.
With CQRS you break this coupling. To write you use a model designed to enforce the rules. To read you use a model designed to show the data quickly, in the shape the screen needs.
The idea has a history. In 1988 Bertrand Meyer, the father of the Eiffel language, formulated the principle of separating commands from queries: a method either changes state or returns a value, not both. Around 2010 Greg Young and Udi Dahan brought the same principle up to the level of architecture, separating the models too. That is the CQRS people talk about today.
Before going on, three things CQRS is not, because the confusion costs millions of euros in wrong projects.
- It is not event sourcing. Event sourcing saves the history of events instead of the state. They are often used together, but they are two separate decisions and the second costs much more.
- It is not microservices. You can do CQRS inside a single program, with a single database. Microservices are a deployment choice, CQRS is a model choice.
- It does not require two databases. Two databases are one of the possible steps, not the starting point.
This third line is the most important in the whole article. Almost all the quotes I see start from the highest step, when for 90 per cent of companies the first two are enough.
What problem does CQRS really solve?
It is not speed. This is the most widespread misunderstanding, and it leads to projects that make the system more complicated without making it faster.
The real problem is another one: writing and reading have different shapes, and a single model keeps them chained together.
Take the order. When you write it, you need rules: the customer exists and is active, every item code is in the catalogue, quantities are multiples of the pack size, the credit limit is not exceeded. The write model is rich in logic and strict, because every badly written field is a problem in accounting.
When you read it, you need completely different things, and there are at least five.
- The list of open orders for the office: number, company name, delivery date, total, status.
- The order page on the customer portal, with shipment status and the tracking link.
- The management control report, with margin by customer and by product family.
- The export for accounting, with fields in the format of the accounting program.
- The screen for the agent on the road, who wants only their own orders and a dozen fields.
With a single model, every screen adds a field, a link, a filter to the same entity. The entity grows, the list query reaches nine tables in a join, and every change to a read risks breaking the write, because they go through the same code.
This is the real cost, and it is measured in development days, not in milliseconds: every change request costs more tests, more risk and more days than it should.
CQRS cuts this knot. The write model stays small and strict. The read models are many, flat, each one tailored to a screen, and they can be changed without touching the rules.
Speed arrives as a side effect, because a flat, well-indexed read is much faster than a chain of joins. But if your only problem is a slow query, the solution almost always costs a tenth.
What does the typical company look like and which numbers does it start from?

The typical company distributes components, has 45 people of whom 14 in the office and a business application written in .NET eleven years ago, with a single Entity Framework model and a single SQL Server database. Around the application, a customer portal and an app for the agents have grown over time.
Here are the starting numbers, the ones you measure in half a day with the database query log and the application logs.
| Measure | Value | How to measure it |
|---|---|---|
| Orders entered per month | 38,000, about 1,727 per working day | Count on the orders table |
| Reads per day | about 96,000 (office, portal, agent app, reports) | Request logs or query statistics |
| Ratio of reads to writes | about 56 to 1 | 96,000 divided by 1,727 |
| Table size | 2.4 million headers, 11 million lines | Row count |
| Open orders list, 95th percentile | 2.8 seconds, with 9 tables in a join | Monitoring tool or logs with timings |
| Order entry at 10:00 during the report | from 140 milliseconds to 4.1 seconds | Command timings during the report window |
| Different read shapes | 5 | List of screens and exports |
| Change requests to a read in the last year | 31, of which 19 also touched the write code | Ticket tracker |
| Cost of a development day | 480 euros (fully loaded) | Internal cost or supplier rate |
A few notes, because every row is a choice and you must be able to discuss it.
- The 56 to 1 ratio is the number you will use shortly. You do not need precision: you need to know whether you are at 3, at 50 or at 500.
- The 4.1 seconds at ten o'clock is the symptom that annoys the operators, but it is not the diagnosis. Here a long read blocks the writes, and the basic cure costs very little.
- The 31 change requests is the most underestimated row. If you have no tracker, count them from memory with whoever develops: an order of magnitude is enough.
- The 480 euros a day is a fully loaded cost for an internal developer or an average supplier rate. If in your company it is different, change that number and the rest adjusts.
Before reading on, redo this table with your own data. You do not need perfect measurements: you need orders of magnitude and an afternoon with whoever develops.
What is the number that decides whether you need the CQRS pattern?

The number that decides is the ratio of reads to writes, read together with how many different read shapes you have to serve. Not the number of users, not the size of the database, not how modern the team is.
The reason is simple. CQRS makes you pay now (more code, more classes, more things to keep aligned) to be able to scale and change reads separately from writes. If you read little or always read in the same way, that separation gives you nothing.
The thresholds I use for a business application:
- Under 10 to 1 and fewer than three read shapes. CQRS is not needed. The right indexes, queries written with a projection and a bit of tidying solve everything. A project would cost more than it recovers.
- Between 10 to 1 and 100 to 1, with at least three read shapes. It pays to separate commands and queries in the code and give the heaviest reads a model of their own on the same database. It is the band of almost all the small and medium companies I meet.
- Above 100 to 1, or reads that disturb writes even after the basic cures. A separate read database makes sense, updated asynchronously, accepting that data arrives with a small delay.
Our company is at 56 to 1, with five read shapes. It sits in the middle band: read models on the same database. It does not need the separate database, and it would not need it even with the portal grown by 50 per cent.
There is a rule that comes before all the thresholds, and I write it large because it saves a lot of money.
Before CQRS you do the basic cures, and only if problems remain do you move to the pattern.
The basic cures are four. An index on the columns you use in filters. Read queries written with a projection, that is, asking only for the columns needed instead of loading the whole entity. The Entity Framework no-tracking read option. And, on SQL Server, snapshot read isolation (it is called READ COMMITTED SNAPSHOT), which stops a long read from blocking whoever is writing.
In our case these four cures cost 3 days, that is 1,440 euros. The insert time at ten o'clock goes back from 4.1 seconds to about 210 milliseconds. The order list drops from 2.8 to 1.1 seconds. It is not yet a good result, but the symptom that annoyed the operators is gone.
What remains, and what the cures do not touch, are the 31 change requests a year that cost too much. And this is where CQRS starts to pay off.
What are the four steps of the CQRS pattern?
CQRS is not a switch you turn on. It is a ladder, and you climb one step at a time, stopping where the sums add up. Each step costs more than the previous one and adds a new kind of risk.
| Step | What you buy | Days | First year | Following years |
|---|---|---|---|---|
| Basic cures | Indexes, projections, no-tracking reads, snapshot isolation | 3 | 1,440 euros | 0 |
| Step 0 | Commands and queries separated in code, same model and same database | 8 | 3,840 euros | 0 |
| Step 1 | Flat read models on the same database, updated in the same transaction | 16 | 7,680 euros | about 960 euros |
| Step 2 | Separate read database, updated with the outbox technique | 32 | 15,360 euros plus 2,400 of infrastructure | about 5,472 euros |
| Step 3 | Event sourcing: the history of events instead of the state | from 60 to 110 | from 28,800 to 52,800 euros | about 20% a year |
The costs add up, because each step rests on the previous one. They are orders of magnitude for an Italian small or medium company in 2026 and not quotes: they change with the quality of the starting code and with how much is covered by tests.
I mention step 3 for completeness. I recommend it only when you have a requirement that demands it, such as a legal audit trail on every change of state or the need to rebuild the state as it was on a given date. For everyone else it is a cost that does not pay off.
In the next three sections we look at the code of steps 0, 1 and 2, because the difference between an idea and a working system lies in the details.
Step 0: how do you separate commands and queries in code?
The first step does not change the database and does not change the model. It changes where you put the code. Every write operation becomes a class of its own, with a single purpose. So does every read.
No library is needed. Two interfaces of ten lines are enough, and you can replace them with a mediation library later, if you ever need one.
public interface ICommandHandler<TCommand, TResult>
{
Task<TResult> HandleAsync(TCommand command, CancellationToken ct);
}
public interface IQueryHandler<TQuery, TResult>
{
Task<TResult> HandleAsync(TQuery query, CancellationToken ct);
}
public sealed record RigaNuova(string CodiceArticolo, decimal Quantita);
public sealed record CreaOrdine(Guid ClienteId, IReadOnlyList<RigaNuova> Righe);
public sealed class CreaOrdineHandler(GestionaleDbContext db, TimeProvider clock)
: ICommandHandler<CreaOrdine, Guid>
{
public async Task<Guid> HandleAsync(CreaOrdine cmd, CancellationToken ct)
{
if (cmd.Righe.Count == 0)
throw new DominioException("Un ordine senza righe non si crea.");
var cliente = await db.Clienti.SingleOrDefaultAsync(c => c.Id == cmd.ClienteId, ct)
?? throw new DominioException("Il cliente non esiste.");
if (!cliente.Attivo)
throw new DominioException("Il cliente non è attivo.");
var codici = cmd.Righe.Select(r => r.CodiceArticolo).Distinct().ToList();
var articoli = await db.Articoli
.Where(a => codici.Contains(a.Codice))
.ToDictionaryAsync(a => a.Codice, ct);
var ordine = Ordine.Crea(cliente, clock.GetUtcNow());
foreach (var riga in cmd.Righe)
{
if (!articoli.TryGetValue(riga.CodiceArticolo, out var articolo))
throw new DominioException($"Codice articolo sconosciuto: {riga.CodiceArticolo}.");
ordine.AggiungiRiga(articolo, riga.Quantita);
}
db.Ordini.Add(ordine);
await db.SaveChangesAsync(ct);
return ordine.Id;
}
}Note three things. The command is a simple object with only the data needed, not an entity. All the rules live in the handler and in the entity, where you find them in one place. And the command returns only the identifier: whoever needs the complete order asks for it with a query.
Now the read. It is a separate class, which does not go through the Order entity with its rules, but projects directly the columns the screen needs.
public sealed record ElencoOrdiniAperti(int Pagina, int PerPagina);
public sealed record OrdineInElenco(
Guid Id, string Numero, string Cliente,
DateOnly DataConsegna, decimal Totale, string Stato);
public sealed class ElencoOrdiniApertiHandler(GestionaleDbContext db)
: IQueryHandler<ElencoOrdiniAperti, IReadOnlyList<OrdineInElenco>>
{
public async Task<IReadOnlyList<OrdineInElenco>> HandleAsync(
ElencoOrdiniAperti q, CancellationToken ct)
{
return await db.Ordini
.AsNoTracking()
.Where(o => o.Stato != StatoOrdine.Chiuso)
.OrderBy(o => o.DataConsegna)
.Skip((q.Pagina - 1) * q.PerPagina)
.Take(q.PerPagina)
.Select(o => new OrdineInElenco(
o.Id, o.Numero, o.Cliente.RagioneSociale,
o.DataConsegna, o.Totale, o.Stato.ToString()))
.ToListAsync(ct);
}
}What have you obtained with eight days? A rule of conduct you can read in the code: whoever writes does not read to display, whoever reads does not write. When the thirty-second request for a new column in the list arrives, you touch the list class and only that. The create command notices nothing, and its tests do not have to be redone.
This step alone, with nothing else, removes half the risk of changes. For many companies it is the point where you stop, and it is an excellent choice.
Step 1: how do you give the heavy reads a model of their own?
If after step 0 you still have slow reads or reads that are too costly to keep going, the next step is to give those reads a table of their own, flat and ready to display. It stays the same database and the same transaction.
The open orders list, which today joins nine tables, becomes a read of a single table.
CREATE SCHEMA lettura;
GO
CREATE TABLE lettura.OrdiniLista (
OrdineId uniqueidentifier NOT NULL PRIMARY KEY,
Numero nvarchar(20) NOT NULL,
Cliente nvarchar(200) NOT NULL,
DataConsegna date NOT NULL,
Totale decimal(18,2) NOT NULL,
Stato tinyint NOT NULL,
Aggiornato datetime2 NOT NULL
);
CREATE INDEX IX_OrdiniLista_Stato_Consegna
ON lettura.OrdiniLista (Stato, DataConsegna)
INCLUDE (Numero, Cliente, Totale);The index includes the columns the list shows, so the query does not even have to go back to the table: the answer sits entirely in the index. On 2.4 million headers, the list goes from 1.1 seconds (after the basic cures) to about 0.35.
The delicate part is keeping this table aligned. At step 1 you do it at the same point where you write the order, in the same transaction. If the write fails, the read table fails too, and it never goes out of alignment.
// Inside CreaOrdineHandler, before SaveChangesAsync:
db.OrdiniLista.Add(new OrdineLista
{
OrdineId = ordine.Id,
Numero = ordine.Numero,
Cliente = cliente.RagioneSociale,
DataConsegna = ordine.DataConsegna,
Totale = ordine.Totale,
Stato = (byte)ordine.Stato,
Aggiornato = clock.GetUtcNow().UtcDateTime
});
await db.SaveChangesAsync(ct); // order and list row are born together, or nothing isThen the list reads from db.OrdiniLista instead of crossing nine tables, and the screen no longer knows anything about the structure of the order.
There are three prices to know.
- Every write does a little more work. In our case the insert goes from 210 to about 240 milliseconds. It is a negligible price, but it must be measured.
- Every new event that changes the list must be handled. Cancelling the order, changing the delivery date, shipping: each one must update the read table. They get forgotten, and the symptom is an order that shows the wrong status in the list.
- You need a way to rebuild everything. A procedure that empties the table and refills it from scratch starting from the real data. Without it, the day you find an inconsistency you do not know how to get out.
With 16 days (7,680 euros) plus continuous attention of about two days a year (960 euros), you have removed the list's link to the structure of the order. A new column in the list no longer touches the write.
For the typical company, this is the step where the sums add up. We see it shortly, with the numbers.
Step 2: when do you need a separate read database?

Step 2 is needed in two cases. The first: reads are so many (above 100 to 1) that they weigh on the hardware and would force you to buy a much bigger database. The second: reads need a different technology, for example a full-text search over millions of rows, which the write database does not do well.
The price is the most important thing to understand in the whole pattern: read data arrives with a delay. Whoever enters an order and immediately opens the list may not see it for a second or two. This is called eventual consistency, and it carries a question no supplier asks you: can your company tolerate it?
In an order list yes, with a notice and a refresh. In a stock availability check before confirming a sale, no. Step 2 is used only on the reads that can bear the delay.
The technique I use to update the read database without losing messages is called outbox. The idea: together with the order you write, in the same transaction, a message that says "order created". A background process reads the messages not yet processed and updates the read database.
The guarantee lies in the single transaction: either the order and the message are there, or nothing is. There is no situation where the order is saved and the message lost.
public sealed class MessaggioOutbox
{
public Guid Id { get; set; }
public string Tipo { get; set; } = "";
public string Payload { get; set; } = "";
public DateTime Creato { get; set; }
public DateTime? Elaborato { get; set; }
}
public sealed record OrdineCreato(
Guid OrdineId, string Numero, string Cliente,
DateOnly DataConsegna, decimal Totale, byte Stato);
// In CreaOrdineHandler, together with db.Ordini.Add(ordine):
db.Outbox.Add(new MessaggioOutbox
{
Id = Guid.NewGuid(),
Tipo = nameof(OrdineCreato),
Payload = JsonSerializer.Serialize(new OrdineCreato(
ordine.Id, ordine.Numero, cliente.RagioneSociale,
ordine.DataConsegna, ordine.Totale, (byte)ordine.Stato)),
Creato = clock.GetUtcNow().UtcDateTime
});
await db.SaveChangesAsync(ct); // order and message: a single transactionOn the other side, the process that updates the read. The delicate point is that it must be idempotent, that is, able to process the same message twice without doing damage. If the process is interrupted between updating the read and marking the message, on restart it processes it again, and the result must be identical.
public sealed class ProiettoreOrdini(IServiceScopeFactory scopes) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stop)
{
while (!stop.IsCancellationRequested)
{
using var scope = scopes.CreateScope();
var scrittura = scope.ServiceProvider.GetRequiredService<GestionaleDbContext>();
var lettura = scope.ServiceProvider.GetRequiredService<LetturaDbContext>();
var messaggi = await scrittura.Outbox
.Where(m => m.Elaborato == null)
.OrderBy(m => m.Creato)
.Take(100)
.ToListAsync(stop);
foreach (var m in messaggi)
{
if (m.Tipo == nameof(OrdineCreato))
{
var e = JsonSerializer.Deserialize<OrdineCreato>(m.Payload)!;
var riga = await lettura.OrdiniLista.FindAsync(new object[] { e.OrdineId }, stop);
if (riga is null)
{
riga = new OrdineLista { OrdineId = e.OrdineId };
lettura.OrdiniLista.Add(riga);
}
riga.Numero = e.Numero;
riga.Cliente = e.Cliente;
riga.DataConsegna = e.DataConsegna;
riga.Totale = e.Totale;
riga.Stato = e.Stato;
riga.Aggiornato = DateTime.UtcNow;
}
m.Elaborato = DateTime.UtcNow;
}
await lettura.SaveChangesAsync(stop);
await scrittura.SaveChangesAsync(stop);
if (messaggi.Count == 0)
await Task.Delay(TimeSpan.FromSeconds(1), stop);
}
}
}These are a hundred lines of code that look like an exercise, but they hide the real work: what do you do if the process has been stopped for an hour? How do you notice that messages are piling up? Who rebuilds the read if it gets corrupted? Which alarm rings if the delay exceeds ten seconds?
These questions cost most of the 32 days. The code you write is little, the system around it is a lot. That is why step 2 is not a natural evolution of step 1: it is another category of project, with another kind of maintenance.
How much does the CQRS pattern cost and when does it pay off?

Back to the numbers of the typical company, and to the question that matters: what is recovered?
The most solid return, the one you can defend in front of a managing director, is the cost of changes. In the last year 31 change requests to a read arrived. With the single model each one cost on average 1.6 days, between development, testing and the risk of touching the write. With separate models I estimate 0.9 days. The difference is 0.7 days per request.
The sum: 31 requests × 0.7 days = 21.7 days, that is about 10,416 euros a year. Net of the 960 euros of maintenance of the read models, the net return is 9,456 euros a year.
| Item | Amount |
|---|---|
| Basic cures (3 days) | 1,440 euros |
| Step 0 (8 days) | 3,840 euros |
| Step 1 (16 days) | 7,680 euros |
| Total cost of the first year | 12,960 euros |
| Savings on changes (21.7 days) | 10,416 euros a year |
| Maintenance of the read models | minus 960 euros a year |
| Net return | 9,456 euros a year |
| Payback | about 16 months |
The payback is 12,960 divided by 9,456, that is 1.37 years, about 16 months. It is not a miraculous investment and it does not have to be: it is work that pays for itself, with a number you can check after six months.
In the sum I did not include the time saved by the operators. The faster list gives the 14 people in the office about 35 minutes a day in total, that is about 126 hours a year, 3,500 euros. I leave it out on purpose, because it is the easiest item to inflate and the first one a managing director takes apart. If you want to keep it, it is a bonus.
And step 2? For the typical company it costs 17,760 euros the first year and 5,472 in the following years, and it brings no additional return: the write database handles the 96,000 reads a day, and changes already cost 0.9 days. It would pay off only if the customer portal grew tenfold, or if it forced you to buy a bigger database.
To make the comparison concrete: moving up a tier on the database costs about 1,900 euros a month, 22,800 euros a year. If your problem is really load, and not maintainability, that number is the ceiling of what it is worth spending on step 2. Above that figure, buying power costs less than building.
The method is worth more than my numbers. For each step: how much it costs to do, what recurring cost you remove, in how many months you break even. If you cannot answer the third question, do not start.
CQRS, event sourcing, microservices and MediatR: what is the difference?
Four words that in meetings are used as synonyms and that instead indicate four separate decisions. Lining them up avoids buying four things when you needed one.
- CQRS is a model choice: separate write and read. You can do it inside a single program with a single database.
- Event sourcing is a storage choice: you save the history of changes and rebuild the state by rereading them. It pairs well with CQRS because projections are born from events, but it is not mandatory, and CQRS works very well with a normal state.
- Microservices are a deployment choice: several independent programs talking to each other. A system with CQRS can stay a single program for its whole life, and it is often the best choice.
- MediatR and similar libraries are a tool: an intermediary that routes commands and queries to the right handler. Handy for adding cross-cutting behaviour (logging, validation, transactions), but it is not CQRS and it is not mandatory. The two interfaces you saw above do the same job in ten lines.
The reason the confusion is costly is that quotes tend to put everything together. A supplier proposes "a modern microservices architecture with CQRS and event sourcing" to replace an application that has one slow list. It is not a technical proposal: it is an extension of the scope.
When you receive a quote like that, ask one question for each word: which measured problem does this solve and what does it cost? If the answer is "it is best practice", you have found the item to remove.
If you are also evaluating how to choose among .NET web technologies, you find the comparison in the article on ASP.NET Core and the other web frameworks. If instead your application is still on an old base, the road is often a gradual migration: I discuss it in migrating WebForms, WPF and MVC applications to Blazor.
Which mistakes should you avoid when adopting the CQRS pattern?
The mistakes I see most often, and that cost the most, are eight.
- Starting with the separate database. It is the most expensive step and the one with the most risk. You reach 2 by passing through 0 and 1, and only if the numbers ask for it.
- Applying it to the whole system. CQRS is used where the single model hurts, typically in two or three areas. The supplier master data, a configuration area, a lookup table do not need it. A system with CQRS everywhere has ten times the classes it needs.
- Underestimating the delay. If you use a separate read database, sooner or later someone will read data one second old and decide accordingly. Decide in advance which screens can tolerate it and which must read from the write database.
- Forgetting to rebuild. A read model is a copy, and copies get corrupted. You need a procedure that redoes it from scratch starting from the source, tested at least once before you need it.
- Making commands return data. A command that returns the complete order puts back together exactly what you had separated. Return the identifier, or at most the outcome.
- Putting logic in the reads. If the list query decides that an order is "late" with a rule of its own, that rule exists in two places. Business rules live in the write model; the read shows them.
- Not measuring first. Without the time of a read and the cost of a change before the project, you cannot say afterwards whether it worked. Two sheets are enough: today's numbers and the numbers in six months.
- Entrusting it to someone who has never kept a system in production. The difference between an example that compiles and a system that holds lies in the questions about failure: what happens if the process stops, if a message arrives twice, if the read database is offline. Whoever has never seen them live discovers them at your expense.
There is a rule I put before all the others and I write it large: every level of separation you add you pay for forever. It costs in the first project, in maintenance, in the onboarding of every new person on the team. Before climbing a step, make sure you know what you gain in euros, not in elegance.
How do you introduce CQRS in an application that is already in production?
Nobody can stop a business application for three months and rewrite it. Luckily there is no need: CQRS is introduced one operation at a time, while the system keeps working, and every step can be undone.
The method I use has a single rule: you start from the operation that costs the most, not from the simplest one. In our case it is the open orders list, the read that generates the most change requests and the most complaints.
Here is the ninety-day plan I have seen work.
- Days 1-10: the numbers. Fill in the typical company table with your data. Count writes, reads, timings, change requests. The result is the ratio that decides where to start.
- Days 11-20: the basic cures. Indexes on filter columns, projections instead of loading whole entities, no-tracking reads, snapshot isolation. Measure again. If the symptom is gone and changes do not cost too much, stop here.
- Days 21-40: step 0 on the worst read. Take the most contested list and move it into a query class. Then take the most used command (usually order creation) and do the same. The rest of the system does not change.
- Days 41-60: the tests. Write integration tests on the command and on the query you separated, on the real database and not on a fake one. They are the safety net for everything that comes next.
- Days 61-80: step 1, only if needed. If after step 0 the list is still slow or changes still cost too much, create the read table and the procedure that rebuilds it from scratch. Test the rebuild before putting it into production.
- Days 81-90: the measurement. Redo the count of timings and days per change. Compare with the estimate. If the return is below half of what was expected, understand why before moving to another area.
Three precautions make the difference between a painless transition and a month of emergencies.
- Coexistence. For a period the old code and the new code do the same thing. A read is served by the new path for 10 per cent of users, then 50, then all. If something does not add up, you go back by changing a setting.
- Automatic comparison. In the first days you run both reads and compare the results in a log. The differences tell you where the read model lost a case (a cancelled order, a moved one) before a customer discovers it.
- One area at a time. After the first area, stop everything for four weeks and use it. The second project starts with a team that has already made the mistakes of the first, and costs less.
This approach is slow only in appearance. A "big" project that rewrites everything in six months has a high probability of arriving late, over budget and with business rules lost along the way. A project by areas delivers value from the first month and lets you choose, at every step, whether to continue.
And there is an advantage quotes do not show: throughout the journey you have a working system. Operators keep entering orders, accounting keeps invoicing, and whoever asked for the new column in the list receives it in two days instead of two weeks.
What if the number says it is not your problem?
It often happens, and it is good news. If the ratio of reads to writes is under 10 to 1 and you have fewer than three read shapes, a CQRS project does not pay off. The bottleneck, if there is one, is almost always elsewhere.
If a screen is slow, check the index first. Half the slow queries I see in business applications are slow because an index is missing on a filter column or because the query loads the whole entity with its relations instead of a projection. One hour of work, not forty days.
If writes block during reports, first of all enable snapshot isolation. It is a database setting, you try it in an afternoon and it often makes the problem disappear.
If the code is a tangle, with rules in the screens and in the queries, CQRS does not tidy it: it multiplies it. You start by bringing the rules into the write model and covering the critical operations with tests. Then, and only then, you separate.
If the team is two people and a single system, a well-done step 0 is worth more than a half-done step 2. Complexity is paid for in attention, and two people have little of it.
And there is a case in which the pattern is not the answer even with ugly numbers: when the system is wrong from the ground up, that is, it supports a process that no longer exists. There the work is not architectural, it is asking what the company really needs. A well-done modernization is worth more than any pattern.
If you have read this far, you probably have your own Tuesday at ten in mind. Before opening any quote, take your numbers: writes and reads per day, time of a slow read and of a write under load, change requests of the last year and the days they cost. Half a day of work.
If the ratio exceeds 10 to 1 and you have at least three read shapes, you already have the answer on the first step. If it exceeds 100 to 1, or if the basic cures are not enough, it is worth looking at the second. The rest is a project, not a fashionable choice.
If you want a second opinion on your case, get in touch: I look at the numbers with you and tell you which step you are on, without selling anything.
Frequently asked questions
It is the separation between the code that changes data (commands) and the code that shows it (queries), with different models: a small, strict one for writing and a flat, fast one for reading. It is not event sourcing, it is not microservices and it does not require two databases: you can do it inside a single program with a single database.
When the ratio of reads to writes exceeds about 10 to 1 and you have at least three different read shapes. Below that threshold, indexes and well-written queries are enough. Above 100 to 1, or with reads that disturb writes even after the basic cures, a separate read database may be needed.
For a small or medium company, orders of magnitude: 1,440 euros for the basic cures, 3,840 to separate commands and queries in code, 7,680 for read models on the same database, about 17,760 in the first year for a separate read database. They are costs at 480 euros a day, not quotes.
No. The separate database is one possible step, not the starting point, and event sourcing is an independent decision that costs much more. For most companies the first two steps are enough: commands and queries separated in code and read models on the same database.
Often yes, as a side effect, because a flat, indexed read is faster than nine joins. But the real reason is another one: reducing the cost of changes. If the problem is only a slow query, an index or a projection costs a tenth and should be tried first.
With a separate read database, data arrives with a small delay, usually one second. It is fine for an order list, it is not fine for checking stock availability before confirming a sale. You decide in advance which reads can tolerate it.
