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:

    1. Rewrite the report class as a record, and fix the one thing that breaks.
    2. See what a record gives you, with three small experiments.
    3. Learn what a record costs, and why the table classes stay as they are.
    4. 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:

    A class and a record, side by side Class Record Writing it Seven lines braces, and every name repeated One line no body, no braces Changing a value after it is built Allowed line.Copies = 5 works Refused one-line form: line.Copies = 5 is an error Two lines with the same numbers Not equal == gives False Equal == gives True

    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:

    • record is used where class was.
    • 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 write new 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.Key is first, so it becomes Category.
    • g.Count() is second, so it becomes Titles.
    • g.Sum(b => b.Stock) is third, so it becomes Copies.
    • g.Sum(b => b.Price * b.Stock) is fourth, so it becomes StockValue.

    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.

    A correction makes a new record line Copies 6 corrected Copies 5 with { Copies = 5 } A new record is made. The old one is left alone.

    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, because Titles and Copies are 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. CategoryStock holds 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 with copy 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.

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

    1. Why does Book stay a class?
    Show answer

    Book is loaded from the table and then changed, and records are for values that only carry data.

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

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

    ← The Stock ReportSmall C# Shortcuts →