Tutorials / Build a REST API with C# and .NET · Lesson 18 of 19
On this page
The Books Service
The table exists, and it is empty. The next job is to read books from it. The first thought is to write the query inside the controller, because that is where the request arrives. This lesson shows why we do not, and builds the better place.
Our goal: put the code that reads books into a class of its own, a service, register it with dependency injection, and test it. The API itself does not change yet. Nothing new appears in the browser. The controller starts using the service in lesson 18.
Here is the route:
- See why the query does not go in the controller, and what a service is.
- Write an interface,
IBookService, that says what the service can do. - Write the class,
BookService, that does it with EF Core. - Register it with DI, and see why it must be scoped.
- Create a test project and write the first tests, using a small database that lives in memory.
This lesson leaves out using the service from the controller (lesson 18), saving, changing and deleting books (lessons 20 and 21), and the extra "repository" layer that some projects put on top of EF Core. We say why at the end of the first section.
The map
The solution map from lesson 14 again. This lesson adds the first test project, BookShop.Data.Tests, marked NEW. No other project is added.
Where we are
This is the solution from lesson 16. We touch these files:
BookShop/
BookShop.slnx <- changes (a new project)
Directory.Build.props
Directory.Packages.props <- changes (one package version)
src/
BookShop.Api/
Program.cs <- changes (one line registers the service)
Controllers/
BooksController.cs
BookShop.Data/
Book.cs
BookShopDbContext.cs
IBookService.cs <- NEW
BookService.cs <- NEW
Migrations/
tests/ <- NEW folder
BookShop.Data.Tests/ <- NEW project
BookShop.Data.Tests.csproj <- NEW
MSTestSettings.cs <- NEW (made by the template)
BookServiceTests.cs <- NEW
Run every command from the solution folder, BookShop/.
Why a service
Imagine the shortest way to list the books. The controller asks EF Core for them, and returns the answer:
[HttpGet]
public async Task<List<Book>> GetAll()
{
return await _context.Books.OrderBy(b => b.Title).ToListAsync(); // the query, in the controller
}
It works. For one endpoint, it is even the least code. So why not stop here?
The controller has a different job
A controller speaks HTTP. It reads the request, decides which status code to send (200, 404, 400), and shapes the answer. It should not also know how books are found. The two jobs change for different reasons. A new rule about which books to show does not touch HTTP. A new status code does not touch the query.
Software outlives its first plan
Once the shop works, the owner asks for more:
- "Do not show books that are out of stock on the shop page." That is a rule about books. If it lives in the controller, the nightly stock job (lesson 16) has to copy it, and now there are two versions to keep in step.
- "The rule must not break." To test a rule inside a controller, you have to build a request around it. A rule in a plain class is tested by calling a method.
So we put the work with books in one class that knows nothing about HTTP: the Books service. The controller calls it and turns the result into an HTTP answer.
The dashed box is a guess about the future, as in lesson 16. The controller on the left holds three jobs. On the right, each class has one.
This is not over-engineering
The cost is small now: one interface and one class. A rule moved out of a grown controller later is expensive, because by then the query is tangled with the HTTP code.
We stop at one service for books. Some projects add one more layer under it, a repository class whose only job is to wrap EF Core. We skip it on purpose. BookShopDbContext already does that job: a DbSet<Book> is a repository, and EF Core is the thing that can be swapped (lesson 34). A wrapper around a wrapper is code the reader must learn and maintain for no gain today. Teams that use repositories have their reasons, and you will meet them. This course does not need one.
The interface
In lesson 03 an interface was a list of what a class can do, with no code inside. Whoever needs the work asks for the list, not for a particular class. The service gets one, in the Data project.
Create IBookService.cs in src/BookShop.Data/. The whole file is:
namespace BookShop.Data;
public interface IBookService
{
Task<List<Book>> GetAllAsync();
Task<Book?> GetByIdAsync(int id);
}
Read it line by line:
GetAllAsyncreturns every book. The result isTask<List<Book>>: a list of books that arrives later, because reading from a database takes time (lesson 09). The name ends inAsyncby convention, so the caller sees it must be awaited.GetByIdAsyncreturns one book. The?afterBookmeans "there may be no book". If the id does not exist, the answer isnull(lesson 14 turned onNullable, so the compiler makes the caller deal with it).- The service does not say "404". It only says "no such book". What that means for the client is HTTP, and HTTP is the controller's job. This is the split from the first section, in two lines.
Why bother with an interface when one class will implement it? In lesson 18 the controller will ask for IBookService, not for BookService. That is what lets a test hand the controller a fake one. The cost is five lines, and the lesson 03 pattern already pays for it.
The service
Create BookService.cs in src/BookShop.Data/. 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);
}
}
: IBookServicesays this class keeps the promise in the interface. The compiler checks that both methods exist.- The constructor asks for a
BookShopDbContextand keeps it in a field. Nobody here creates the context. DI hands it in, exactly the pattern of lesson 03 and the constructor of the context itself in lesson 16. _context.Booksis theDbSet<Book>from lesson 16, theBookstable.OrderBy(b => b.Title)sorts by title. The part after=>is a small function: "for each bookb, use itsTitle". EF Core turns it intoORDER BY Titlein SQL. A list that comes back in a steady order is kinder to the people reading it. It also gives the tests something real to check.ToListAsync()is the moment EF Core really goes to the database. Until then,_context.Books.OrderBy(...)is only a description of the query.awaitwaits for the answer without blocking the server (lesson 09).FindAsync(id)looks a book up by its primary key. It gives backnullif there is none, which matches theBook?in the interface.
Build to check:
dotnet build
Build succeeded.
0 Warning(s)
0 Error(s)
Register it with DI
A class that nobody hands to anyone does nothing. Tell DI that when somebody asks for IBookService, it should create a BookService. Open src/BookShop.Api/Program.cs. The whole file is:
using BookShop.Data;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
var connectionString = builder.Configuration.GetConnectionString("BookShop");
builder.Services.AddDbContext<BookShopDbContext>(options => options.UseSqlite(connectionString));
builder.Services.AddScoped<IBookService, BookService>(); // new
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
AddScoped<IBookService, BookService>() reads: "when something asks for IBookService, give it a BookService". Only this line knows which class is behind the interface. To change it, you change here and nowhere else (lesson 03).
Why scoped
Lesson 07 has three lifetimes. Which fits? Look at what the service holds: a BookShopDbContext. And AddDbContext (lesson 16) registered that context as scoped: one new context for each web request, thrown away when the request ends. That is by design. A context remembers what it has loaded, and it is not safe to use from two requests at the same moment.
So the service that holds it must not live longer than a request:
The rule of thumb: a service that uses a scoped object must be scoped too (or transient, never singleton). Transient would also work, because a new service per request is cheap. Scoped is the closer match: the service and the context have the same life.
See the mistake
You do not have to take this on trust. Change the line to AddSingleton, and run the app (dotnet run --project src/BookShop.Api, or the green button in Visual Studio):
builder.Services.AddSingleton<IBookService, BookService>();
Unhandled exception. System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: BookShop.Data.IBookService Lifetime: Singleton ImplementationType: BookShop.Data.BookService': Cannot consume scoped service 'BookShop.Data.BookShopDbContext' from singleton 'BookShop.Data.IBookService'.)
(Only the first line is shown. The real output continues with a long stack trace.) The framework refuses to start and names both services and both lifetimes. It checks this on your machine because the template starts the app as Development. In a live environment that check is off by default, so the same mistake would start quietly and fail later, under load. That is the reason to learn the rule and not to rely on the error.
Change the line back to AddScoped.
How to test a service that needs a database
The service is written. Does it work? Lesson 11 says: test one class, think like a QA, and keep tests fast and independent. But this class needs a database, and a test must not depend on the real bookshop.db, whose content changes every time someone runs the app.
(This is the question at the end of lesson 11: how do you test code that reads a database, without a real one? This lesson and the next give two answers.)
The idea is the same in both: stand something in for the real thing, so the test controls it. There are two ways here.
A: a hand-written fake of the database. BookShopDbContext is a large EF Core class, not a small interface. Faking it means writing our own pretend DbSet, which then has to behave like EF Core. Worse, our OrderBy would run on a list in C#, not as SQL, so the test would prove nothing about the real query. Fakes are the right tool when the thing you stand in for is small, like IBookService. Lesson 18 uses one.
B: a real SQLite database that lives only in memory. SQLite can keep a whole database in memory (the connection string Data Source=:memory:). It is a real database engine, so EF Core really turns our OrderBy into SQL and SQLite really runs it. It is fast, needs no file and disappears when the test ends.
We use B for the service. Three honest notes:
- It is a stand-in, not the real thing. SQLite and SQL Server behave differently in places. For example, SQL Server compares text without caring about upper and lower case by default, and SQLite does care. A test can pass on one and fail on the other. Microsoft's EF Core documentation says this too, and says that the most faithful test runs against the same database system as production. That means a SQL Server on your machine or in a container. It is more setup, so this course stays with SQLite. Lesson 33 moves the app itself to a real database server.
- The other choices are real. Some teams put a repository class between the service and EF Core, so that tests can fake the repository (the extra layer we skipped above). Others test only against the real database. EF Core also has a separate "InMemory" provider, but its documentation discourages it for tests: it is not a relational database, so more queries behave differently.
- These tests are a little bigger than the purest unit tests, because they touch a real (tiny) database. For a class whose whole job is a query, that is the right trade.
The test project
Lesson 11 put tests in a tests/ folder, one project for each project under test, named <Project>.Tests. Create the first, for the Data project:
dotnet new mstest -n BookShop.Data.Tests -o tests/BookShop.Data.Tests --no-restore
dotnet add tests/BookShop.Data.Tests reference src/BookShop.Data
dotnet sln add tests/BookShop.Data.Tests
--no-restore skips the package download that the template starts on its own. We need to fix its project file first, as the next paragraphs show. Delete the sample Test1.cs the template made. Leave MSTestSettings.cs: it is the template's own small settings file, which lets tests run side by side. That is safe here because each of our tests makes its own database.
Visual Studio: right-click the solution, choose Add, then New Project, and pick MSTest Test Project. Name it BookShop.Data.Tests and set the location to a tests folder. Then right-click Dependencies on the new project, choose Add Project Reference and tick BookShop.Data. Delete Test1.cs. Both routes give the same result.
Fix the project file
The template writes a project file as if the solution had no shared settings. Open tests/BookShop.Data.Tests/BookShop.Data.Tests.csproj. It looks like this:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" Version="4.0.1" />
</ItemGroup>
<ItemGroup>
<Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\BookShop.Data\BookShop.Data.csproj" />
</ItemGroup>
</Project>
If you ran dotnet restore now, it would stop with error NU1008: The following PackageReference items cannot define a value for Version: MSTest. That is lesson 14 at work. Versions live in Directory.Packages.props, and a project file may not repeat them. The three settings in the first group are already given by Directory.Build.props. Remove the PropertyGroup and the version, so the whole file is:
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\BookShop.Data\BookShop.Data.csproj" />
</ItemGroup>
</Project>
The Using line is a small gift from the template: it adds using Microsoft.VisualStudio.TestTools.UnitTesting; to every file, so a test file does not need it.
Now add the version to Directory.Packages.props. Use the number the template wrote (yours may be newer). The whole file is:
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.13">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.13" />
<PackageVersion Include="MSTest" Version="4.0.1" /> <!-- new -->
<PackageVersion Include="Scalar.AspNetCore" Version="2.18.2" />
</ItemGroup>
</Project>
The test project needs no EF Core package of its own. It references the Data project, and the packages that project uses (EF Core and the SQLite provider) come along.
The tests
Create BookServiceTests.cs in tests/BookShop.Data.Tests/. The whole file is:
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);
}
}
The setup
The top half builds the stand-in database. In order:
[TestInitialize]marks a method that MSTest runs before every test.[TestCleanup]runs after every test. MSTest also makes a new object of the test class for each test, so the three fields start fresh every time, and no test can leave books behind for the next one. That is the "independent" rule from lesson 11.new SqliteConnection("Data Source=:memory:")opens a SQLite database that lives in memory. We open the connection ourselves and keep it. An in-memory database exists only as long as its connection is open, so closing it would throw the tables away.Cleanupcloses it at the end, which is the whole of "clean up".UseSqlite(_connection)is the same line asProgram.cs, except it gets our open connection and not a file name. This is what the constructor of the context (lesson 16) was designed for: it takes its options from outside.EnsureCreated()builds the tables straight from the classes. The app creates its tables from migrations (lesson 16). A test does not need the history, only the tables, so this is quicker.new BookService(_context)is lesson 03 with no DI container: the test is the one who hands the service its context.
The = null! on the fields tells the compiler "I know these start empty, and Setup fills them before any test uses them". Without it Nullable would complain.
The tests
Each test follows Arrange, Act, Assert from lesson 11, with a blank line between the steps, and a name in the Method_Input_ExpectedResult style.
- Tests that read books first save some with
_context.Books.Add(...)andSaveChangesAsync(), which is the same as anINSERT. GetAllAsync_SomeBooks_ReturnsThemOrderedByTitlesaves "Refactoring" before "Clean Code" on purpose. If the service did not sort, "Refactoring" would come out first.GetByIdAsync_UnknownId_ReturnsNullis the QA thought from lesson 11: what happens when the book is not there? The service saysnull.- The tests are
async Taskbecause the service is async (lesson 09). MSTest waits for them.
Run them
From the solution folder:
dotnet test
Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4, Duration: 1 s - BookShop.Data.Tests.dll (net10.0)
Your duration will differ. In Visual Studio, open Test Explorer from the Test menu and run all tests, as in lesson 11.
Break it on purpose
Passing tests are only useful if they can fail. In BookService.cs, remove the sorting for a moment:
return await _context.Books.ToListAsync();
Run dotnet test again:
Assert.AreEqual failed. Expected string length 10 but was 11. 'expected' expression: '"Clean Code"', 'actual' expression: 'books[0].Title'.
Expected: "Clean Code"
But was: "Refactoring"
Failed! - Failed: 1, Passed: 3, Skipped: 0, Total: 4, Duration: 944 ms - BookShop.Data.Tests.dll (net10.0)
(Only the useful lines are shown. A stack trace follows the first message.) The test name told you which promise broke, and the message told you how. Put OrderBy(b => b.Title) back and run the tests once more: four passes.
Where we ended
Compare with your solution. The Data project got the interface and the service. The API got one line. The first test project exists:
BookShop/
BookShop.slnx the test project is listed
Directory.Build.props
Directory.Packages.props MSTest version added
src/
BookShop.Api/
Program.cs registers IBookService as scoped
Controllers/
BooksController.cs unchanged (still the typed-in books)
BookShop.Data/
Book.cs
BookShopDbContext.cs
IBookService.cs what the service can do
BookService.cs does it with EF Core
Migrations/
tests/
BookShop.Data.Tests/
BookShop.Data.Tests.csproj references BookShop.Data
MSTestSettings.cs template settings, left as made
BookServiceTests.cs four tests on an in-memory SQLite database
Recap
- A service holds the work with one kind of thing (here, books). The controller handles HTTP, and the service knows nothing about it. Another program, such as a nightly job, can use the same service.
- The interface
IBookServicelists what the service can do. The controller will ask for the interface, so a test can hand it a fake. GetByIdAsyncreturnsnullfor an unknown book. Turning that into a 404 is the controller's job.AddScoped<IBookService, BookService>()registers it. A service that holds the scopedDbContextmust be scoped or transient, never singleton. InDevelopmentthe framework stops the app if you get it wrong.- To test code that needs a database, stand something in for it. A fake suits a small interface. For a class that is a query, an in-memory SQLite database runs the real query and is thrown away after each test. It is a stand-in, so it can differ from SQL Server in details.
[TestInitialize]and[TestCleanup]run before and after every test, so each test starts clean.- The template's test project file needs two fixes in a solution with shared settings: no version on the package, and no repeated settings.
Questions to think about
These have no answers here, on purpose. Try them from memory first.
- Why is the query not written in the controller? What is each of the two classes good at?
- Why is the service registered as scoped, and not as a singleton?
GetByIdAsyncreturnsnullfor an unknown id and does not say "404". Why?- Why test
BookServiceagainst an in-memory SQLite database, and not against a hand-written fake of the context? - The service is finished and tested, but the API still shows the three typed-in books. What is the one thing that connects them? (Lesson 18 answers this.)
Next lesson: read books. The controller asks for the service, GET /books returns the real table, and GET /books/{id} answers 200 or 404.