Skip to main content
ANVISoftware Solutions
Lesson 7 of 18Intermediate18 min

DTOs and Why Not to Expose Entities

By the end of this lesson

Separate the shapes you expose from the shapes you store.

An entity is shaped for storage. It carries whatever the database and your data access layer need: foreign keys, a concurrency token, audit columns, navigation properties, occasionally a password hash.

A DTO — a data transfer object — is shaped for the contract. It carries exactly the fields a caller should send or receive, named the way you want them named on the wire, and nothing else.

They are different jobs, and the argument of this lesson is that one type cannot do both well. This is the lesson the rest of the module depends on, because validation, error shapes and versioning all assume you control the shapes you publish.

When entities become the contract, the database schema becomes the public API. That has three consequences, and each shows up on a different timescale.

The first is immediate: internal fields leak. Every public property is serialised, so PasswordHash, InternalNotes and Salary travel to whoever called the endpoint. Adding a property to the entity next month exposes that too, silently, with no change to any endpoint.

The second arrives with your first refactor. Renaming a column and its property renames a JSON field, so a change you thought was internal breaks every caller. You now have a codebase where ordinary tidying is a public event, and people stop tidying.

The third is the one that catches people out on input. If a caller can post a whole entity, a caller can set any field on it — including an id, an audit column, or a flag you never intended to accept from outside. The fix is not more checks in the handler; it is a request type that has no such property to set.

One entity, two DTOs, three different shapes
C#
// Entity — shaped for the database
public class Employee
{
    public int Id { get; set; }
    public string FullName { get; set; } = "";
    public string Email { get; set; } = "";
    public decimal Salary { get; set; }
    public string PasswordHash { get; set; } = "";
    public string? InternalNotes { get; set; }
    public int DepartmentId { get; set; }
    public Department? Department { get; set; }
    public DateTimeOffset CreatedAt { get; set; }
    public string CreatedBy { get; set; } = "";
    public byte[]? RowVersion { get; set; }
}

// Request — only what a caller is allowed to supply
public sealed record CreateEmployeeRequest(
    string FullName,
    string Email,
    int DepartmentId,
    DateOnly StartDate);

// Response — only what a caller is allowed to see
public sealed record EmployeeResponse(
    int Id,
    string FullName,
    string Email,
    string DepartmentName,
    DateOnly StartDate);
  • The entity has eleven properties. Five of them are for the database or for internal use, and none of those five belongs in a response.
  • CreateEmployeeRequest has no Id. The server assigns the identifier, so there is no property for a caller to set and no check to forget.
  • It also has no CreatedAt, CreatedBy or RowVersion. Audit fields describe what your system did, so your system fills them in. A field the caller can write is a field the caller can write untruthfully.
  • The response shows DepartmentName where the entity holds DepartmentId and a navigation property. The caller wanted a name to display; giving it one removes a second request and hides the relationship from the contract.
  • Salary, PasswordHash and InternalNotes appear in neither DTO. That absence is the security control — not a filter, not an attribute, just a type that has no place to put them.

The DTOs worth having, and the distinctions that matter:

Create request
What a caller may supply to make a new record. No id, no audit fields, no status the server controls. Required fields are genuinely required.
Update request
Often different from the create request, because some fields are set once. A create may accept a department; an update may not, if moving people has its own rules.
Detail response
One record, with the fields a caller needs when looking at it on its own.
List item response
A deliberately smaller shape for collections. Returning fifty full records to draw a table sends fields nobody reads and costs time on every row.
Mapping in the query, so the entity never leaves the data layer
C#
app.MapGet("/api/departments/{id:int}/employees", async (
    int id, AppDbContext db, CancellationToken ct) =>
{
    var employees = await db.Employees
        .Where(e => e.DepartmentId == id)
        .OrderBy(e => e.FullName)
        .Select(e => new EmployeeListItem(e.Id, e.FullName, e.Department!.Name))
        .ToListAsync(ct);

    return Results.Ok(new { items = employees, count = employees.Count });
});
  • The Select happens inside the query, so the database is asked for three columns rather than every column of every row. Mapping after loading works too, and reads the same, but transfers data you then throw away.
  • No Employee instance is ever handed to the serialiser, so no property added to the entity later can appear in this response.
  • The projection also removes the navigation-property problem. Serialising an entity with a Department that links back to its Employees can produce a cycle or a response far larger than intended.
  • The result is wrapped in an object rather than returned as a bare array, which leaves room to add paging fields later without changing the top-level shape.

Summary

  • Entities are shaped for storage; DTOs are shaped for the contract, and one type cannot do both
  • Exposing entities turns your schema into the public API, so renames break callers and new fields leak
  • A create request has no id and no audit fields, because the server owns them
  • Project into DTOs inside the query so entities never reach the serialiser
  • The separation costs extra types and mapping, and pays off wherever the boundary is real

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Split a leaking endpoint

An endpoint returns the Employee entity above directly, and its create endpoint accepts the same type.

Write the two DTOs you would introduce. For each field you drop, say whether you dropped it because it is internal, because the server owns it, or because the caller does not need it.

Show solution

The create request keeps FullName, Email, DepartmentId and StartDate. PasswordHash and InternalNotes are internal. Id, CreatedAt, CreatedBy and RowVersion are owned by the server. Salary is a business decision that almost certainly should not travel on the same endpoint as a name change.

The response keeps Id, FullName, Email, DepartmentName and StartDate. Id appears here and not in the request, which is the clearest illustration of the difference: the server produces it, the caller reads it.

Notice how the type does the work. Once the request has no Salary property, no reviewer has to remember to check for salary changes on that endpoint, and no future refactor can reintroduce the hole.

C#
public sealed record CreateEmployeeRequest(
    string FullName,
    string Email,
    int DepartmentId,
    DateOnly StartDate);

public sealed record EmployeeResponse(
    int Id,
    string FullName,
    string Email,
    string DepartmentName,
    DateOnly StartDate);

Think about it

Why the id is absent

Explain why a create request should have no id property, when the handler could check that any supplied id is ignored.

Show solution

A check can be forgotten, on this endpoint or on the next one someone writes by copying it. A property that does not exist cannot be set, so the guarantee comes from the type rather than from everyone remembering.

It also makes the contract honest. An id in the request document implies a caller may choose one. If they cannot, the property is misleading documentation.

There is a real exception: when the caller genuinely does own the identifier, for example a client-generated GUID used to make creates idempotent. Then accepting it is correct, and the endpoint should validate it and say so in the documentation.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

What is the main long-term problem with returning database entities from endpoints?
Why should a create request type omit audit fields such as CreatedAt and CreatedBy?

Saved in this browser only.