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

On this page

    Business Rules and Meaningful Errors

    Ask a bank for money you do not have, and the answer is not "404". It is not "invalid input" either. It is something with meaning: "insufficient funds." Your account exists, your request is fine, and the bank's rules say no. A bookshop has the same kind of "no": a customer wants five copies of a book, and the shelf holds three.

    Our goal: answer a request that the shop's rules forbid with an error that says what is wrong and why, in the standard error format of errors the professional way (Problem Details). The example is a new endpoint that sells copies of a book. We write the rule, the endpoint and the tests, and try three different failures.

    Here is the route:

    1. Tell apart three kinds of "no": invalid input, not found, and a business rule.
    2. Write the rule in the service, and see why it returns a small result and not an exception.
    3. Add the endpoint, and let the controller turn the result into an answer.
    4. Give the answer meaning at three levels: the status, the words, and a code the frontend can act on.
    5. Run all three failures, and test them.

    This lesson leaves out orders, payments and customers, two people buying the last copy at the same moment, and a real documentation page for the error codes.

    Where we are

    This is the solution from the end of update and delete. We touch these files:

    BookShop/
      BookShop.slnx
      Directory.Build.props
      Directory.Packages.props
      src/
        BookShop.Api/
          Program.cs
          GlobalExceptionHandler.cs
          Controllers/
            BooksController.cs               <- changes (a new endpoint)
        BookShop.Data/
          Book.cs
          BookShopDbContext.cs
          IBookService.cs                    <- changes (a new method)
          BookService.cs                     <- changes (the rule)
          SellResult.cs                      <- NEW
          Migrations/
      tests/
        BookShop.Data.Tests/
          BookServiceTests.cs                <- changes (four new tests)
          BookValidationTests.cs
        BookShop.Api.Tests/
          FakeBookService.cs                 <- changes (the new method)
          FakeLogger.cs
          BooksControllerTests.cs            <- changes (three new tests)
          GlobalExceptionHandlerTests.cs
    

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


    Three kinds of "no"

    All three of these answers are in the 4xx family: the client asked for something that cannot be done. They are still different jobs, and mixing them up is one of the most common mistakes in an API.

    The question it answers Bank example Bookshop example Status
    Invalid input Is the request acceptable in any case? The amount is "abc" or negative Sell 0 copies, or "abc" copies 400
    Not found Does the thing exist? There is no such account There is no book with id 99 404
    Business rule The request is fine and the thing exists, but does the current state allow it? Insufficient funds Sell 5 copies when 3 are left 409

    A business rule is a rule of the shop and not of the computer: "you cannot sell more copies than you have." The computer would happily do the subtraction and reach minus two. Only our code knows that minus two copies on a shelf makes no sense.

    A quick way to tell a business rule from invalid input: would the answer be the same whatever is in the database? "Sell 0 copies" is wrong with any data, so it is invalid input. "Sell 5 copies" is fine with a full shelf and wrong with a short one, so it depends on the data. That makes it a business rule.

    Three requests, three kinds of no The request Who says no The answer quantity = 0 any data, still wrong [ApiController] the input rules 400 Bad Request sell book 99 there is no book 99 the service cannot find it 404 Not Found sell 5 of 3 left fine on a full shelf the service the stock rule 409 Conflict

    The third row is the new one, and it is the subject of the lesson.


    The rule lives in the service

    The books service gave each class one job: the controller speaks HTTP, and the service knows the shop. The stock rule is a rule of the shop, so it goes in the service. That also means a nightly job, or a second app, would obey the same rule without copying it.

    The service must report back more than "yes" or "no". The controller needs to know whether the book exists, whether it was sold, and, if it was not, which book and how many copies are left (to say so in words). A bool, like the one in the update and delete methods, cannot carry that. So the service returns a small class that holds the outcome. Create SellResult.cs in src/BookShop.Data/. The whole file is:

    namespace BookShop.Data;
    
    public class SellResult
    {
        public Book? Book { get; set; }
        public bool Sold { get; set; }
    }
    

    It is a plain class, like Book, with two properties. Book is the book, or null when there is no book with that id. Sold says whether the sale happened. When it did not, the book is still in the result with its stock unchanged, so the controller can read the title and the copies left from it. Two properties are enough for all three outcomes.

    Why a result and not an exception?

    Exceptions and stack traces gave a rule: throw when a method cannot do its job, not for an ordinary outcome. Is "not enough copies" ordinary? Yes. It will happen every week, and nothing is broken when it does: the shop is simply out of a book. An exception is built for the unexpected, and it travels up through every caller. A result says "here is what happened" and lets the caller decide. Other teams do use exceptions for business rules, and have a handler turn them into the 409. Others report it as a 400 with the same errors list as the validation rules of creating a book. Both are real styles, and both end in a standard Problem Details answer. We did not build them here. We use a result, because the outcome is expected and the flow stays visible in the code.

    The interface and the service

    Open src/BookShop.Data/IBookService.cs. The whole file is:

    namespace BookShop.Data;
    
    public interface IBookService
    {
        Task<List<Book>> GetAllAsync();
        Task<Book?> GetByIdAsync(int id);
        Task<Book> CreateAsync(Book book);
        Task<bool> UpdateAsync(int id, Book book);
        Task<bool> DeleteAsync(int id);
        Task<SellResult> SellAsync(int id, int quantity);                   // new
    }
    

    Open src/BookShop.Data/BookService.cs. The whole file is:

    using Microsoft.EntityFrameworkCore;
    
    namespace BookShop.Data;
    
    public class BookService : IBookService
    {
        private readonly BookShopDbContext _context;
    
        public BookService(BookShopDbContext context)
        {
            _context = context;
        }
    
        public async Task<List<Book>> GetAllAsync()
        {
            return await _context.Books.OrderBy(b => b.Title).ToListAsync();
        }
    
        public async Task<Book?> GetByIdAsync(int id)
        {
            return await _context.Books.FindAsync(id);
        }
    
        public async Task<Book> CreateAsync(Book book)
        {
            _context.Books.Add(book);
            await _context.SaveChangesAsync();
            return book;
        }
    
        public async Task<bool> UpdateAsync(int id, Book book)
        {
            var existing = await _context.Books.FindAsync(id);
            if (existing == null)
            {
                return false;
            }
    
            existing.Title = book.Title;
            existing.Author = book.Author;
            existing.Price = book.Price;
            existing.Stock = book.Stock;
            existing.Category = book.Category;
            await _context.SaveChangesAsync();
            return true;
        }
    
        public async Task<bool> DeleteAsync(int id)
        {
            var existing = await _context.Books.FindAsync(id);
            if (existing == null)
            {
                return false;
            }
    
            _context.Books.Remove(existing);
            await _context.SaveChangesAsync();
            return true;
        }
    
        public async Task<SellResult> SellAsync(int id, int quantity)       // new
        {                                                                   // new
            var book = await _context.Books.FindAsync(id);                  // new
            if (book == null)                                               // new
            {                                                               // new
                return new SellResult();                                    // new
            }                                                               // new
    
            if (book.Stock < quantity)                                      // new
            {                                                               // new
                return new SellResult { Book = book };                      // new
            }                                                               // new
    
            book.Stock -= quantity;                                         // new
            await _context.SaveChangesAsync();                              // new
            return new SellResult { Book = book, Sold = true };             // new
        }                                                                   // new
    }
    

    Read SellAsync from the top:

    • FindAsync(id) loads the book, or gives null. With no book, the result is empty: Book is null.
    • book.Stock < quantity is the rule. Selling exactly the copies left is allowed (3 of 3), and selling more is not. The result then holds the book with Sold left at false, so the controller can explain with its title and its stock.
    • book.Stock -= quantity takes the copies off. EF Core notices that Stock changed, and SaveChangesAsync() sends the UPDATE.
    • Three exits, three results. new SellResult() means no such book. { Book = book } means it was not sold. { Book = book, Sold = true } means it was. There is no exception anywhere.

    💡 One honest limit. If two customers ask for the last three copies at the very same moment, both can pass the check before either saves. A real shop closes that gap with a rule in the database itself, or a version number on the row, as the lost-update note in the update and delete lesson said. We leave it out.


    The endpoint

    Open src/BookShop.Api/Controllers/BooksController.cs. The whole file is:

    using System.ComponentModel.DataAnnotations;                            // new
    using BookShop.Data;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.AspNetCore.Mvc.ModelBinding;                            // new
    
    [ApiController]
    [Route("books")]
    public class BooksController : ControllerBase
    {
        private readonly IBookService _bookService;
    
        public BooksController(IBookService bookService)
        {
            _bookService = bookService;
        }
    
        [HttpGet]
        public async Task<ActionResult<List<Book>>> GetAll()
        {
            var books = await _bookService.GetAllAsync();
            return books;
        }
    
        [HttpGet("{id}")]
        [ProducesResponseType<Book>(StatusCodes.Status200OK)]
        [ProducesResponseType(StatusCodes.Status404NotFound)]
        public async Task<ActionResult<Book>> GetById(int id)
        {
            var book = await _bookService.GetByIdAsync(id);
            if (book == null)
            {
                return NotFound();
            }
            return book;
        }
    
        [HttpPost]
        [ProducesResponseType<Book>(StatusCodes.Status201Created)]
        [ProducesResponseType(StatusCodes.Status400BadRequest)]
        public async Task<ActionResult<Book>> Create(Book book)
        {
            var created = await _bookService.CreateAsync(book);
            return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
        }
    
        [HttpPut("{id}")]
        [ProducesResponseType(StatusCodes.Status204NoContent)]
        [ProducesResponseType(StatusCodes.Status400BadRequest)]
        [ProducesResponseType(StatusCodes.Status404NotFound)]
        public async Task<IActionResult> Update(int id, Book book)
        {
            var found = await _bookService.UpdateAsync(id, book);
            if (!found)
            {
                return NotFound();
            }
            return NoContent();
        }
    
        [HttpDelete("{id}")]
        [ProducesResponseType(StatusCodes.Status204NoContent)]
        [ProducesResponseType(StatusCodes.Status404NotFound)]
        public async Task<IActionResult> Delete(int id)
        {
            var found = await _bookService.DeleteAsync(id);
            if (!found)
            {
                return NotFound();
            }
            return NoContent();
        }
    
        [HttpPost("{id}/sell")]                                             // new
        [ProducesResponseType(StatusCodes.Status204NoContent)]              // new
        [ProducesResponseType(StatusCodes.Status400BadRequest)]             // new
        [ProducesResponseType(StatusCodes.Status404NotFound)]               // new
        [ProducesResponseType(StatusCodes.Status409Conflict)]               // new
        public async Task<IActionResult> Sell(int id, [BindRequired, Range(1, 1000)] int quantity)  // new
        {                                                                   // new
            var result = await _bookService.SellAsync(id, quantity);        // new
            if (result.Book == null)                                        // new
            {                                                               // new
                return NotFound();                                          // new
            }                                                               // new
            if (!result.Sold)                                               // new
            {                                                               // new
                return Problem(                                             // new
                    statusCode: StatusCodes.Status409Conflict,              // new
                    title: "Not enough copies in stock.",                   // new
                    detail: $"Copies left of {result.Book.Title}: {result.Book.Stock}. You asked for {quantity}.",  // new
                    type: "https://bookshop.example/errors/insufficient-stock");  // new
            }                                                               // new
            return NoContent();                                             // new
        }                                                                   // new
    }
    

    What is new:

    • [HttpPost("{id}/sell")] gives the address POST /books/3/sell. Why POST and not PUT? Selling is an action, not a replacement of the book, and it is not repeatable: sending it twice sells twice (the update and delete lesson called this "not idempotent"). POST is the verb for that.
    • int quantity is a plain number, and a plain number that is not in the address comes from the query string: ?quantity=2. (A whole class would come from the body, as when creating a book.)
    • [Range(1, 1000)] is the input rule of the first row of the table: at least one copy. [ApiController] checks it before our code runs, as before.
    • [BindRequired] says "this value must be in the request", and a request without it is turned away with a 400. The box below says why it is there.
    • if (result.Book == null) return NotFound(); is the second row, the same as when reading books.
    • Problem(...) is the third row, and the new tool. ControllerBase has this helper to build a Problem Details answer with the status, the words and the code we choose. The next section reads it line by line.
    • return NoContent(); is the happy path: the sale worked, and there is nothing to send back. (A real shop might return the new stock. We keep to the 204 of the update and delete lesson.)

    The four [ProducesResponseType] lines list every answer, as before.

    ⚠️ A trap we fell into. We first wrote only [Range], and then sent a request with no quantity at all. The answer was 204, and nothing had been sold. A missing number quietly becomes 0, and [Range] is not checked for a value that was never sent. That is why [BindRequired] is there.

    💡 The address has a verb in it, sell, and HTTP and REST concepts said to put the noun in the address and the verb in the method. Real APIs do this often for actions that are not a plain create, update or delete. A purer style treats the sale as a thing you create (POST /sales), which needs a table of sales, an orders lesson of its own. So we keep the action address here, and say so.


    Meaning at three levels

    Look at what the answer says, and who it is for. The 409 is built from these lines:

    return Problem(
        statusCode: StatusCodes.Status409Conflict,
        title: "Not enough copies in stock.",
        detail: $"Copies left of {result.Book.Title}: {result.Book.Stock}. You asked for {quantity}.",
        type: "https://bookshop.example/errors/insufficient-stock");
    
    Three levels of meaning in one error status: 409 Any client, and every tool the kind of "no": a clash with the current state title + detail A person what happened and why, in words type The frontend's code a stable name it can act on
    1. The status code (409) is for every client and every tool, whatever they know about the bookshop. It says what kind of answer this is. We chose 409 Conflict, which RFC 9110 defines as "a conflict with the current state of the target resource". That fits "the shelf holds fewer than you asked". Other teams choose 400 or 422 for business rules, and are not wrong. What matters is that your API picks one and keeps to it. (Read the section on 409 Conflict if you want the standard's own words.)
    2. The title and detail are for a person. The title is the same for every error of this kind. The detail is about this case: the book, the copies left, the number asked for. This is where "insufficient funds" becomes "you have 30 and asked for 50".
    3. The type is for the frontend's code. It is the field the errors lesson said teams can fill with their own address. A page that wants to show a "Restock this book" button when this error comes back should not search the sentence for the word "copies". Wording changes, and translations exist. A stable name does not change: if (problem.type === ".../insufficient-stock") always works. (The address does not have to open a page for the code to work. A real shop would put a page there that explains the error, and .example is a name reserved for examples, so it can never belong to anyone.)

    The standard also allows extra fields in an answer, for example a number copiesLeft, so the frontend need not read the sentence at all. We keep to type, title and detail to stay simple.

    That is the difference between a generic error and one with meaning. 404 Not Found tells you something is missing. The 409 tells you what cannot be done, why, and gives the program something firm to react to.


    Run it

    Start the app (dotnet run --project src/BookShop.Api, or the green button in Visual Studio). The table holds the three books from reading books, and Refactoring has 3 copies. First, ask for more than there are:

    curl -i -X POST "http://localhost:5101/books/3/sell?quantity=5"
    

    (The quotes matter: without them, a shell treats the ? as something else.)

    HTTP/1.1 409 Conflict
    Content-Type: application/problem+json; charset=utf-8
    
    {
      "type": "https://bookshop.example/errors/insufficient-stock",
      "title": "Not enough copies in stock.",
      "status": 409,
      "detail": "Copies left of Refactoring: 3. You asked for 5.",
      "traceId": "00-125f4e1b204327ea727917e4528cec88-2b912cb17f6ea080-00"
    }
    

    (The real body is one long line, shown here with line breaks.) The same Problem Details shape as every other error in the course, now with our own type and a detail that says something. The traceId was added for us. Now a sale that works:

    curl -i -X POST "http://localhost:5101/books/3/sell?quantity=2"
    
    HTTP/1.1 204 No Content
    

    Did the stock change?

    curl http://localhost:5101/books/3
    
    {"id":3,"title":"Refactoring","author":"Martin Fowler","price":44.0,"stock":1,"category":"Programming"}
    

    Stock went from 3 to 1. Now the other two kinds of "no". Invalid input, a quantity of zero:

    curl -i -X POST "http://localhost:5101/books/3/sell?quantity=0"
    
    HTTP/1.1 400 Bad Request
    Content-Type: application/problem+json; charset=utf-8
    
    {
      "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
      "title": "One or more validation errors occurred.",
      "status": 400,
      "errors": {
        "quantity": [
          "The field quantity must be between 1 and 1000."
        ]
      },
      "traceId": "00-690b78d86aabb1ee7a7ae53aa1989640-4c54c7219288f883-00"
    }
    

    No quantity at all (the trap, caught by [BindRequired]):

    curl -i -X POST "http://localhost:5101/books/3/sell"
    
    HTTP/1.1 400 Bad Request
    Content-Type: application/problem+json; charset=utf-8
    
    {
      "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
      "title": "One or more validation errors occurred.",
      "status": 400,
      "errors": {
        "quantity": [
          "A value for the 'quantity' parameter or property was not provided."
        ]
      },
      "traceId": "00-689485871cb095aa6fd42f96f1d52e71-579f83efa63a1c48-00"
    }
    

    And a book that does not exist:

    curl -i -X POST "http://localhost:5101/books/99/sell?quantity=1"
    
    HTTP/1.1 404 Not Found
    Content-Type: application/problem+json; charset=utf-8
    
    {
      "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
      "title": "Not Found",
      "status": 404,
      "traceId": "00-3e079a5e0fcc61b52baaeef0ff7a0334-34ada519f41e6e53-00"
    }
    

    Four failures, three kinds, one shape. The frontend developer reads the status, then the Problem Details, and never needs to know which kind it is unless they want to show something special. The /openapi/v1.json document lists the answers 204, 400, 404 and 409 for the endpoint.

    Put the stock back for later lessons. In DB Browser, run UPDATE Books SET Stock = 3 WHERE Title = 'Refactoring'; and press Write Changes.


    Test it

    The fake

    The controller's tests need the fake service to understand the new method. 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 List<Book>();
        public SellResult SellResult { get; set; } = new SellResult();      // new
    
        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)             // new
        {                                                                   // new
            return Task.FromResult(SellResult);                             // new
        }                                                                   // new
    }
    

    This fake does something new. For SellAsync it does not copy the rule. It returns whatever answer the test has put in SellResult, a canned answer. The controller test only needs to know that the controller turns each result into the right status. The rule itself is tested on the real service, where it lives. If the fake repeated the rule, a mistake would have to be fixed in two places, and the controller test would be testing the fake.

    The service

    Add four tests to tests/BookShop.Data.Tests/BookServiceTests.cs. They use the in-memory SQLite database of the books service, and ChangeTracker.Clear() from the update and delete lesson where a test must prove what was saved. The whole file is below. A // new line above a method marks the whole method:

    using Microsoft.Data.Sqlite;
    using Microsoft.EntityFrameworkCore;
    
    namespace BookShop.Data.Tests;
    
    [TestClass]
    public class BookServiceTests
    {
        private SqliteConnection _connection = null!;
        private BookShopDbContext _context = null!;
        private BookService _service = null!;
    
        [TestInitialize]
        public void Setup()
        {
            _connection = new SqliteConnection("Data Source=:memory:");
            _connection.Open();
    
            var options = new DbContextOptionsBuilder<BookShopDbContext>()
                .UseSqlite(_connection)
                .Options;
            _context = new BookShopDbContext(options);
            _context.Database.EnsureCreated();
    
            _service = new BookService(_context);
        }
    
        [TestCleanup]
        public void Cleanup()
        {
            _context.Dispose();
            _connection.Dispose();
        }
    
        [TestMethod]
        public async Task GetAllAsync_NoBooks_ReturnsEmptyList()
        {
            var books = await _service.GetAllAsync();
    
            Assert.IsEmpty(books);
        }
    
        [TestMethod]
        public async Task GetAllAsync_SomeBooks_ReturnsThemOrderedByTitle()
        {
            _context.Books.Add(new Book { Title = "Refactoring", Author = "Martin Fowler", Price = 44.00m });
            _context.Books.Add(new Book { Title = "Clean Code", Author = "Robert C. Martin", Price = 34.50m });
            await _context.SaveChangesAsync();
    
            var books = await _service.GetAllAsync();
    
            Assert.HasCount(2, books);
            Assert.AreEqual("Clean Code", books[0].Title);
            Assert.AreEqual("Refactoring", books[1].Title);
        }
    
        [TestMethod]
        public async Task GetByIdAsync_ExistingBook_ReturnsIt()
        {
            var saved = new Book { Title = "Clean Code", Author = "Robert C. Martin", Price = 34.50m };
            _context.Books.Add(saved);
            await _context.SaveChangesAsync();
    
            var book = await _service.GetByIdAsync(saved.Id);
    
            Assert.IsNotNull(book);
            Assert.AreEqual("Clean Code", book.Title);
        }
    
        [TestMethod]
        public async Task GetByIdAsync_UnknownId_ReturnsNull()
        {
            var book = await _service.GetByIdAsync(999);
    
            Assert.IsNull(book);
        }
    
        [TestMethod]
        public async Task CreateAsync_ValidBook_SavesItAndGivesItAnId()
        {
            var book = new Book { Title = "Clean Code", Author = "Robert C. Martin", Price = 34.50m };
    
            var created = await _service.CreateAsync(book);
    
            Assert.IsGreaterThan(0, created.Id);
            var found = await _service.GetByIdAsync(created.Id);
            Assert.IsNotNull(found);
            Assert.AreEqual("Clean Code", found.Title);
        }
    
        [TestMethod]
        public async Task CreateAsync_TwoBooks_GetDifferentIds()
        {
            var first = await _service.CreateAsync(new Book { Title = "Clean Code" });
            var second = await _service.CreateAsync(new Book { Title = "Refactoring" });
    
            Assert.AreNotEqual(first.Id, second.Id);
        }
    
        [TestMethod]
        public async Task UpdateAsync_ExistingBook_ReplacesItsValues()
        {
            var saved = new Book { Title = "Clean Code", Author = "Robert C. Martin", Price = 34.50m, Stock = 7, Category = "Programming" };
            _context.Books.Add(saved);
            await _context.SaveChangesAsync();
            var replacement = new Book { Title = "Clean Code, 2nd edition", Author = "Robert C. Martin", Price = 29.90m, Stock = 20, Category = "Software" };
    
            var found = await _service.UpdateAsync(saved.Id, replacement);
    
            Assert.IsTrue(found);
            _context.ChangeTracker.Clear();
            var book = await _service.GetByIdAsync(saved.Id);
            Assert.IsNotNull(book);
            Assert.AreEqual("Clean Code, 2nd edition", book.Title);
            Assert.AreEqual(29.90m, book.Price);
            Assert.AreEqual(20, book.Stock);
            Assert.AreEqual("Software", book.Category);
        }
    
        [TestMethod]
        public async Task UpdateAsync_BodyHasAnotherId_KeepsTheIdOfTheAddress()
        {
            var saved = new Book { Title = "Clean Code" };
            _context.Books.Add(saved);
            await _context.SaveChangesAsync();
    
            await _service.UpdateAsync(saved.Id, new Book { Id = 99, Title = "Clean Code, 2nd edition" });
    
            _context.ChangeTracker.Clear();
            Assert.IsNull(await _service.GetByIdAsync(99));
            var book = await _service.GetByIdAsync(saved.Id);
            Assert.IsNotNull(book);
            Assert.AreEqual("Clean Code, 2nd edition", book.Title);
        }
    
        [TestMethod]
        public async Task UpdateAsync_UnknownId_ReturnsFalse()
        {
            var found = await _service.UpdateAsync(999, new Book { Title = "Clean Code" });
    
            Assert.IsFalse(found);
        }
    
        [TestMethod]
        public async Task DeleteAsync_ExistingBook_RemovesIt()
        {
            var saved = new Book { Title = "Clean Code" };
            _context.Books.Add(saved);
            await _context.SaveChangesAsync();
    
            var found = await _service.DeleteAsync(saved.Id);
    
            Assert.IsTrue(found);
            _context.ChangeTracker.Clear();
            Assert.IsNull(await _service.GetByIdAsync(saved.Id));
        }
    
        [TestMethod]
        public async Task DeleteAsync_UnknownId_ReturnsFalse()
        {
            var found = await _service.DeleteAsync(999);
    
            Assert.IsFalse(found);
        }
    
        // new
        [TestMethod]
        public async Task SellAsync_EnoughCopies_ReducesTheStock()
        {
            var saved = new Book { Title = "Refactoring", Stock = 3 };
            _context.Books.Add(saved);
            await _context.SaveChangesAsync();
    
            var result = await _service.SellAsync(saved.Id, 2);
    
            Assert.IsTrue(result.Sold);
            _context.ChangeTracker.Clear();
            var book = await _service.GetByIdAsync(saved.Id);
            Assert.IsNotNull(book);
            Assert.AreEqual(1, book.Stock);
        }
    
        // new
        [TestMethod]
        public async Task SellAsync_ExactlyTheCopiesLeft_SellsThemAll()
        {
            var saved = new Book { Title = "Refactoring", Stock = 3 };
            _context.Books.Add(saved);
            await _context.SaveChangesAsync();
    
            var result = await _service.SellAsync(saved.Id, 3);
    
            Assert.IsTrue(result.Sold);
            Assert.IsNotNull(result.Book);
            Assert.AreEqual(0, result.Book.Stock);
        }
    
        // new
        [TestMethod]
        public async Task SellAsync_NotEnoughCopies_SellsNothingAndKeepsTheStock()
        {
            var saved = new Book { Title = "Refactoring", Stock = 3 };
            _context.Books.Add(saved);
            await _context.SaveChangesAsync();
    
            var result = await _service.SellAsync(saved.Id, 5);
    
            Assert.IsFalse(result.Sold);
            Assert.IsNotNull(result.Book);
            Assert.AreEqual("Refactoring", result.Book.Title);
            Assert.AreEqual(3, result.Book.Stock);
            _context.ChangeTracker.Clear();
            var book = await _service.GetByIdAsync(saved.Id);
            Assert.IsNotNull(book);
            Assert.AreEqual(3, book.Stock);
        }
    
        // new
        [TestMethod]
        public async Task SellAsync_UnknownId_ReturnsNoBook()
        {
            var result = await _service.SellAsync(999, 1);
    
            Assert.IsNull(result.Book);
            Assert.IsFalse(result.Sold);
        }
    }
    

    The four tests are the rule from every side: enough copies (the stock goes down, and the database agrees), exactly the copies that are left (the edge, which is the place where a < is easily written as <=), too many (nothing is sold, and the stock is unchanged), and a book that does not exist.

    The controller

    Add three tests to tests/BookShop.Api.Tests/BooksControllerTests.cs. The whole file is:

    using BookShop.Data;
    using Microsoft.AspNetCore.Mvc;
    
    namespace BookShop.Api.Tests;
    
    [TestClass]
    public class BooksControllerTests
    {
        private FakeBookService _fakeService = null!;
        private BooksController _controller = null!;
    
        [TestInitialize]
        public void Setup()
        {
            _fakeService = new FakeBookService();
            _controller = new BooksController(_fakeService);
        }
    
        [TestMethod]
        public async Task GetAll_TwoBooks_ReturnsBoth()
        {
            _fakeService.Books.Add(new Book { Id = 1, Title = "Clean Code" });
            _fakeService.Books.Add(new Book { Id = 2, Title = "Refactoring" });
    
            var result = await _controller.GetAll();
    
            Assert.IsNotNull(result.Value);
            Assert.HasCount(2, result.Value);
        }
    
        [TestMethod]
        public async Task GetById_ExistingBook_ReturnsIt()
        {
            _fakeService.Books.Add(new Book { Id = 7, Title = "Clean Code" });
    
            var result = await _controller.GetById(7);
    
            Assert.IsNotNull(result.Value);
            Assert.AreEqual("Clean Code", result.Value.Title);
        }
    
        [TestMethod]
        public async Task GetById_UnknownId_ReturnsNotFound()
        {
            var result = await _controller.GetById(999);
    
            Assert.IsInstanceOfType<NotFoundResult>(result.Result);
        }
    
        [TestMethod]
        public async Task Create_ValidBook_ReturnsCreatedPointingAtTheNewBook()
        {
            var result = await _controller.Create(new Book { Title = "Clean Code" });
    
            var created = result.Result as CreatedAtActionResult;
            Assert.IsNotNull(created);
            Assert.AreEqual("GetById", created.ActionName);
            Assert.AreEqual(1, created.RouteValues!["id"]);
            var book = created.Value as Book;
            Assert.IsNotNull(book);
            Assert.AreEqual("Clean Code", book.Title);
        }
    
        [TestMethod]
        public async Task Create_ValidBook_PassesItToTheService()
        {
            await _controller.Create(new Book { Title = "Clean Code" });
    
            Assert.HasCount(1, _fakeService.Books);
            Assert.AreEqual("Clean Code", _fakeService.Books[0].Title);
        }
    
        [TestMethod]
        public async Task Update_ExistingBook_ReturnsNoContent()
        {
            _fakeService.Books.Add(new Book { Id = 3, Title = "Clean Code" });
    
            var result = await _controller.Update(3, new Book { Title = "Clean Code, 2nd edition" });
    
            Assert.IsInstanceOfType<NoContentResult>(result);
        }
    
        [TestMethod]
        public async Task Update_ExistingBook_PassesTheNewValuesToTheService()
        {
            _fakeService.Books.Add(new Book { Id = 3, Title = "Clean Code" });
    
            await _controller.Update(3, new Book { Title = "Clean Code, 2nd edition" });
    
            Assert.AreEqual("Clean Code, 2nd edition", _fakeService.Books[0].Title);
        }
    
        [TestMethod]
        public async Task Update_UnknownId_ReturnsNotFound()
        {
            var result = await _controller.Update(999, new Book { Title = "Clean Code" });
    
            Assert.IsInstanceOfType<NotFoundResult>(result);
        }
    
        [TestMethod]
        public async Task Delete_ExistingBook_ReturnsNoContentAndRemovesIt()
        {
            _fakeService.Books.Add(new Book { Id = 3, Title = "Clean Code" });
    
            var result = await _controller.Delete(3);
    
            Assert.IsInstanceOfType<NoContentResult>(result);
            Assert.IsEmpty(_fakeService.Books);
        }
    
        [TestMethod]
        public async Task Delete_UnknownId_ReturnsNotFound()
        {
            var result = await _controller.Delete(999);
    
            Assert.IsInstanceOfType<NotFoundResult>(result);
        }
    
        // new
        [TestMethod]
        public async Task Sell_EnoughCopies_ReturnsNoContent()
        {
            _fakeService.SellResult = new SellResult { Book = new Book { Title = "Refactoring", Stock = 1 }, Sold = true };
    
            var result = await _controller.Sell(3, 2);
    
            Assert.IsInstanceOfType<NoContentResult>(result);
        }
    
        // new
        [TestMethod]
        public async Task Sell_NotEnoughCopies_ReturnsConflictWithAMeaningfulProblem()
        {
            _fakeService.SellResult = new SellResult { Book = new Book { Title = "Refactoring", Stock = 3 } };
    
            var result = await _controller.Sell(3, 5);
    
            var answer = result as ObjectResult;
            Assert.IsNotNull(answer);
            Assert.AreEqual(409, answer.StatusCode);
            var problem = answer.Value as ProblemDetails;
            Assert.IsNotNull(problem);
            Assert.AreEqual("https://bookshop.example/errors/insufficient-stock", problem.Type);
            Assert.Contains("Refactoring", problem.Detail!);
            Assert.Contains("3", problem.Detail!);
            Assert.Contains("5", problem.Detail!);
        }
    
        // new
        [TestMethod]
        public async Task Sell_UnknownBook_ReturnsNotFound()
        {
            _fakeService.SellResult = new SellResult();
    
            var result = await _controller.Sell(999, 1);
    
            Assert.IsInstanceOfType<NotFoundResult>(result);
        }
    }
    
    • _fakeService.SellResult = ... sets the canned answer for each test, then the test calls Sell and looks at the answer.
    • The 409 test is the interesting one. Problem(...) gives an ObjectResult, so the test reads its StatusCode and its Value, which is a ProblemDetails. It checks the status, the type (the code that the frontend depends on) and that the detail mentions the book and both numbers. If someone changes the type string by accident, a frontend would break without a warning, and this test catches it first.
    • The 400s are not here, for the reason given when creating a book: they happen in front of the controller method. We saw them by hand above.

    Run all tests from the solution folder:

    dotnet test
    
    Passed!  - Failed:     0, Passed:    16, Skipped:     0, Total:    16, Duration: 131 ms - BookShop.Api.Tests.dll (net10.0)
    Passed!  - Failed:     0, Passed:    19, Skipped:     0, Total:    19, Duration: 1 s - BookShop.Data.Tests.dll (net10.0)
    

    Thirty-five tests, two projects. (The order and the durations may differ.)

    In the test page

    The last step of the loop. Open /scalar and find POST /books/{id}/sell. It has an id box and a quantity box. Try the four requests of the section above and compare each answer with the curl output. The page should list the answers 204, 400, 404 and 409.


    Where we ended

    Compare with your solution. The rule is in the service, and the new class holds its result:

    BookShop/
      BookShop.slnx
      Directory.Build.props
      Directory.Packages.props
      src/
        BookShop.Api/
          Program.cs
          GlobalExceptionHandler.cs
          Controllers/
            BooksController.cs               plus Sell: 204, 400, 404 and 409
        BookShop.Data/
          Book.cs
          BookShopDbContext.cs
          IBookService.cs                    plus SellAsync
          BookService.cs                     plus SellAsync, the stock rule
          SellResult.cs                      what the rule decided
          Migrations/
      tests/
        BookShop.Data.Tests/
          BookServiceTests.cs                fifteen tests, four of them new
          BookValidationTests.cs
        BookShop.Api.Tests/
          FakeBookService.cs                 plus SellAsync with a canned answer
          FakeLogger.cs
          BooksControllerTests.cs            thirteen tests, three of them new
          GlobalExceptionHandlerTests.cs
    

    Recap

    • Three kinds of 4xx: invalid input (wrong with any data, 400), not found (404), and a business rule (the request is fine, but the current state forbids it).
    • A business rule is a rule of the shop, so it lives in the service, where every caller obeys it.
    • The service returns a small result class and does not throw, because "not enough stock" is an ordinary outcome. The controller turns the result into a status.
    • A business error is still Problem Details. It carries meaning at three levels: the status (the kind of "no", for any client), the title and detail (what and why, for a person) and the type (a stable name the frontend code can act on).
    • We chose 409 Conflict for a rule that depends on the current state. Other teams choose 400 or 422. Pick one and keep to it.
    • ControllerBase.Problem(...) builds the answer. Test the type, because a frontend may depend on it.
    • A number missing from the query string becomes 0, and [Range] does not check it. [BindRequired] makes a missing value a 400.

    Questions to think about

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

    1. What is the difference between invalid input, a missing thing and a business rule? Give a bookshop example of each.
    2. Why does the service return a result, and not throw an exception, when there are not enough copies?
    3. Why is the type field more useful to a frontend than the detail sentence?
    4. Why does the controller choose the status code, and not the service?
    5. The stock report needs an answer that is not a book and not an error: a summary of many books. What class would hold it? (A later lesson answers this.)

    Next lesson: the stock report. The first endpoint whose answer is not a row of the table, so the API needs a class made for the answer.

    ← Update and Delete: PUT and DELETEThe Stock Report →