📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

Domain model versus DTO: what is the difference, why should you keep them separate, and how do you map between them in a .NET application? Discuss over-posting, API versioning, EF Core entities and when sharing one type is acceptable.

Design judgement on domain models versus DTOs: responsibilities, boundaries, over-posting and data leaks, versioning, mapping options (manual, Mapster, Mapperly, projections) and pragmatic exceptions.

Senior Architecture Advanced api-design csharp design domain-model dto mapping oop security

🎯 Key points

  • Domain models hold behaviour and invariants; DTOs are flat, behaviour-free contracts at boundaries
  • Separate types prevent over-posting (mass assignment) and data leakage in responses
  • API contracts and the domain can then change and version independently
  • Inbound: map request DTOs to domain methods or factories, not property by property onto entities
  • Outbound: map or project to response DTOs; EF Core projections avoid loading entities
  • Use separate request and response types, and separate create and update requests
  • Prefer manual or source-generated mapping (Mapperly) for compile-time safety
  • Sharing one type is acceptable only for simple internal CRUD, never exposing EF entities publicly

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. The difference

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(...)

2. Why keep them separate

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.

3. Mapping between them

Options, from most explicit to most automatic:

  1. Manual mapping in extension methods or static factories — explicit, fast, refactor-safe, easy to debug:
public static OrderResponse ToResponse(this Order o) =>
    new(o.Id.Value, o.Status.ToString(), o.Total.Amount, o.Lines.Select(l => l.ToResponse()).ToList());
  1. Query projections with EF Core — map straight from the database to DTOs without loading entities:
var orders = await db.Orders.Where(o => o.CustomerId == id)
    .Select(o => new OrderSummary(o.Id, o.Total, o.CreatedAt)).ToListAsync(ct);
  1. Source-generated mappers (Mapperly) — compile-time, no reflection, AOT-friendly, with compile errors for unmapped members.
  2. Reflection-based mappers (AutoMapper) — less code, but runtime failures, hidden logic and debugging cost; AutoMapper also moved to a commercial licence model in 2025, which pushed many teams to manual mapping or Mapperly.

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.

4. When sharing one type is acceptable

  • Internal tools, prototypes, simple CRUD with no business rules, single consumer and short lifespan — the indirection may not pay off.
  • Read-only query models that are already projections (the projection type is the DTO).
  • Contracts shared between services you own, versioned as a package — but those are DTOs, not domain entities.

Even then, never bind writes directly to EF entities.

5. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Where DTOs live in a layered/clean architecture

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.

2. EF Core entities: domain model, persistence model or both?

Two common approaches:

  • Domain model = EF entities (most common in .NET): rich classes with private setters, backing fields and owned/complex types, configured via IEntityTypeConfiguration<T>. EF Core supports private constructors and fields, so a reasonably pure domain model is achievable.
  • Separate persistence models: domain objects mapped to/from EF-specific classes. Purer but more mapping; worthwhile when persistence and domain shapes diverge significantly.

Either way, EF entities are not API DTOs.

3. Over-posting in detail

Model binding sets any public settable property present in the request. Mitigations:

  • Use request DTOs with only allowed fields (best).
  • [BindNever]/[Bind("Name,Phone")] attributes (MVC) — brittle allow/deny lists.
  • JsonUnmappedMemberHandling.Disallow (.NET 8) to reject unknown JSON properties.
  • Authorisation per property (e.g. only admins may set certain fields) handled in the use case, not by binding.

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.

4. API versioning with DTOs

  • Keep DTOs per version (V1.OrderResponse, V2.OrderResponse), mapped from the same domain model.
  • Additive changes (new optional fields) are usually non-breaking; renames/removals require a new version.
  • Asp.Versioning.Http supports URL/header/media-type versioning; OpenAPI documents per version.
  • Contract tests (snapshot the OpenAPI JSON or use Pact) catch accidental breaking changes.

5. Mapping performance and correctness

  • Projections (Select) avoid loading entities and tracking — the fastest read path.
  • Manual/Mapperly mappings are compile-time and allocation-efficient.
  • Reflection mappers add start-up cost and run-time configuration validation; use AssertConfigurationIsValid in tests if you use them.
  • Unit-test mapping only where logic exists (formatting, calculations); trivial mappings are covered by integration tests.

6. Validation layers

  • DTO validation: shape and format (required fields, lengths, email format) via data annotations, FluentValidation, or .NET 10 minimal API validation.
  • Domain validation: business rules and invariants (can't ship an unpaid order) inside entities/value objects.
  • Don't duplicate business rules in DTO validators; don't put format validation for HTTP concerns in the domain.

Try it yourself

  1. Bind an EF entity directly in a minimal API and exploit over-posting with an extra JSON property.
  2. Replace it with a request DTO and a domain method call; enable JsonUnmappedMemberHandling.Disallow.
  3. Write a projection query to a response DTO and compare the SQL with Include + mapping.
  4. Generate a mapper with Mapperly and introduce an unmapped property to see the compile-time diagnostic.

💻 Code

// 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);

⚠ Common mistakes

1. Binding requests directly to entities

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.

2. Returning entities from APIs

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.

3. Mapping DTOs onto entities with a mapper

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.

4. Business rules in DTO validators

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.

5. One DTO for every purpose

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.

6. Breaking API contracts during refactors

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.

7. Hidden mapping logic

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.

8. Over-engineering simple CRUD

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says rules live in the domain model Why: Checks rich versus anemic model judgement
    How to answer

    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.

  2. When: Candidate uses records for DTOs Why: Checks record versus class choices with EF Core
    How to answer

    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.

🛠 Real-world problems: how you handle them

Over-posting raised a customer's own credit limit
Situation
A customer self-service API bound `PUT /customers/{id}` directly to the EF Core `Customer` entity.
Signals
A penetration test showed that adding `"creditLimit": 100000` to the JSON body raised the caller's limit; no alert fired.
Investigation
Reviewed all endpoints and found [11] that bound entities directly, three of which had sensitive fields.
Resolution
Introduced request DTOs with only editable fields, changed handlers to call domain methods, and returned response DTOs projected from queries. Audited and reverted any changed credit limits.
Prevention
Architecture test fails if an endpoint parameter or return type is an EF entity; integration test posts unknown fields to every endpoint and asserts they have no effect.

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.

↩ Usually asked after

More in Design Judgment

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me