Never Return Your EF Entities From an API
Your API returns a user object. The frontend team notices a PasswordHash field in the JSON. A penetration tester finds that posting back the payload overwrites the Role property. A schema change in your database breaks every consumer. The root cause is always the same: you returned your EF Core entity directly from an endpoint.
The problem
Section titled “The problem”EF Core entities are persistence concerns. They carry navigation properties, shadow properties, change-tracking baggage, and fields that exist for the database, not for your API consumers. Returning them directly causes four distinct problems:
- Data leakage — sensitive columns (
PasswordHash,DeletedAt,TenantId, internal flags) end up in the JSON response. You are one forgotten property away from a security incident. - Circular references — navigation properties (
Order.Customer.Orders.Customer...) causeJsonExceptionat runtime or infinite loops with lenient serializers. - Lazy loading traps — if lazy loading is enabled, serializing an entity triggers N+1 queries you never intended. Your “simple GET” suddenly fires 200 SQL statements.
- Schema coupling — your API contract is now your database schema. Rename a column, add a field, normalize a table, and every consumer breaks. You cannot evolve one without the other.
Bad practice: returning the entity
Section titled “Bad practice: returning the entity”app.MapGet("/api/v1/invoices/{id:guid}", async ( Guid id, AppDbContext db, CancellationToken ct) =>{ var invoice = await db.Invoices .Include(i => i.Customer) .Include(i => i.Lines) .FirstOrDefaultAsync(i => i.Id == id, ct);
return invoice is null ? Results.NotFound() : Results.Ok(invoice); // Serializes the entire entity graph});This endpoint returns everything: the Customer navigation with all its properties, every InvoiceLine with internal pricing flags, the TenantId, the DeletedAt timestamp. The OpenAPI schema mirrors your database, and any schema migration is now a breaking API change.
Good practice: response record + explicit mapping
Section titled “Good practice: response record + explicit mapping”Define a response record that represents exactly what the consumer needs. Nothing more.
public sealed record InvoiceResponse( Guid Id, string InvoiceNumber, string CustomerName, DateTimeOffset IssuedAt, DateTimeOffset DueDate, decimal TotalExcludingTax, decimal TotalIncludingTax, IReadOnlyList<InvoiceLineResponse> Lines);
public sealed record InvoiceLineResponse( string Description, int Quantity, decimal UnitPrice, decimal LineTotal);Then map explicitly in the endpoint:
app.MapGet("/api/v1/invoices/{id:guid}", async Task<Results<Ok<InvoiceResponse>, ProblemHttpResult>> ( Guid id, IInvoiceReader reader, CancellationToken ct) =>{ var invoice = await reader.FindAsync(id, ct);
if (invoice is null) { return TypedResults.Problem("Invoice not found.", statusCode: 404); }
var response = new InvoiceResponse( invoice.Id, invoice.InvoiceNumber, invoice.Customer.DisplayName, invoice.IssuedAt, invoice.DueDate, invoice.TotalExcludingTax, invoice.TotalIncludingTax, invoice.Lines.Select(l => new InvoiceLineResponse( l.Description, l.Quantity, l.UnitPrice, l.LineTotal)).ToList());
return TypedResults.Ok(response);});The entity stays inside the service layer. The API consumer sees a stable, curated contract.
Naming conventions
Section titled “Naming conventions”Granit enforces strict naming rules for endpoint DTOs to prevent OpenAPI schema collisions:
*Requestfor input bodies (CreateInvoiceRequest, notCreateInvoiceDto).*Responsefor return types (InvoiceResponse, notInvoiceDto).- Module prefix on every DTO. OpenAPI flattens namespaces into a single schema map.
TransitionRequestfrom your Workflow module collides withTransitionRequestfrom your Notifications module. UseWorkflowTransitionRequestandNotificationTransitionRequest. - Never use the
Dtosuffix. It says nothing about direction.RequestandResponsemake intent explicit. - Shared cross-cutting types like
PagedResult<T>andProblemDetailsare exempt from the module prefix rule.
Why it matters
Section titled “Why it matters”API contract stability. You can rename database columns, split tables, add internal tracking fields. None of it touches the response record. Your consumers never know.
Security. The response record is an allowlist. Only the fields you explicitly include are serialized. No PasswordHash, no TenantId, no soft-delete flags leaking out. This is not defense in depth — it is the primary defense.
OpenAPI schema clarity. Your generated OpenAPI document describes what consumers actually receive, not the internal shape of your database. Schema names are meaningful (InvoiceResponse), not accidental (Invoice — is that the entity? the command? the event?).
Testability. Response records are plain data. You can construct them in tests without a DbContext, assert on them with Shouldly, and serialize them predictably.
Further reading
Section titled “Further reading”- CQRS pattern — why Granit separates Reader and Writer interfaces
- Layered architecture — where entities and response records live