In the whirlwind of new technologies whizzing by us continuously, it is sobering to remember that most of a developer’s role is to get data from one location to another. In the case of HTTP APIs, the response we return represents the data stored in our database de jour. For .NET developers, working with ASP.NET Core and Entity Framework Core are commonplace.
In this post, we’ll try to help folks starting with the combination of ASP.NET Core and Entity Framework Core (EF Core) to deliver HTTP APIs and look at some common approaches to structuring endpoints. We’ll start with a common pitfall from a lack of understanding about EF Core usage and work our way to some solutions.
The Database Model
In most cases, developers reaching for EF Core will be working with a relational database (RDBMS). EF Core supports SQL Server, SQLite, PostgreSQL, MySQL, and Oracle. These database engines have their quirks but generally, work similarly. The advantage of using an RDBMS is that it allows us to model bidirectional relationships that might also include some hierarchical importance.
In this post, we’ll model a Company
and Employee
model.
public class Company
{
public int Id { get; set; }
public string Name { get; set; }
public ICollection<Employee> Employees { get; set; }
}
public class Employee
{
public int Id { get; set; }
public string Name { get; set; }
public int CompanyId { get; set; }
public Company Company { get; set; }
}
We can see that a Company
has a collection of Employee
and that an Employee
refers to a Company
. The relationship is cyclical, a normal relationship, but we’ll see how it causes issues later.
Our complete EF Core database context looks like this.
public class Database : DbContext
{
public DbSet<Employee> Employees { get; set; }
public DbSet<Company> Companies { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Company>().HasData(
new { Id = 1, Name = "JetBrains" }
);
modelBuilder.Entity<Employee>().HasData(
new {Id = 1, Name = "Khalid Abuhakmeh", CompanyId = 1}
);
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlite("Data Source=app.db");
}
}
In the data seeding process, we add a new employee who works at a company. Most folks who are familiar with EF Core will be comfortable with this implementation, and those new to EF Core should be able to comprehend the model.
Now, let’s move on to exposing this data through an ASP.NET Core HTTP endpoint.
The ASP.NET Core Endpoints
The most robust way of building HTTP endpoints with ASP.NET Core is by utilizing the Model-View-Controller approach. Developers get extension points for request/response serialization, validation, and authorization. In our case, we’ll be using the JSON serialization provided by System.Text.Json
.
Let’s start with the most common mistake a new EF Core/ASP.NET Core developer might make. If you’ve come to this post via a search, this is likely the answer you need.
EF Core Anti-Pattern Endpoint
Some folks may have seen some demos and samples where an endpoint returns an EF Core model directly. This “works” for simple relational models with non-hierarchical relationships, but it is a bad idea. Let’s make a mistake with our database model and see what happens.
[HttpGet]
[Route("/oops")]
public async Task<List<Company>> Oops()
{
// this is a mistake, don't do this!
// The Json serializer will keep following
// reference properties until it gives up
// by throwing a JsonException: A possible object cycle was detected
return await database
.Companies
.Include(c => c.Employees)
.ToListAsync();
}
As the comment in the code says, this is a big mistake. EF Core queries are the equivalent of lit dynamite sticks. LINQ queries pack a lot of power, but we need to understand and respect them. Hitting this endpoint, we will see the following exception.
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 32. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles.
at System.Text.Json.ThrowHelper.ThrowJsonException_SerializerCycleDetected(Int32 maxDepth)
...
The issue comes from our relationship between Company
and Employee
and the impedance mismatch between our database and the objects we’re expecting to be output over HTTP. In simpler terms, the ASP.NET Core serializer doesn’t know when to stop following the navigation properties of Company
and Employees
, so it keeps following the properties until it throws an exception.
Again, Don’t write code like this! It will explode. If not immediately, it will become an issue as our data model evolves.
EF Core Projection Option
Using EF Core requires a mental model that some developers may gloss over. EF Core can lull some into thinking, “well, this is just C# right?!” Not exactly.
EF Core is a query interface powered by LINQ. While we define types using C# classes, the types and queries represent our database’s entities and concepts (SQL), a subtle yet significant distinction.
Switching to that mindset, we can think about writing a query that expresses our intent and eventual JSON response.
[HttpGet]
[Route("projection-anon")]
public async Task<IEnumerable<object>> Projection()
{
// We're using anonymous object projections
// to build our response object. Since we
// are defining the LINQ query, there is a finite
// end to our results.
return await database
.Companies
.Select(c => new
{
c.Id,
c.Name,
Employees = c
.Employees
.Select(e => new { e.Id, e.Name})
.ToList()
}).ToListAsync();
}
In this solution, we lean on LINQ and anonymous object projection. When running this endpoint, the JSON serializer no longer has a cyclical issue as we’ve terminated the relationship correctly.
[
{
"id": 1,
"name": "JetBrains",
"employees": [
{
"id": 1,
"name": "Khalid Abuhakmeh"
}
]
}
]
Great! We’ve solved our serialization issue, but let’s keep going. A problem with this approach is that our top-level response is an array. The approach leaves no room to evolve our response and add more information to our endpoint.
EF Core Projection With Anonymous Wrapper
In the previous section, we solved our cyclical exception but introduced a structural issue to an endpoint. There’s no way to add metadata to our response, metadata like total item count, pages, cursors, and statistical data.
It’s a good practice to wrap a response in a parent object to evolve the response without breaking clients. Let’s modify our endpoint.
[HttpGet]
[Route("projection-anon-wrapper")]
public async Task<object> Anonymous()
{
// still using projection,
// we'll choose to wrap our model
// in another anonymous object.
//
// this gives us room to grow
// and evolve our response, unlike
// returning an array directly
return new
{
Results = await database
.Companies
.Select(c => new
{
c.Id,
c.Name,
Employees = c
.Employees
.Select(e => new {e.Id, e.Name})
.ToList()
}).ToListAsync()
};
}
In this altered endpoint, we wrap our results in another anonymous object and set the results to a Results
field. Let’s see how that affects the JSON.
{
"results": [
{
"id": 1,
"name": "JetBrains",
"employees": [
{
"id": 1,
"name": "Khalid Abuhakmeh"
}
]
}
]
}
Great! At this point, we could stop. We’ve solved some major stumbling blocks that folks starting with HTTP APIs might encounter. That said, let’s think about strongly-typing our responses.
Strongly-Typed Responses
While the approach in the previous section works, it is prone to developer error. When creating responses, we can make Response
models that we can use across multiple endpoints. We have the added benefit of clearly understanding the schema our API endpoints expose to our consuming clients. Structured models can be helpful for code-generation scenarios using OpenAPI.
Here are the strongly typed response models.
public class Response<T>
{
public Response(IEnumerable<T> results = null)
{
Results = results ?? Array.Empty<T>();
}
public IEnumerable<T> Results { get; set; }
}
public class CompanyResponse
{
public int Id { get; set; }
public string Name { get; set; }
public IEnumerable<EmployeeResponse> Employees { get; set; }
= Array.Empty<EmployeeResponse>();
}
public class EmployeeResponse
{
public int Id { get; set; }
public string Name { get; set; }
}
In this case, they are similar to our database models, but they have an opportunity to evolve and change separate from our database technology. A necessary separation as where we read our data could change as our needs evolve. Today we’re reading our data entirely from an RDBMS, but tomorrow we may be aggregating data for this endpoint using a search engine like Elasticsearch.
Let’s see what the endpoint looks like after moving to strongly-typed response models.
[HttpGet]
[Route("projection-type-wrapper")]
public async Task<Response<CompanyResponse>> Wrapper()
{
// still using LINQ projection,
// but now using strongly-typed models.
//
// this allows for reuse and a better understanding
// of our responses.
//
// By using a Response<T> model, we can add additional
// metadata as well, like stats (i.e. total count, pages, cursor, etc.)
var result = await database
.Companies
.Select(c => new CompanyResponse
{
Id = c.Id,
Name = c.Name,
Employees = c
.Employees
.Select(e => new EmployeeResponse {
Id = e.Id,
Name = e.Name
})
}).ToListAsync();
return new Response<CompanyResponse>(result);
}
As you can see, it’s not very different than our previous approach. This approach begins to benefit us when we have multiple endpoints that return the same response objects. In this case, we may produce a CompanyResponse
from a single response endpoint like /companies/{id}
.
Conclusion
There is an assortment of powerful technologies in the .NET space, but developers can run into problems when using them together. In ASP.NET Core and EF Core implementations, developers might accidentally take the “easy” path and return the EF objects directly from a LINQ Query. The approach is a common mistake, as these EF entities may still connect to the database context. In our case, cyclical relationships can cause the JSON serializer to follow that cyclical relationship until it throws a JsonException
. We walked through several solutions to solve this issue, with all solutions leveraging some idea of query projection. This post shows the basic solution, and developers can expand on it by adding mapping libraries or more architecture if they choose to.
Please visit this GitHub repostiory to see a working sample of the code found in this post.
I hope you found this post useful, and good luck building your HTTP APIs. Please leave a comment below.