Tutorials / Build a REST API with C# and .NET · Lesson 27 of 34
Concept
On this page
Records
This lesson is optional. The course works without it, and nothing here changes what the API does. It is good to know because it shortens your code. The lessons that follow use records, so come back when you meet one.
Look at CategoryStock from the stock report. It holds four values, and it takes seven lines to say so. Every class that only carries data looks like that: the same words repeated, and braces around them. Modern C# has a shorter way, and many teams use it every day.
Our goal: write the report class in one line, know what you get from it, and see why the table classes stay as they are, without changing anything the API does.
Here is the route:
- Rewrite the report class as a record, and fix the one thing that breaks.
- See what a record gives you, with three small experiments.
- Learn what a record costs, and why the table classes stay as they are.
- Prove that nothing changed: same tests, same JSON.
This lesson leaves out the other new class styles of C# (such as pattern matching and constructors written on the class line), because the course code does not need them. A few small shortcuts for writing less code are left for the next lesson, because they have nothing to do with records.
Where we are
This is the solution from the end of the stock report. Only a few files change, and none is added:
BookShop/
BookShop.slnx
Directory.Build.props
Directory.Packages.props
src/
BookShop.Api/
Program.cs
GlobalExceptionHandler.cs
Controllers/
BooksController.cs
ReportsController.cs
BookShop.Data/
Book.cs
BookShopDbContext.cs
IBookService.cs
BookService.cs
SellResult.cs
CategoryStock.cs <- changes (a record)
IStockReportService.cs
StockReportService.cs <- changes (builds the record)
Migrations/
tests/
BookShop.Data.Tests/
BookServiceTests.cs
BookValidationTests.cs
StockReportServiceTests.cs
BookShop.Api.Tests/
FakeBookService.cs
FakeLogger.cs
BooksControllerTests.cs
GlobalExceptionHandlerTests.cs
FakeStockReportService.cs
ReportsControllerTests.cs <- changes (builds the record)
Run every command from the solution folder, BookShop/. No project is added, so there is no solution map.
The class as a record
CategoryStock is a class whose only job is to carry four values from the service to the client. A plain class is clumsy for that job, in three ways:
- It is long. Four values take seven lines, with the same words repeated and braces around them.
- Anyone can change it. Once the service has worked out a line ("Cooking, 2 titles, 6 copies, 181.00"), nobody should change a number on its way to the client, but a class allows it.
- It does not compare or print by its values. Two lines with the same numbers are not equal to each other, and printing one shows only the name of the class.
A record is a class made for exactly this job. The experiments later in this lesson show the last two points at work. Side by side, on the three points:
First, the class as we have it now:
public class CategoryStock
{
public string Category { get; set; } = "";
public int Titles { get; set; }
public int Copies { get; set; }
public decimal StockValue { get; set; }
}
To build one, we create it and name each value in braces, in any order:
var line = new CategoryStock { Category = "Cooking", Titles = 2, Copies = 6, StockValue = 181.00m };
As a record, it takes one line. Open src/BookShop.Data/CategoryStock.cs. The whole file is now:
namespace BookShop.Data;
public record CategoryStock(string Category, int Titles, int Copies, decimal StockValue);
To build one, we give the four values in brackets, in the order the record lists them:
var line = new CategoryStock("Cooking", 2, 6, 181.00m);
Same object, same four values. Only the way of writing it changed: names in braces for the class, order in brackets for the record.
Read the record from the left:
recordis used whereclasswas.- The four values in brackets are the data of one object, a type and a name each. C# makes a property from each one (
Category,Titles,Copies,StockValue), and a constructor that takes the four in that order. The old class had no constructor, and we filled it with{ Category = ..., ... }. Now we writenew CategoryStock("Cooking", 2, 6, 181.00m). - The properties can be set once, when the object is built, and never again. (C# calls this
init.) A record does not have to work this way: you can write one with properties that can be changed. The one-line form, which this course uses, sets them once. - The semicolon ends the whole declaration, since there is no body.
The names and the types are the same as before, so everything that reads a CategoryStock (the tests, the JSON) keeps working. Only the code that builds one has to change: there are two places. The service, and the controller tests.
The service
As a reminder, this is the record the service has to build, with its four values in this order:
public record CategoryStock(string Category, int Titles, int Copies, decimal StockValue);
Open src/BookShop.Data/StockReportService.cs. The whole file is:
using Microsoft.EntityFrameworkCore;
namespace BookShop.Data;
public class StockReportService : IStockReportService
{
private readonly BookShopDbContext _context;
public StockReportService(BookShopDbContext context)
{
_context = context;
}
public async Task<List<CategoryStock>> GetByCategoryAsync()
{
return await _context.Books
.GroupBy(b => b.Category)
.OrderBy(g => g.Key) // new
.Select(g => new CategoryStock( // new
g.Key, // new
g.Count(), // new
g.Sum(b => b.Stock), // new
g.Sum(b => b.Price * b.Stock))) // new
// .Select(g => new CategoryStock // replaced
// { // replaced
// Category = g.Key, // replaced
// Titles = g.Count(), // replaced
// Copies = g.Sum(b => b.Stock), // replaced
// StockValue = g.Sum(b => b.Price * b.Stock) // replaced
// }) // replaced
// .OrderBy(r => r.Category) // replaced
.ToListAsync();
}
}
The commented-out lines show where the old code went. They are temporary: delete them once you have seen them, because git remembers the old version.
Two things changed. The first is the Select. It now calls the constructor, and a constructor matches values by position, not by name. The first value goes to the first parameter, the second to the second, and so on:
g.Keyis first, so it becomesCategory.g.Count()is second, so it becomesTitles.g.Sum(b => b.Stock)is third, so it becomesCopies.g.Sum(b => b.Price * b.Stock)is fourth, so it becomesStockValue.
The old form wrote each name (Category = g.Key), so the order did not matter. Now the order is the meaning: swap two lines and the values land in the wrong places. When the two have the same type (here Titles and Copies are both int), the compiler does not warn you.
The second change is that the OrderBy moved before the Select, so it sorts the groups while they are still groups. g.Key is the category, and EF Core knows exactly what that is and can turn it into SQL. After the Select, the sort would have to look inside a CategoryStock built by a constructor, and EF Core cannot tell which of the four values became Category. The rule to keep: when you build a record inside a query, sort before you build it.
The controller tests
The tests that build a CategoryStock by hand use the constructor now. Open tests/BookShop.Api.Tests/ReportsControllerTests.cs. The whole file is:
using BookShop.Data;
namespace BookShop.Api.Tests;
[TestClass]
public class ReportsControllerTests
{
private FakeStockReportService _fakeService = null!;
private ReportsController _controller = null!;
[TestInitialize]
public void Setup()
{
_fakeService = new FakeStockReportService();
_controller = new ReportsController(_fakeService);
}
[TestMethod]
public async Task GetStock_TwoCategories_ReturnsBothLines()
{
_fakeService.Report.Add(new CategoryStock("Cooking", 2, 6, 181.00m)); // new
_fakeService.Report.Add(new CategoryStock("Programming", 3, 22, 852.30m)); // new
// _fakeService.Report.Add(new CategoryStock { Category = "Cooking", Titles = 2, Copies = 6, StockValue = 181.00m }); // replaced
// _fakeService.Report.Add(new CategoryStock { Category = "Programming", Titles = 3, Copies = 22, StockValue = 852.30m }); // replaced
var result = await _controller.GetStock();
Assert.IsNotNull(result.Value);
Assert.HasCount(2, result.Value);
Assert.AreEqual("Cooking", result.Value[0].Category);
Assert.AreEqual(852.30m, result.Value[1].StockValue);
}
[TestMethod]
public async Task GetStock_NoBooks_ReturnsAnEmptyList()
{
var result = await _controller.GetStock();
Assert.IsNotNull(result.Value);
Assert.IsEmpty(result.Value);
}
}
The service tests need no change: they only read Category, Titles, Copies and StockValue, and those are still there.
What a record gives you
Three small experiments, in any throwaway console project (the first lessons of the course made some). Each one builds objects and prints what we ask. First, the two shapes we compare. To compare, the old class is kept for this one experiment under the name CategoryStockClass.
The old class, with a body, braces and one line per value:
public class CategoryStockClass
{
public string Category { get; set; } = "";
public int Titles { get; set; }
public int Copies { get; set; }
public decimal StockValue { get; set; }
}
The record, as it is now:
public record CategoryStock(string Category, int Titles, int Copies, decimal StockValue);
The old class, two objects with the same values. Are they equal?
var first = new CategoryStockClass { Category = "Cooking", Titles = 2, Copies = 6, StockValue = 181.00m };
var second = new CategoryStockClass { Category = "Cooking", Titles = 2, Copies = 6, StockValue = 181.00m };
Console.WriteLine(first == second);
Console.WriteLine(first);
False
CategoryStockClass
Two separate objects, so False: a class is equal only to itself, never to another object, even with the same values. And printing one shows only the name of the class, which tells us nothing.
The record, two objects with the same values. Same question:
var first = new CategoryStock("Cooking", 2, 6, 181.00m);
var second = new CategoryStock("Cooking", 2, 6, 181.00m);
Console.WriteLine(first == second);
Console.WriteLine(first);
True
CategoryStock { Category = Cooking, Titles = 2, Copies = 6, StockValue = 181.00 }
Same values, so True: a record is compared by its values. And printing it shows every value, for free. That is handy in a test failure message or a log line.
A change in place. Say a line was built with the wrong number of copies, and we try to fix it directly:
var line = new CategoryStock("Cooking", 2, 6, 181.00m);
line.Copies = 5;
A record in the one-line form refuses this, before the program even runs:
error CS8852: Init-only property or indexer 'CategoryStock.Copies' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.
(The message is long, and the part to read is "Init-only property": it can be set once, when the object is built.)
This lock comes from the one-line form, not from the word "record". The one-line form is what most teams use for data that is only carried around, such as responses and report rows, because such data should not change once it is built.
A correction. So to correct a record, you make a new one with one value different. In C# the word is with:
var line = new CategoryStock("Cooking", 2, 6, 181.00m);
var corrected = line with { Copies = 5 };
Console.WriteLine(corrected);
Console.WriteLine(line);
CategoryStock { Category = Cooking, Titles = 2, Copies = 5, StockValue = 181.00 }
CategoryStock { Category = Cooking, Titles = 2, Copies = 6, StockValue = 181.00 }
corrected is a copy with one number changed, and line is untouched.
Why the table classes stay classes
Book could be a record too, so why is it not? Because its values do change. EF Core builds a Book from a row, and our code changes it later: the sale does book.Stock -= quantity, and EF Core notices the change and saves it. A record is made for values that are built once and are not meant to change, like a report line, or (in a later lesson) the shape of a request.
So the rule we follow: classes for the things that change (the table rows), records for the values that only carry data. Both are normal C#, and many teams draw the line in the same place.
Pros and cons of a record
A record is not better in every way. It trades some freedom for safety.
Pros
- Short. One line instead of seven.
- Safe to hand around. The values are set once, so nobody can change a number on its way to the client. This also matters for threads. A web server answers many requests at the same moment, on different threads, and two threads changing the same object can leave it half changed. Values that can never change cannot be damaged that way, so they can be shared freely.
- Easy to compare and read. Two records with the same values are equal, and printing one shows every value.
Cons
- You cannot change one in place. Every change is a new object made with
with. That suits a report line, and it is wrong for a table row that the code updates. - The order of the values matters.
new CategoryStock("Cooking", 6, 2, 181.00m)compiles, becauseTitlesandCopiesare both numbers, and it quietly swaps them. The old{ Titles = 2, Copies = 6 }named every value, so a mix-up was visible. (C# lets you name them in a constructor call too:new CategoryStock("Cooking", Titles: 2, Copies: 6, StockValue: 181.00m).) - A list inside a record can still be changed. The record's own values cannot be reassigned, but a list held inside a record can still be added to, so it is not safe to share.
CategoryStockholds only text and numbers, so it is safe. (This is the rule for the one-line form.)
So where is a record used, and why is it good there?
Records are used for data that is built once and then only carried or read:
- what an API sends back to a client
- what a client sends in
- a row of a report
- a message passed between two parts of a program
- the result of a method
Why it suits them:
- Nobody needs to change a value after it is built, so the pros come for free: short to write, safe to share, easy to compare and print.
- The cons barely matter: nothing has to be changed in place, and a
withcopy is rare. - That is why records are common for the classes at the edges of an API, and why this course uses them from here on.
Did anything change?
A rewrite that changes nothing is a refactoring, and the way to prove it is the tests, and the endpoint. Run all tests from the solution folder:
dotnet test
Passed! - Failed: 0, Passed: 18, Skipped: 0, Total: 18, Duration: 155 ms - BookShop.Api.Tests.dll (net10.0)
Passed! - Failed: 0, Passed: 23, Skipped: 0, Total: 23, Duration: 1 s - BookShop.Data.Tests.dll (net10.0)
The same forty-one tests as before. (The durations may differ.) Then start the app and send the same request as in the stock report:
curl http://localhost:5101/reports/stock
[
{
"category": "Cooking",
"titles": 2,
"copies": 6,
"stockValue": 181.0
},
{
"category": "Programming",
"titles": 3,
"copies": 22,
"stockValue": 852.3
}
]
(The real body is one long line.) Letter for letter the same JSON as before the rewrite, so a frontend would not notice. One small difference, on the OpenAPI document that feeds the test page: it now lists all four fields as required, because the record's constructor needs all four. That is true of the answer, so the test page describes it better than before.
Where we ended
Compare with your solution. The report is one line:
BookShop/
BookShop.slnx
Directory.Build.props
Directory.Packages.props
src/
BookShop.Api/
Program.cs
GlobalExceptionHandler.cs
Controllers/
BooksController.cs
ReportsController.cs
BookShop.Data/
Book.cs
BookShopDbContext.cs
IBookService.cs
BookService.cs
SellResult.cs
CategoryStock.cs a record, one line
IStockReportService.cs
StockReportService.cs builds the record, sorts before it
Migrations/
tests/
BookShop.Data.Tests/
BookServiceTests.cs
BookValidationTests.cs
StockReportServiceTests.cs
BookShop.Api.Tests/
FakeBookService.cs
FakeLogger.cs
BooksControllerTests.cs
GlobalExceptionHandlerTests.cs
FakeStockReportService.cs
ReportsControllerTests.cs builds the record with its constructor
Recap
- A class can be changed in place, and two objects with the same values are different objects.
- A record in the one-line form has its values set once, two records with the same values are equal, and a correction is a new object made with
with. public record CategoryStock(string Category, int Titles, int Copies, decimal StockValue);makes the properties and the constructor in one line. Everything that reads the properties keeps working, and only the code that builds one changes.- When a query builds a record, sort before you build it, or EF Core cannot translate the
OrderBy. - The table classes stay classes, because they are loaded and then changed. Records are for values that only carry data.
- A rewrite that changes nothing is proved by the same tests and the same JSON.
Questions to think about
Each question has its answer folded away underneath. Guess first, then open it to check.
- What do you get from a record that you do not get from a class?
Show answer
Two records with the same values are equal, in the one-line form the values are set once so they cannot be changed by accident, and a corrected copy is made with with. A record also takes one line to declare.
- Why does
Bookstay a class?
Show answer
Book is loaded from the table and then changed, and records are for values that only carry data.
- How do we know a rewrite like this one did not break anything?
Show answer
The same tests pass and the same JSON comes out.
- The requests that clients send have the same shape as this report line: a few values that only carry data. Which kind of class would you use for them? (A later lesson answers this.)
Show answer
A record.
Next lesson: small C# shortcuts, a few more ways to write less code. It is optional too.