DTO → Entity in DDD: why “not passed” ≠ “passed as default”
In DDD, a DTO comes from the Application Layer while an Entity lives in the Domain Layer. A mapping seam is needed between them: update only the fields that actually arrived, without leaking transport semantics into the domain. In practice, this is exactly where “not passed” often turns into “reset to default”.
elriseio/dto-entity-updater is one option for closing that seam. This article is not a bundle API reference: it explains the architectural problem and the solution. The full code, contracts, sentinel table, and tests are on the project page and in the repository.
Where this comes from
In DDD model work, I kept seeing the same piece of code: a command handler receives a DTO and has to move it into a Domain Entity. While the DTO describes a complete object, the mapping looks mechanical. As soon as PATCH or partial updates appear, a field value starts carrying two different meanings: a concrete value and no change.
This is not a comparison of every DTO-to-Entity mapping technique. It presents one architectural option: preserve input semantics at the Application–Domain boundary without making the Entity know about the DTO or spreading checks across handlers.
The boundary that must remain explicit
In a layered DDD architecture, the three layers have different responsibilities:
| Layer | Responsibility |
|---|---|
| Application Layer | use-case orchestration, command handlers, DTOs, and preparing input for the domain |
| Domain Layer | aggregates, Entities, value objects, domain services, and business invariants |
| Infrastructure Layer | Doctrine, persistence, messages, and integrations with the outside world |
A DTO is an Application Layer object. It knows the shape of external input and may reflect HTTP, a message, or another transport. A Domain Entity must not know about DTOs, PATCH, or which field arrived from JSON.
This is not an aesthetic preference. If an Entity starts accepting a DTO or contains a condition such as “if the DTO field is null, do not touch the setter”, the boundary between business rules and transport has already been broken. The Entity starts serving the input format instead of the domain model. Testing it independently becomes harder because Application Layer knowledge has entered the domain.
The consequence is concrete: the Application Layer must pass the domain a normal set of Entity-change intentions rather than a DTO, while preserving which fields were omitted.
Where partial updates break
Consider a PATCH request for a user profile. The request contains name, email, and phone, but the user changes only phone. The expected behaviour is simple: write the new phone and leave the other two fields untouched.
Naive solutions quickly create ambiguity:
- Check every field with
isset. It works, but turns every update call into repeated boilerplate and fits poorly with typed readonly DTOs. - Use
nullas the absence marker. This is impossible for ordinaryint,string,float, andarrayfields whennullis not part of the type. Also,nullcan be a real value that the use-case needs to write. - Use an ordinary default as the sentinel.
0forintor an empty string forstringmay be valid values. Once a default matches an intentional input, the code can no longer distinguish “do not change” from “write exactly this”.
The problem is not a particular syntax for the check. It is lost semantics. The DTO says “the value was not passed”, while the mapper sees only a value of type int or string and has to guess what the caller meant. The result is a call that is type-correct but wrong for the use-case.
The solution: separate semantics for “not passed”
A special marker is needed to mean no change rather than a concrete value. Such a marker is commonly called a sentinel: a predefined value that is distinguishable from valid domain values and has a separate meaning at the mapping seam.
The DTO declares the sentinel as the field default. The Application Layer passes values onward through one consistent path instead of adding a branch for every property. A component at the seam compares the input with the sentinel and decides:
- sentinel means “leave the field as it is”;
- an ordinary value means “call the corresponding Entity setter”.
The important point is where the decision lives: at the Application–Domain boundary. The Entity receives normal domain values and does not know that a field was omitted outside. The semantics of a partial update therefore do not spread through Entities, setters, and domain services.
This is not the only way to represent absence. Its advantage here is that it works with typed DTOs and separates no change from a valid null, 0, empty string, or another default value.
One seam, two strictness policies
The same mapping logic is useful for different input sources, but they should not share the same error policy.
- For a DTO coming from HTTP, JSON, or an external message bus, an unknown field may be a normal consequence of an extended external schema. This path can reasonably be lenient: skip the unknown field and keep a diagnostic log.
- For a typed call from a command handler or service layer, an unknown accessor usually means a programming error. This path should be strict: fail immediately and expose the problem next to the call site.
This matters more than the name of a particular class. The same sentinel pattern receives two policies depending on where the input came from: external transport should not break a handler because of an extra field, while an error in owned typed code should not disappear silently.
dto-entity-updater implements this model with two executors. The bundle does not change the Entity or manage its lifecycle; it formalises the Application–Domain seam. The implementer choice, signatures, and error handling are documented on the project page.
How the solution fits the architecture
The responsibility flow looks like this:
HTTP / CLI / Messenger
│
▼
── Application Layer ────────────────────────
Command Handler
│
▼
DTO (input from transport)
│
▼
Entity updater ◀── mapping seam
│
▼
Domain Entity (aggregate root, value objects)
── Domain Layer ─────────────────────────────
│
▼
── Infrastructure Layer ────────────────────
Repository / Doctrine ORM / DB
The diagram does not describe the bundle’s internal pipeline. It describes architectural responsibility: the DTO ends at the Application–Domain boundary, while the Entity remains part of the domain and accepts only domain values.
When this layer is justified
A separate updater is not needed for every CRUD project. If a form fully replaces an Entity and every input has the same field semantics, a simple explicit mapper may be clearer.
A specialised layer is justified when:
- the project regularly accepts partial updates;
- DTOs are typed and cannot use
nullas a universal absence marker; - the same mapping is repeated in several command handlers;
- the Entity must remain unaware of the external input format;
- external and internal sources need different unknown-field policies.
Without these conditions, a sentinel and a separate updater can become ceremony. The problem must be real: the layer is needed not for another abstraction, but to preserve the meaning of a partial update.
Where to see the implementation
The project page contains installation, API, the complete sentinel set, both executors, examples, tests, and licensing. In dummy-market-agent, a reference Symfony service can be started with Docker to inspect how Application Layer, CQRS, API Platform, and persistence are assembled across real layer boundaries.
Conclusion
DTOs and Domain Entities belong to different layers. If the mapping loses the difference between “not passed” and “passed as default”, the system starts changing data that the use-case never asked it to change. The fix is to preserve that semantics at the Application–Domain boundary without bringing DTO knowledge into the domain.
dto-entity-updater is one implementation of that seam: the sentinel separates no change from an ordinary value, while lenient and strict policies distinguish external input from typed input. It is not a universal mapper for every architecture, but a specialised solution for projects where partial updates and a clean Domain Entity actually matter.
Useful links:
- DTO Entity Updater: implementation, API, and tests
- dummy-market-agent — reference Symfony service with Application Layer, CQRS, API Platform, and persistence
- Domain-Driven Design, Eric Evans — layered architecture and the Application / Domain / Infrastructure split
- Implementing Domain-Driven Design, Vaughn Vernon — CQRS separation and the invariant that a Domain Entity does not know about a DTO
- Symfony Serializer — Symfony’s official mapping tool
- PSR-3 Logger Interface — logging contract for lenient mode