Tutorials / Build a REST API with C# and .NET · Lesson 26 of 27

On this page

    Records and Small C# Shortcuts

    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. This lesson is optional: the course works without it. The lessons that follow use these shortcuts, so come back when you meet one.

    Our goal: write the report class in one line, know what you get from it, and meet three more small shortcuts, without changing anything the API does.

    Here is the route:

    1. Picture the two kinds of object: one you can change, and one you cannot.
    2. Rewrite the report class as a record, and fix the one thing that breaks.
    3. See what a record gives you, with three small experiments.
    4. Learn why the table classes stay as they are.
    5. Meet three shortcuts: new(), [] and required.
    6. 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.

    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                 <- changes (two shortcuts)
          FakeLogger.cs                      <- changes (one shortcut)
          BooksControllerTests.cs
          GlobalExceptionHandlerTests.cs
          FakeStockReportService.cs          <- changes (one shortcut)
          ReportsControllerTests.cs          <- changes (builds the record)
    

    Run every command from the solution folder, BookShop/. No project is added, so there is no solution map.


    Two kinds of object

    Think of a whiteboard and a printed receipt. Both can say "Cooking, 6 copies".

    • The whiteboard can be changed at any time. Rub out the 6, write a 5. Two whiteboards with the same words on them are still two whiteboards.
    • The printed receipt cannot be changed. If a number is wrong, you print a new receipt that is the same except for that one line. And two receipts with the same lines are the same receipt, as far as anyone reading them cares.
    A whiteboard and a receipt Whiteboard = a class Cooking · 6 copies Fix a number? rub it out, write the new one Same words on two boards? still two different boards Receipt = a record Cooking · 6 copies Fix a number? print a new one, one line changed Same lines on two receipts? the same receipt

    A report line is a receipt. Once the service has worked out "Cooking, 2 titles, 6 copies, 181.00", nobody should rub out a number on its way to the client. A C# record is that receipt. The class we wrote is the whiteboard. (This is a picture to remember the idea by. Real records have more to them, and we only meet the parts the course uses.)


    The class as a record

    Here is the class from the stock report, for comparison:

    public class CategoryStock
    {
        public string Category { get; set; } = "";
        public int Titles { get; set; }
        public int Copies { get; set; }
        public decimal StockValue { get; set; }
    }
    

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

    One line. Read it 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.)
    • 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

    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 Select now calls the constructor, so it lists the four values in order. And the OrderBy moved before the Select. That move is not a style choice. The first try, with the sort left at the end, built and then failed when the tests ran:

    System.InvalidOperationException: The LINQ expression 'DbSet<Book>()
        .OrderBy(e0 => e0.Category)' could not be translated. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to 'AsEnumerable', 'AsAsyncEnumerable', 'ToList', or 'ToListAsync'.
    

    The message says EF Core could not turn OrderBy into SQL. After a Select, the sort refers to r.Category of a CategoryStock, and with a constructor EF Core cannot see which of the four values became Category. (With the old Category = g.Key form, the link was written out, and it could.) The fix is to sort the groups while they are still groups: g.Key is the category, and EF Core knows exactly what that is. 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 of CategoryStock as it is now, and prints what we ask. To compare, the first also uses the old class, kept for this one experiment under the name CategoryStockClass.

    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 whiteboards, so False. 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 }
    

    Two receipts with the same lines are the same, 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 correction. To change one line of a receipt, print a new one. 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. Trying to rub out the number in place is an error before the program even runs:

    var line = new CategoryStock("Cooking", 2, 6, 181.00m);
    line.Copies = 5;
    
    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.)


    Why the table classes stay classes

    Book could be a record too, so why is it not? Because it is not a receipt. 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. That is a whiteboard's life. A record is made for values that are built once and never change, like a report line, or (in the next 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.


    Three more shortcuts

    Each one is a few characters saved on code the course already has. They are habits to recognise as much as to use.

    new(): do not repeat the type

    When the type is already written on the left, the right side does not need to say it again. The fake book service has:

    public SellResult SellResult { get; set; } = new SellResult();
    

    The left side says SellResult, so the right side can be:

    public SellResult SellResult { get; set; } = new();
    

    The compiler fills in the type from the left. It works only where the type is written on the left (a property, a field, a parameter), and not after var, where nothing says what the type is.

    []: an empty list, or a list of items

    An empty list used to be written new List<Book>(). It can now be written [], which is called a collection expression:

    public List<Book> Books { get; } = new List<Book>();
    public List<Book> Books { get; } = [];
    

    The two lines mean the same. The type on the left (List<Book>) decides what [] is. With items, it reads as a list too: [1, 2, 3] is a list of three numbers. Again the type must be known from the left.

    required: a value that must be set

    The old class could be built with a value left out, and nothing complained: Titles stayed 0. A record's constructor already forbids that, because you must give all four values. When you want the same guarantee on a plain class, put required in front of a property. This experiment uses the old class shape:

    var line = new CategoryStockClass { Category = "Cooking" };
    
    public class CategoryStockClass
    {
        public required string Category { get; set; }
        public required int Titles { get; set; }
        public required int Copies { get; set; }
        public required decimal StockValue { get; set; }
    }
    
    error CS9035: Required member 'CategoryStockClass.Titles' must be set in the object initializer or attribute constructor.
    

    Forgetting a value is now an error when you build, not a quiet zero in the report. We do not put required on Book: our tests build books with only the fields they care about (new Book { Id = 1, Title = "Clean Code" }), and required would stop all of them. It is a choice about how strict the class should be.

    The fakes, with the shortcuts

    Open tests/BookShop.Api.Tests/FakeStockReportService.cs. The whole file is:

    using BookShop.Data;
    
    namespace BookShop.Api.Tests;
    
    public class FakeStockReportService : IStockReportService
    {
        public List<CategoryStock> Report { get; } = [];                        // new
        // public List<CategoryStock> Report { get; } = new List<CategoryStock>();   // replaced
    
        public Task<List<CategoryStock>> GetByCategoryAsync()
        {
            return Task.FromResult(Report);
        }
    }
    

    Open tests/BookShop.Api.Tests/FakeLogger.cs. The whole file is:

    using Microsoft.Extensions.Logging;
    
    namespace BookShop.Api.Tests;
    
    public class FakeLogger<T> : ILogger<T>
    {
        public List<LogLevel> Levels { get; } = [];                             // new
        public List<Exception?> Exceptions { get; } = [];                       // new
        // public List<LogLevel> Levels { get; } = new List<LogLevel>();        // replaced
        // public List<Exception?> Exceptions { get; } = new List<Exception?>();     // replaced
    
        public IDisposable? BeginScope<TState>(TState state) where TState : notnull
        {
            return null;
        }
    
        public bool IsEnabled(LogLevel logLevel)
        {
            return true;
        }
    
        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
            Exception? exception, Func<TState, Exception?, string> formatter)
        {
            Levels.Add(logLevel);
            Exceptions.Add(exception);
        }
    }
    

    Open tests/BookShop.Api.Tests/FakeBookService.cs. The whole file is:

    using BookShop.Data;
    
    namespace BookShop.Api.Tests;
    
    public class FakeBookService : IBookService
    {
        public List<Book> Books { get; } = [];                                  // new
        public SellResult SellResult { get; set; } = new();                     // new
        // public List<Book> Books { get; } = new List<Book>();                 // replaced
        // public SellResult SellResult { get; set; } = new SellResult();       // replaced
    
        public Task<List<Book>> GetAllAsync()
        {
            return Task.FromResult(Books);
        }
    
        public Task<Book?> GetByIdAsync(int id)
        {
            var book = Books.FirstOrDefault(b => b.Id == id);
            return Task.FromResult(book);
        }
    
        public Task<Book> CreateAsync(Book book)
        {
            book.Id = Books.Count + 1;
            Books.Add(book);
            return Task.FromResult(book);
        }
    
        public Task<bool> UpdateAsync(int id, Book book)
        {
            var index = Books.FindIndex(b => b.Id == id);
            if (index < 0)
            {
                return Task.FromResult(false);
            }
    
            book.Id = id;
            Books[index] = book;
            return Task.FromResult(true);
        }
    
        public Task<bool> DeleteAsync(int id)
        {
            var existing = Books.FirstOrDefault(b => b.Id == id);
            if (existing == null)
            {
                return Task.FromResult(false);
            }
    
            Books.Remove(existing);
            return Task.FromResult(true);
        }
    
        public Task<SellResult> SellAsync(int id, int quantity)
        {
            return Task.FromResult(SellResult);
        }
    }
    

    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, and the fakes are a little shorter:

    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                 [] and new()
          FakeLogger.cs                      []
          BooksControllerTests.cs
          GlobalExceptionHandlerTests.cs
          FakeStockReportService.cs          []
          ReportsControllerTests.cs          builds the record with its constructor
    

    Recap

    • A class is like a whiteboard: it can be changed in place, and two objects with the same values are different objects.
    • A record is like a printed receipt: its values are 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.
    • new() and [] save repeating a type the left side already names. required makes forgetting a value an error when you build.
    • A rewrite that changes nothing is proved by the same tests and the same JSON.

    Questions to think about

    These have no answers here, on purpose. Try them from memory first.

    1. What do you get from a record that you do not get from a class?
    2. Why does Book stay a class?
    3. What does required protect against?
    4. How do we know a rewrite like this one did not break anything?
    5. 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? (The next lesson answers this.)

    Next lesson: DTOs for the books endpoints, where the books endpoints stop sending and receiving the table class.

    ← The Stock ReportDTOs for the Books Endpoints →