Design judgement on domain models versus DTOs: responsibilities, boundaries, over-posting and data leaks, versioning, mapping options (manual, Mapster, Mapperly, projections) and pragmatic exceptions.
A domain model represents business concepts with behaviour and invariants (an Order that can be paid, cancelled, and refuses invalid changes). A DTO (data transfer object) is a flat, behaviour-free contract for moving data across a boundary: an HTTP request or response, a message, a query result. Keep them separate so that the API contract and the domain can change independently, so clients cannot over-post fields they must not set (IsAdmin, Status), and so internal data does not leak in responses. Map with explicit code, source-generated mappers (Mapperly) or EF Core projections straight to DTOs for reads. Sharing one type is acceptable only for simple CRUD with no rules, and even then be careful at public boundaries.
A domain model represents business concepts with behaviour and invariants (an Order that knows how to add lines, apply discounts and ship). A DTO (data transfer object) is a flat, behaviour-free contract for moving data across a boundary — an HTTP request/response, a message, a file. Keeping them separate lets each evolve for its own reasons: the domain for business rules, the DTO for API consumers. I'd explain the risks of mixing them (over-posting, coupling, leaks), how to map, versioning, EF Core entities, and when one type is acceptable.
| Domain model (entity/aggregate) | DTO | |
|---|---|---|
| Purpose | Enforce business rules | Transfer data across a boundary |
| Behaviour | Methods with invariants | None |
| Shape | Rich object graph, value objects, private state | Flat, serialisation-friendly |
| Changes driven by | Business rules | API consumers / contracts |
| Mutability | Controlled via methods | Usually immutable records |
| Validation | Invariants always hold | Input validation (format, required) |
| Typical type | sealed class Order |
sealed record OrderResponse(...) |
Over-posting / mass assignment (security): binding a request directly to an entity lets clients set properties they shouldn't:
// DANGEROUS
app.MapPut("/users/{id}", async (int id, User user, AppDb db) => { db.Update(user); await db.SaveChangesAsync(); });
// Client sends { "name": "Ada", "isAdmin": true, "creditLimit": 1000000 }
A request DTO exposes only what the client may set (UpdateUserRequest(string Name, string? Phone)), closing that hole (OWASP API3:2023 — Broken Object Property Level Authorisation).
Over-exposure (privacy): returning entities leaks internal fields — password hashes, internal notes, personal data not needed by the client (Privacy Act APP 11: minimise disclosure).
Coupling and versioning: if the API contract is the entity, renaming a column or refactoring the domain breaks clients. With DTOs you can keep v1 contracts stable while the domain evolves, or add v2 DTOs side by side.
Serialisation problems: entities with navigation properties cause cycles, lazy-loading storms and huge payloads.
Different invariants: a request can be invalid (missing fields), but a domain object should never exist in an invalid state. DTOs carry raw input; the domain validates and enforces.
Options, from most explicit to most automatic:
public static OrderResponse ToResponse(this Order o) =>
new(o.Id.Value, o.Status.ToString(), o.Total.Amount, o.Lines.Select(l => l.ToResponse()).ToList());
var orders = await db.Orders.Where(o => o.CustomerId == id)
.Select(o => new OrderSummary(o.Id, o.Total, o.CreatedAt)).ToListAsync(ct);
For commands (writes), I usually don't "map" a DTO into an entity; I call domain methods with values from the DTO: order.ChangeShippingAddress(new Address(dto.Street, ...)). Mapping request DTOs onto entities with a mapper reintroduces over-posting.
Even then, never bind writes directly to EF entities.
An admin API bound PUT /customers/{id} to the Customer EF entity. A penetration test showed a client could set IsVerified=true and CreditLimit by adding fields to the JSON. Separately, GET endpoints returned Customer with Orders navigation and an internal RiskNotes field. We introduced request and response DTOs (records), changed writes to call domain methods, used projections for reads, enabled JsonUnmappedMemberHandling.Disallow on requests, and added contract tests on the OpenAPI document to catch accidental changes.
Checks architectural judgement and security awareness.
Green flags: clear role difference; over-posting and data leakage; independent versioning; inbound via domain methods versus outbound mapping; projections for reads; trade-offs of mapping libraries including source generators; pragmatic exceptions.
Red flags: binds entities in controllers; returns entities; maps DTOs onto entities with AutoMapper by default; one type for everything; no awareness of mass assignment.
API layer: Request/Response DTOs, validation, mapping, ProblemDetails
Application layer: Commands/queries (often records), use-case handlers
Domain layer: Entities, value objects, domain services, events (no DTOs)
Infrastructure: EF Core DbContext, configurations, external API DTOs (anti-corruption layer)
External API clients also have their own DTOs (e.g. Stripe's JSON shapes), translated to domain types in an anti-corruption layer so provider changes don't leak inward.
Two common approaches:
IEntityTypeConfiguration<T>. EF Core supports private constructors and fields, so a reasonably pure domain model is achievable.Either way, EF entities are not API DTOs.
Model binding sets any public settable property present in the request. Mitigations:
[BindNever]/[Bind("Name,Phone")] attributes (MVC) — brittle allow/deny lists.JsonUnmappedMemberHandling.Disallow (.NET 8) to reject unknown JSON properties.The related under-posting problem: missing fields bind as defaults (e.g. IsActive=false) and overwrite data in full updates — use required members, PATCH semantics (JSON Patch or explicit optional wrappers), and validation.
V1.OrderResponse, V2.OrderResponse), mapped from the same domain model.Asp.Versioning.Http supports URL/header/media-type versioning; OpenAPI documents per version.Select) avoid loading entities and tracking — the fastest read path.AssertConfigurationIsValid in tests if you use them.JsonUnmappedMemberHandling.Disallow.Include + mapping.// DTOs: contracts at the boundary
public sealed record UpdateCustomerRequest(string Email, string? Phone);
public sealed record CustomerResponse(Guid Id, string Name, string Email, string? Phone);
// Domain model: rules live here
public sealed class Customer
{
public Guid Id { get; private set; }
public string Name { get; private set; } = "";
public string Email { get; private set; } = "";
public string? Phone { get; private set; }
public decimal CreditLimit { get; private set; } // never set from a request
public void UpdateContactDetails(string email, string? phone)
{
if (!MailAddress.TryCreate(email, out _)) throw new DomainException("Invalid email");
Email = email.Trim().ToLowerInvariant();
Phone = phone?.Trim();
}
}
// Inbound: DTO -> domain method (no property-by-property mapping onto the entity)
app.MapPut("/customers/{id:guid}", async (Guid id, UpdateCustomerRequest req, AppDb db, CancellationToken ct) =>
{
var customer = await db.Customers.FindAsync([id], ct);
if (customer is null) return Results.NotFound();
customer.UpdateContactDetails(req.Email, req.Phone);
await db.SaveChangesAsync(ct);
return Results.Ok(customer.ToResponse());
});
// Outbound: manual mapping or Mapperly
public static class CustomerMappings
{
public static CustomerResponse ToResponse(this Customer c) => new(c.Id, c.Name, c.Email, c.Phone);
}
[Mapper]
public static partial class OrderMapper
{
public static partial OrderResponse ToResponse(Order order); // generated at compile time
}
// Reads: project straight to the DTO
var page = await db.Customers
.OrderBy(c => c.Name)
.Select(c => new CustomerResponse(c.Id, c.Name, c.Email, c.Phone))
.Take(50)
.ToListAsync(ct);
What happens: controllers accept EF entities as parameters and save them. Why it is wrong: over-posting lets clients change fields they shouldn't. Do instead: use request DTOs and call domain methods.
What happens: endpoints serialise EF entities. Why it is wrong: leaks internal/personal data, causes cycles and lazy-loading queries, couples clients to the schema. Do instead: return response DTOs or projections.
What happens: mapper.Map(dto, entity) for updates.
Why it is wrong: bypasses domain invariants and reintroduces over-posting.
Do instead: call intention-revealing domain methods.
What happens: "order can't be shipped unless paid" checked in a request validator. Why it is wrong: rules are bypassed by other entry points (jobs, messages). Do instead: enforce business rules in the domain.
What happens: the same DTO used for create, update and read responses. Why it is wrong: read-only fields become settable; responses expose input-only fields. Do instead: separate request and response DTOs per use case.
What happens: renaming domain properties changes JSON field names. Why it is wrong: clients break unexpectedly. Do instead: keep DTOs stable and add contract tests.
What happens: reflection mapper profiles contain calculations and conditional logic. Why it is wrong: hard to find, debug and test; runtime failures. Do instead: keep mapping explicit or source-generated; put logic in the domain.
What happens: multiple layers of DTOs and mappers for a trivial internal admin screen. Why it is wrong: cost without benefit. Do instead: keep it simple, but still avoid binding writes to entities.
Seniors treat DTOs as contracts and domain models as rules, and choose mapping cost deliberately.
| Context | Senior choice |
|---|---|
| Public API | Separate request/response records, versioned |
| Writes | DTO → domain method or factory, never mass assignment |
| Reads | EF projection straight to DTO |
| Mapping | Manual or Mapperly; avoid run-time surprises |
| Internal admin CRUD | Simplified, but still no entity binding on public endpoints |
| Messages between services | Separate integration event records, never domain types |
They also link this to security reviews: over-posting is an OWASP API Security risk (mass assignment / broken object property level authorisation), so DTO separation is a security control, not just tidiness.
In an anemic domain model, entities are property bags with public getters and setters, and all business rules live in service classes that read and set those properties. In a rich domain model, entities own their behaviour: state is private, changes happen through methods like order.Pay(amount) that enforce invariants, and services only orchestrate (load, call, save). Anemic models scatter and duplicate rules, let any code put objects into invalid states, and are hard to test without a database. They are acceptable for simple CRUD or reporting with few rules. Move towards a richer model incrementally: make setters private, move one rule at a time into the entity, protect collections, and use value objects, guided by where bugs actually occur.
Use a record when a type is defined by its data: two instances with the same values are the same thing, and the data should not change after creation. Typical examples are DTOs, API contracts, messages, events, query results, cache keys and value objects such as Money. Use a class when a type has identity and a life cycle: an Order with an Id stays the same order as its status changes, holds behaviour and invariants, and is tracked by EF Core. Records also bring generated ToString (beware secrets), shallow with copies and reference equality for collection members. Wrong choices show up as broken change tracking, surprising equality, or shared mutable state.
How to tell it (STAR)
**Situation:** a penetration test showed customers could raise their own credit limits by adding a field to a JSON request. **Task:** close the hole across the API, not just one endpoint. **Action:** I found [11] endpoints binding entities, replaced them with request and response DTOs, moved changes into domain methods, and added architecture and over-posting tests. **Result:** the retest passed, no endpoint exposes entities any more, and the response payloads shrank by about [40%] because we stopped serialising whole entities.
Last updated 30 Oct 2026.