Skip to main content

Why You Should Not Return EF Core Entities from a Web API

· 6 min read
Jagdish Kumawat
Founder @ Dewiride

Taking an EF Core entity as a request body, and returning one as a response, is less code. It also ties your API to your database, and hands clients control of columns you never meant to share. Here are three real failures of that approach, on a real API, and the small records that prevent them.

Prerequisites​

  • Some EF Core and Web API basics. The examples use a .NET 10 minimal API, EF Core 10, MySQL 9.7, and a Category entity with Id, Name, CreatedAtUtc and a list of Products.

Problem 1: Clients Can Set Columns You Didn't Offer​

This endpoint binds the entity directly:

A temporary endpoint
d.MapPost("/bind-entity", async (Category category, InventoryDbContext db) =>
{
db.Categories.Add(category);
await db.SaveChangesAsync();
return Results.Ok(category.Id);
});

A client sends an id of its own:

Request body
{"id":500,"name":"Chosen id","createdAtUtc":"2001-01-01T00:00:00Z"}
Response
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

500
API log
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (4ms) [Parameters=[@p0='?' (DbType = Int32), @p1='?' (DbType = DateTime), @p2='?' (Size = 100)], CommandType='Text', CommandTimeout='30']
INSERT INTO `categories` (`Id`, `CreatedAtUtc`, `Name`)
VALUES (@p0, @p1, @p2);
SELECT ROW_COUNT();

A terminal with the API log: an INSERT that includes the Id column, with the client's value of 500, and a response body of 500.

EF Core inserted the client's Id, and CreatedAtUtc too. Choosing the primary key was never meant to be possible. It also moves MySQL's counter. The next normal category got the ID after it:

Response (the next POST /api/v1/categories)
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /api/v1/categories/501

{
"id": 501,
"name": "After chosen id",
"createdAtUtc": "2026-11-04T11:43:34.324001Z"
}

This is called over-posting. Microsoft's minimal API tutorial says: "In production applications, use a subset of the model to restrict the data that clients can input and receive. Security is a major reason for this restriction." On a table with something like an IsAdmin or Price column, this is a security hole.

Problem 2: A Navigation Property Crashes the Response​

Return a category with its products included, and each product points back at its category:

Response
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500,
"traceId": "00-01fed7d34d160ef7fe429033a93d1f38-1b77050af5b1a51f-00"
}
API log (the lines that matter)
fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
An unhandled exception has occurred while executing the request.
System.Text.Json.JsonException: A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 64. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. Path: $.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Category.Products.Id.

The whole query ran, and then the JSON failed. This post goes through it.

Problem 3: Your Database Becomes Your API​

Every property on the entity becomes a JSON field that some client may rely on. Rename a column and those clients break. Add one with an internal note in it, and it's public, with no change to the endpoint. Loading an entity also reads every column, whether you send them or not:

API log (Select(p => new { p.Sku, p.Name }) compared with the whole entity)
SELECT `p`.`Sku`, `p`.`Name`
SELECT `p`.`Id`, `p`.`ArchivedAtUtc`, `p`.`CategoryId`, `p`.`CreatedAtUtc`, `p`.`Description`, `p`.`IsArchived`, `p`.`Name`, `p`.`QuantityOnHand`, `p`.`ReorderLevel`, `p`.`Sku`, `p`.`UnitPrice`, `p`.`UpdatedAtUtc`, `p`.`Version`

The Fix: Two Small Records​

One record for what the client may send, and one for what it gets back. People call these DTOs, for data transfer objects:

src/Dewiride.Inventory.Api/Features/Categories/CategoryContracts.cs
using System.ComponentModel.DataAnnotations;

namespace Dewiride.Inventory.Api.Features.Categories;

/// <summary>What a client sends to create or rename a category.</summary>
public sealed record CategoryRequest(
[Required, StringLength(100)] string Name);

/// <summary>What the API returns for a category.</summary>
public sealed record CategoryResponse(
int Id,
string Name,
DateTime CreatedAtUtc);

The handler builds the entity itself, choosing what comes from the client, and what comes from the server:

src/Dewiride.Inventory.Api/Features/Categories/CategoryEndpoints.cs (excerpt)
var category = new Category
{
Name = request.Name.Trim(),
CreatedAtUtc = clock.GetUtcNow().UtcDateTime,
};

db.Categories.Add(category);

Now the same request can't set the ID, because CategoryRequest has nowhere to put it. And the response is exactly the record's fields:

Response
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /api/v1/categories/1

{
"id": 1,
"name": "Cables",
"createdAtUtc": "2026-11-04T10:48:37.000261Z"
}

Reads get cheaper too: a Select into the record makes EF Core ask for only those columns.

Common Mistakes​

MistakeWhat happensDo this instead
One record used for both requests and responsesServer-only fields become settableSeparate them
Mapping every property of the entity acrossYou've rebuilt the entity, with the same problemsAdd only what the client needs
Adding IgnoreCycles to return entitiesIt hides one of the three problemsReturn a response record
Marking properties [JsonIgnore] on entitiesThe entity now holds API rules, and it's easy to forget oneKeep entities free of API concerns

FAQ​

Isn't this a lot of extra classes?​

For this API, it's two short records per feature. That's cheap for keeping the database and the API free to change separately.

Do I need a mapping library?​

Not at this size. A new CategoryResponse(…) is one line, and the compiler tells you when a field is missing.

Conclusion​

An entity as a request lets clients write columns you didn't offer. An entity as a response can crash on navigation properties, and turns your schema into your public API. Two small records per feature, one for requests and one for responses, fix all of that.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.