0% прочитано

DDD Read Models: Why Reads Do Not Always Need the Domain Model

The domain model primarily protects rules when state changes. A list, table, or API response often needs a different data shape. This article explains why a separate read model does not violate DDD and often makes the code simpler.

15 жовтня 2026 р. 10 хв читанняLaravel

When I started working more seriously with DDD, I had a simple concern: if a domain model exists, perhaps every read should go through it.

Otherwise it can feel like we are "bypassing DDD."

In practice, that quickly became inconvenient. An admin table might need a few fields, filters, sorting, counts, and data from several tables, while the domain model exists for a different purpose: protecting rules when state changes.

After the article about aggregates and consistency boundaries this distinction became especially clear to me: an aggregate defines a consistency boundary, but that does not mean every SELECT must reconstruct the whole aggregate.

The domain model answers "how can state change safely?" A read model answers "which data does this screen or API response need?"

Writes and Reads Answer Different Questions

One system, but different needs
Write side asks: - Is this transition allowed?- Which invariant must stay true?- Who owns this state?- What must change atomically?  Read side asks: - Which columns are needed?- How should rows be sorted?- Which filters are applied?- Which totals should be shown?

This does not mean reads and writes need to become two separate applications.

It is enough to acknowledge that they are optimized for different questions.

For state changes I want a strong domain model. For a table I want a simple and precise query that returns exactly the data the interface needs.

At First I Tried to Read Through the Same Models

A simple response now forces the domain model to know list concerns
$products = $productsRepository    ->all(); return $products->map(    fn (Product $product) => [        'id' => $product->id(),        'name' => $product->name(),        'status' => $product->status(),        'registrations' =>            $product->registrationsCount(),        'active_accesses' =>            $product->activeAccessCount(),    ],);

The code looks "properly domain-oriented," but problems appear quickly.

Rendering a list starts requiring a lot of state that no invariant needs. Extra relations, counts, N+1 queries, or complicated eager loading appear.

It gets worse when entity methods are added only because one UI table wants one more column.

For a List, I Allow a Separate Read Model

The read model describes response shape rather than aggregate behavior
final readonly class ProductListItem{    public function __construct(        public int $id,        public string $name,        public string $status,        public int $registrationsCount,        public int $activeAccessCount,    ) {    }}

This object does not need to know whether Product can be activated, which state transitions are allowed, or which invariants must be protected.

Its responsibility is much simpler: carry the data required by a particular read.

This is what I mean by a Read Model.

A Read Model Can Be Built with a Direct Query

A read query can be optimized for one specific screen
$rows = DB::table('products')    ->leftJoin(        'registrations',        'registrations.product_id',        '=',        'products.id',    )    ->select([        'products.id',        'products.name',        'products.status',    ])    ->selectRaw(        'COUNT(registrations.id) AS registrations_count'    )    ->groupBy([

This query does not reconstruct the domain aggregate and does not try to.

It simply reads a projection of the data.

If the table needs a status filter, name sorting, and registration counts, the query can be written specifically for those needs.

This Does Not Violate DDD

This was an important realization for me.

DDD does not require every SELECT to pass through an entity. It helps model the domain and protect important rules.

In the first DDD article about the point where CRUD stops being enough I started from exactly this idea: DDD becomes useful where the system must protect behavior rather than merely store data.

A Read Model does not bypass those rules when it only reads data and is not used as a shortcut for changing domain state.

The Problem Starts When the Read Model Begins Changing State

Reads and writes have different responsibilities
Safe: Read Model   |   +-- display   +-- filter   +-- sort   +-- export  Dangerous: Read Model   |   +-- update status   +-- enforce domain rule

If a DTO or query result starts getting an activate() method, the two models are being mixed.

Commands that change state should return to the domain path and the aggregate rules.

This is the important boundary for me: reads may use an optimized projection, while writes go through the model that protects invariants.

An API Response Does Not Have to Mirror the Aggregate

The API shape can be designed for the client
{  "id": 42,  "name": "Example",  "status": "active",  "registrations_count": 18,  "active_access_count": 7}

The client does not need to know the internal aggregate structure.

In fact, if an API mirrors the domain model one-to-one, the external contract becomes coupled to internal modeling decisions.

A separate read model allows the client to receive a stable and convenient data shape.

One Domain Can Have Several Read Models

Read shape depends on the use case
Same Product domain Admin list:ProductListItem Dashboard:ProductSummary API:ProductDetailsResponse Export:ProductExportRow  Same source domain.

This is another thing that used to look like duplication to me.

Product in a list, Product on a dashboard, and Product in an export answer different questions.

We do not need one universal object with every possible field merely to avoid several small read models.

For Reads I Use a Dedicated Query Service or Query Object

The query object is optimized for reads rather than aggregate changes
final class FindProductsForAdminTable{    public function handle(        ProductFilters $filters,    ): LengthAwarePaginator {        return DB::table('products')            ->when(                $filters->status,                fn ($query, $status) =>                    $query->where(                        'status',                        $status,                    ),            )            ->orderBy(                $filters->sortBy,

This class is not a domain service.

It does not make domain decisions. It simply answers a read question.

This distinction is useful to me: a command/application service coordinates state changes, while a query service returns data for reads.

Application Services and Query Services Have Different Goals

In the article about Application Service and Domain Service I wrote that an Application Service coordinates a state-changing use case: transaction, repository, external dependencies, and domain behavior.

A Query Service has a much simpler goal: retrieve the required data projection.

It does not need a state-changing transaction if it only reads.

A Read Model Often Makes the Query Simpler and Faster

I do not introduce a Read Model only for performance, but performance often becomes a useful side effect.

A list query can select only required columns, calculate aggregates in SQL, apply pagination directly, and avoid loading a full behavioral model.

This is especially visible in admin tables and analytical lists where the read shape is richer than one entity.

In AVN Center, This Difference Became Practical Very Quickly

In AVN Center, this topic appeared naturally when state-changing rules were joined by lists, status views, and data shaped for different screens.

I did not want the domain model to know how an admin table was rendered.

I also did not want a table layout to dictate the shape of the domain model.

This continues the idea from the article about domain boundaries in a growing system: boundaries become clearer when we look at responsibility rather than simple data proximity.

A Separate Read Model Does Not Require Full CQRS

This also helped me avoid unnecessary complexity.

You can have a dedicated query object and DTO for reads while still using the same database and the same application.

You do not need a separate store, asynchronous projections, or complex infrastructure immediately.

I add that complexity only when there is a real reason for it.

When I Choose a Separate Read Model

I look for several signals:

  • The screen needs only part of the aggregate.
  • The list needs data from several tables.
  • Counts, sorting, or filters are required but are not domain behavior.
  • The API has its own stable response shape.
  • Loading the domain model creates unnecessary relations or N+1 queries.
  • Domain entities start gaining methods only for one particular screen.
  • The operation is read-only and does not need to protect an invariant.

The Domain Model Protects Rules, the Read Model Answers Read Questions

My main mistake was assuming that a strong domain model must also be the only data shape used across the entire system.

Its real value is different: it protects rules and valid state changes.

A Read Model has another job. It gives a specific screen, table, export, or API exactly the data required for reading.

I am not bypassing DDD when I read directly. I bypass the domain only when I try to change its state without going through its rules.

Not every SELECT needs to reconstruct an aggregate. For reads I choose the shape that answers the query, and for state changes I return to the model that protects invariants.