In this article, we will explore the pros and cons of using records with EF Core as model classes. We’ll dive into what C# records are, how they differ from classes, and how they can be used as model classes in EF Core.
It’s important to understand the different approaches available when it comes to defining model classes. So, let’s get started and find out whether using records with EF Core is a good choice for creating entities in EF Core.
What Are Records in .NET?
Records are a type introduced in C# 9. They provide a concise syntax for declaring types that primarily hold data, and don’t have any behavior, such as Data Transfer Objects (DTOs).
Also, they have inbuilt methods for checking the equality of objects as well as hashing. Since records are immutable types, changing the data requires us to create new objects.
First, let’s set up a Web API project using Visual Studio. Alternatively, we can use the CLI command:
dotnet new webapi
After that, let’s install the Microsoft.EntityFrameworkCore package using Visual Studio Package Manager.
We could also use the CLI command:
dotnet add package Microsoft.EntityFrameworkCore
Let’s also add the Microsoft.EntityFrameworkCore.InMemory package:
dotnet add package Microsoft.EntityFrameworkCore.InMemory
The two libraries give us all the functionality we need for consuming data from a database in our application. It’s worth mentioning that we’ll use an in-memory database provider.
Database Preparation
To start, let’s create a Car
record that we’ll use as our model class:
public record Car(string Make, string Model, int Year) { public int Id { get; init; } }
It’s worth noting that the record has an Id
property that will be auto-incremented by ef core and we don’t need to assign it, that’s why it’s not part of the primary constructor of the record.
For organizational purposes, we put this record in the namespace RecordsAsModelClasses.Core.Entities.Records
.
Now that we’ve created our Car
record, let’s create a database context that we can use to interact with the database.
To do this, we create a new CarDbContext
class that derives from DbContext
, then add a DbSet
property for our Car
record:
public class CarDbContext : DbContext { public DbSet<Entities.Records.Car> RecordCars { get; set; } public CarDbContext(DbContextOptions<CarDbContext> options) : base(options) { } protected override void OnConfiguring (DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseInMemoryDatabase(databaseName: "CarsDb"); } }
After that, let’s register the database context in our dependency injection container:
builder.Services.AddDbContext<CarDbContext>(options => options.UseInMemoryDatabase(databaseName: "CarsDb"));
Let’s proceed to manipulate data in our database.
Using Records With EF Core as Model Classes
To capture data from controllers, we’ll use Data Transfer Objets (DTOs). So let’s create them:
public record CarDto(int Id, string Make, string Model, int Year);
We’ll use this to map models to the responses we return from our application.
For making updates, we’ll use the UpsertCarDto
:
public record UpsertCarDto(string Make, string Model, int Year);
In each of the DTOs, we’ve used records because they are easy-to-define types that hold data and are immutable, making them the best choice.
Now, let’s add the first version of the CarsController
which we’ll use for CRUD operations. Let’s start with a POST endpoint:
[HttpPost] public async Task<IActionResult> CreateCarAsync([FromBody] UpsertCarDto carDto) { var car = new Car(carDto.Make, carDto.Model, carDto.Year); _context.RecordCars.Add(car); await _context.SaveChangesAsync(); var carResponse = new CarDto(car.Id, car.Make, car.Model, car.Year); return CreatedAtAction(nameof(GetCar), new {car.Id}, carResponse); }
Calling this endpoint, we add a new car object to the in-memory database, save changes and return the created car.
It’s worth noting that we’re injecting CarDbContext
directly into the controller but this is only for simplicity and demo purposes. In real-world applications, we should not do this since it leads to tight coupling between the Presentation layer and the Data layer. This has been pointed out in Onion Architecture in ASP.NET Core.
Now, let’s update the same car we’ve created:
[HttpPut("car/{id:int}")] public async Task<IActionResult> UpdateCarUsingRecords(int id, [FromBody] UpsertCarDto updatedCar) { var car = await _context.RecordCars.FindAsync(id); if (car == null) { return NotFound(); } car = car with { Make = updatedCar.Make, Model = updatedCar.Model, Year = updatedCar.Year }; _context.RecordCars.Update(car); await _context.SaveChangesAsync(); return NoContent(); }
We first fetch the car from the database, then update the properties of the record using the with
operator. Since records are immutable, the with
operator creates a new object of the type Car
and overrides the values of the properties we updated. Until this point, EF core is tracking the old object and not the new one, so we need to update it by calling the _context.RecordCars.Update(car)
method. After that, we save the changes in the database.
Calling this endpoint, we get a System.InvalidOperationException
:
System.InvalidOperationException: The instance of entity type 'Car' cannot be tracked because another instance with the same key value for {'Id'} is already being tracked. When attaching existing entities, ensure that only one entity instance with a given key value is attached. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the conflicting key values.
Using the with
operator, we have created another object with the same Id
. Calling the _context.RecordCars.Update(car)
method, EF Core attempts to track both objects in the same context. Since we have two objects with the same Id
, EF Core throws the InvalidOperationException
.
Disabling EF Core Entity Tracking
One solution is to disable tracking using the AsNoTracking()
method call.
So let’s modify the query:
[HttpPut("car/{id:int}")] public async Task<IActionResult> UpdateCarUsingRecords(int id, [FromBody] UpsertCarDto updatedCar) { var car = await _context .RecordCars .Where(c => c.Id == id) .AsNoTracking() .FirstOrDefaultAsync(); if (car == null) { return NotFound(); } car = car with { Make = updatedCar.Make, Model = updatedCar.Model, Year = updatedCar.Year }; _context.RecordCars.Update(car); await _context.SaveChangesAsync(); return NoContent(); }
Calling this endpoint, we can now update the database entity successfully. However, this comes at a disadvantage because we have to manually call the RecordCars.Update()
method to inform EF core of the changes. Otherwise, if we only call the _context.SaveChangesAsync()
method, any changes we make to our entities won’t update the database.
Using SetValues() on Records as Model Classes
Alternatively, we could use the SetValues()
method of the Entry
object to update the values of the car in the database.
For this, let’s create one new endpoint:
[HttpPut("{id:int}")] public async Task<IActionResult> UpdateCar(int id, [FromBody] UpsertCarDto updatedCar) { var car = await _context .RecordCars .Where(c => c.Id == id) .FirstOrDefaultAsync(); if (car == null) { return NotFound(); } _context.Entry(car).CurrentValues.SetValues(updatedCar); await _context.SaveChangesAsync(); return NoContent(); }
We’re using the SetValues()
method of the CurrentValues
property of the Entry
object to update the values of the old Car
object with the values of the updated one. Then, we save the changes to the database. Using this approach, we rewrite the values of all the properties of the car object. If we have a large number of properties, this could be a performance bottleneck.
While records can be useful in certain scenarios, they are not the best choice to use as model classes in a database context where we need to frequently modify our data.
Using Classes With EF Core as Model Classes
In the Entities
folder, let’s a new Classes
folder. In the same folder, let’s create a new Car
class:
public class Car { public int Id { get; set; } public string Make { get; set; } public string Model { get; set; } public int Year { get; set; } }
Then, let’s add the corresponding DbSet
it in the database context:
public DbSet<Entities.Classes.Car> ClassCars { get; set; }
After that, in the Controllers
folder, let’s add a v2
folder that will contain a second version of our CarsController
and add two new endpoints:
[HttpPost] public async Task<ActionResult<CarDto>> CreateCar([FromBody] UpsertCarDto carDto) { var car = new Car { Make = carDto.Make, Model = carDto.Model, Year = carDto.Year }; _context.ClassCars.Add(car); await _context.SaveChangesAsync(); var carResponse = new CarDto(car.Id, car.Make, car.Model, car.Year); return CreatedAtAction(nameof(GetCar), new {car.Id}, carResponse); }
Calling this endpoint adds a new car to our database.
The second endpoint lets us modify the car in our database:
[HttpPut("{id:int}")] public async Task<IActionResult> UpdateCar(int id, [FromBody] UpsertCarDto carDto) { var car = await _context .ClassCars .Where(c => c.Id == id) .FirstOrDefaultAsync(); if (car == null) { return NotFound(); } car.Model = carDto.Model; car.Make = carDto.Make; car.Year = carDto.Year; await _context.SaveChangesAsync(); return NoContent(); }
We can directly update the objects in the database because we’re using regular classes, so we can easily mutate the data. In this case, we only update the fields we need without any additional configuration.
Compared to records, classes are more flexible as EF Core models. This makes them the best option for EF Core models.
Conclusion
In this article, we have discussed records and their usage as model classes in EF Core. We have learned that because of immutability, records are not best suited for use as model classes. Instead, we can use them as data transfer objects (DTO) to pass data from the client to the server and vice versa. We have also looked at how we can use classes to create database models and the flexibility we get from using them.