CQRS is a fairly well-studied pattern. You can often hear that you either follow the CQRS or not, bearing in mind that this is something like a binary choice. In this article I would like to show that there is a range of variations of this concept, as well as how different types of CQRS may look in practice.
Type 0: no CQRS
With this type, you do not use the CQRS pattern at all. This means that your domain model uses domain classes to service both commands (commands) and queries (queries).
Consider the Customer class as an example:
public class Customer { public int Id { get; private set; } public string Name { get; private set; } public IReadOnlyList<Order> Orders { get; private set; } public void AddOrder(Order order) { } }
With CQRS type zero, you work with the CustomerRepository class, which looks like this:
')
public class CustomerRepository { public void Save(Customer customer) { } public Customer GetById(int id) { } public IReadOnlyList<Customer> Search(string name) { } }
The Search method here is a query. It is used to fetch data on customers from the database and return this data to the client (which may be a UI or a separate application that accesses your application via the API). Note that this method returns a list of domain objects.
The advantage of this approach is that there is no overhead projector in terms of the amount of code. In other words, you have a single model that you can use for commands and queries, and you don’t have to duplicate code.
The disadvantage here is that this single model is not optimized for reading operations. If you need to show a list of customers on the UI, you usually do not need to display their orders (Orders). Instead, in most cases, you will want to show only brief information, such as id, name, and number of orders.
Using domain classes to transport data leads to the fact that all sub-objects (such as Orders) of customers are loaded into memory from the database. This leads to serious overhead, because The UI only needs the number of orders, not the orders themselves.
This type of CQRS is good for applications with little (or no) performance requirements. For other types of applications, we must use the following types of CQRS.
Type 1: Separate Class Hierarchy
With this type of CQRS, your class structure is divided into read and write services. This means that you create a set of DTO classes for transporting data loaded from the database.
DTO for the Customer class might look like this:
public class CustomerDto { public int Id { get; set; } public string Name { get; set; } public int OrderCount { get; set; } }
The Search method in the repository returns a list of DTO instead of a list of domain objects:
public class CustomerRepository { public void Save(Customer customer) { } public Customer GetById(int id) { } public IReadOnlyList<CustomerDto> Search(string name) { } }
Search can use either ORM or regular ADO.NET to fetch the necessary data. This should be determined by performance requirements in each case. There is no need to roll back to ADO.NET if the performance of the method is satisfactory.
DTO adds some duplication in the sense that we now need to create two classes instead of one: once for commands in the form of a domain object and once for requests in the DTO format. At the same time, they allow us to create clean and clear data structures that clearly fall on the need for our read operations, since they contain only what is needed when displaying. And the more clearly we express our intentions in code, the better.
In my opinion, this type of CQRS is sufficient for most enterprise applications, since it gives a pretty good balance between simplicity and code performance. Also, with this approach, we have some flexibility in which tool to choose for queries. If the performance of the method is not critical, we can use the ORM and save developer time. Otherwise, we can use ADO.NET directly (or lightweight ORM such as Dapper) and write complex and optimized queries manually.
Type 2: individual models
This type of CQRS involves the use of separate models and a set of APIs to serve read and write requests.
This means that in addition to the DTO, we retrieve all the reads from our model. Repositories now contain only methods that are related to commands:
public class CustomerRepository { public void Save(Customer customer) { } public Customer GetById(int id) { } }
And the search logic is in a separate class:
public class SearchCustomerQueryHandler { public IReadOnlyList<CustomerDto> Execute(SearchCustomerQuery query) { } }
This approach adds more overhead in comparison with the previous one in the amount of code needed to process requests, but this is a good solution in case you have large loads on reading data.
In addition to the ability to write optimized requests, type 2 allows us to easily wrap the part of the API related to requests into a caching mechanism, or even move this API to a separate server or server group with a configured load server. This solution is great for applications with a big difference in read and write loads, because allows to scale read operations well.
If you need an even greater increase in performance in terms of reading operations, you need to move towards type 3.
Type 3: Separate Storage
This is the type that many consider to be the “true” CQRS. To scale read operations even more, we can use a separate storage optimized for our system requests. Often, a similar storage is NoSQL DB, for example, MongoDB, or a set of replicas from several instances:
Synchronization takes place here in the background and may take some time. Such storages are called “consistent in the long run” (eventually consistent).
A good example is indexing customer data with Elastic Search. Often we don’t want to use the full text search built into SQL Server, because it doesn't scale well. Instead, we can use non-relational data warehouses that are optimized for searching for customers.
Together with the best scalability in reading operations, this type of CQRS carries the largest overhead projector. We not only share our reading and writing model logically, i.e. we use separate classes and even builds for this, but we also share the database itself.
Conclusion
There are different gradations of the CQRS pattern that you can use in your application. There is nothing wrong with sticking with type 1 and not moving towards types 2 and 3 if type 1 meets the performance requirements of your application.
I would like to emphasize this point: CQRS is not a binary choice. There are various variations between not separating read and write operations in general (type 0) and their complete separation (type 3).
There should be a balance between the degree of segregation and the complexity that this segregation introduces. The balance should be sought in each particular case separately, often with the use of several iterations. The CQRS pattern should not be applied simply because “we can”.
English version of the article:
Types of CQRS