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.

To download the source code for this article, you can visit our GitHub repository.

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).

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

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.

If you want to learn more about records, our article Records in C# provides a more in-depth discussion with examples for better understanding.

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. 

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!