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:
- Tell apart three kinds of "no": invalid input, not found, and a business rule.
- Write the rule in the service, and see why it returns a small result and not an exception.
- Add the endpoint, and let the controller turn the result into an answer.
- Give the answer meaning at three levels: the status, the words, and a code the frontend can act on.
- 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.
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 givesnull. With no book, the result is empty:Bookisnull.book.Stock < quantityis the rule. Selling exactly the copies left is allowed (3 of 3), and selling more is not. The result then holds the book withSoldleft atfalse, so the controller can explain with its title and its stock.book.Stock -= quantitytakes the copies off. EF Core notices thatStockchanged, andSaveChangesAsync()sends theUPDATE.- 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 addressPOST /books/3/sell. WhyPOSTand notPUT? 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").POSTis the verb for that.int quantityis 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 a400. 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.ControllerBasehas 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 was204, and nothing had been sold. A missing number quietly becomes0, 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");
- 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
400or422for 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.) - The
titleanddetailare 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". - The
typeis 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.exampleis 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 callsSelland looks at the answer.- The
409test is the interesting one.Problem(...)gives anObjectResult, so the test reads itsStatusCodeand itsValue, which is aProblemDetails. It checks the status, thetype(the code that the frontend depends on) and that thedetailmentions the book and both numbers. If someone changes thetypestring 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
titleanddetail(what and why, for a person) and thetype(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 thetype, 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 a400.
Questions to think about
These have no answers here, on purpose. Try them from memory first.
- What is the difference between invalid input, a missing thing and a business rule? Give a bookshop example of each.
- Why does the service return a result, and not throw an exception, when there are not enough copies?
- Why is the
typefield more useful to a frontend than thedetailsentence? - Why does the controller choose the status code, and not the service?
- 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.