An entity used by EF Core represents application state mapped to persistence. It may also enforce domain rules. A public API contract describes what a caller may send or receive. Those shapes change for different reasons.
Use request DTOs that contain only caller-controlled fields:
using System.ComponentModel.DataAnnotations;
public sealed record UpdateCustomerRequest(
[Required, StringLength(200)]
string DisplayName);
public sealed record CustomerResponse(
Guid Id,
string DisplayName,
DateTimeOffset UpdatedAt);
app.MapPut(
"/customers/{customerId:guid}/display-name",
async (
Guid customerId,
UpdateCustomerRequest request,
CustomerDbContext db,
CurrentTenant tenant,
CancellationToken cancellationToken) =>
{
Customer? customer = await db.Customers.SingleOrDefaultAsync(
item => item.Id == customerId &&
item.TenantId == tenant.Id,
cancellationToken);
if (customer is null)
{
return Results.NotFound();
}
customer.Rename(request.DisplayName);
await db.SaveChangesAsync(cancellationToken);
return Results.Ok(new CustomerResponse(
customer.Id,
customer.DisplayName,
customer.UpdatedAt));
});
The request contract has no member into which TenantId, IsSuspended, internal timestamps, navigation properties, or internal concurrency fields can be deserialized. By default, System.Text.Json ignores unknown JSON properties, but they cannot affect the resulting UpdateCustomerRequest. Configure JsonUnmappedMemberHandling.Disallow when unknown properties should cause deserialization to fail.
In .NET 10 Minimal APIs, register builder.Services.AddValidation() so supported validation attributes such as [Required] and [StringLength] are enforced before the handler runs and validation failures return 400 Bad Request. The domain method must still enforce its own invariants.
When clients need optimistic concurrency, expose it deliberately through an API-level mechanism such as an ETag instead of leaking the persistence field.
This also protects contract stability. Renaming a database column, splitting an entity, or adding an internal property should not silently change the JSON sent to clients.
The same rule applies to responses. Returning an entity can leak internal fields today and turn tomorrow’s persistence change into a breaking API change. A response DTO also lets you shape names, nullability, and formatting for the client without changing the entity.
On write paths, load the entity you need to change. On read paths, project directly into the response DTO:
app.MapGet(
"/customers/{customerId:guid}",
async (
Guid customerId,
CustomerDbContext db,
CurrentTenant tenant,
CancellationToken cancellationToken) =>
{
CustomerResponse? response = await db.Customers
.Where(item => item.Id == customerId &&
item.TenantId == tenant.Id)
.Select(item => new CustomerResponse(
item.Id,
item.DisplayName,
item.UpdatedAt))
.SingleOrDefaultAsync(cancellationToken);
if (response is null)
{
return Results.NotFound();
}
return Results.Ok(response);
});
The projection lets EF Core generate a query that selects only the columns needed for CustomerResponse. Because the result contains no entity instance, EF Core does not track a Customer.
Do not turn this into mapping ceremony for every internal method. The boundary matters at public APIs, messages, persistence edges, and other contracts that evolve independently. A small application can map explicitly in the endpoint or application service. Larger systems may benefit from dedicated mapping code.
Validation attributes and binding controls can help, but an allow-listed request DTO makes the permitted input visible in code review. A valid DTO is not proof that the state transition is allowed.
Keep entities inside the application boundary. Publish the contract the client needs, not the object the database happens to store.