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:

    1. See why the query does not go in the controller, and what a service is.
    2. Write an interface, IBookService, that says what the service can do.
    3. Write the class, BookService, that does it with EF Core.
    4. Register it with DI, and see why it must be scoped.
    5. 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.

    The projects of the BookShop solution src/ tests/ Api lesson 14 Data lesson 16 Api.Tests lesson 18 Api. IntegrationTests lesson 33, planned Data.Tests lesson 17 NEW wwwroot: the screens uses Every name starts with BookShop. An arrow means "uses". Dashed: not built yet, or still only a plan.

    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.

    Controller alone, and controller with a service Without a service Controller HTTP + rules + the query Database With a service Controller Service Database Nightly job The rules live in one place.

    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:

    • GetAllAsync returns every book. The result is Task<List<Book>>: a list of books that arrives later, because reading from a database takes time (lesson 09). The name ends in Async by convention, so the caller sees it must be awaited.
    • GetByIdAsync returns one book. The ? after Book means "there may be no book". If the id does not exist, the answer is null (lesson 14 turned on Nullable, 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);
        }
    }
    
    • : IBookService says this class keeps the promise in the interface. The compiler checks that both methods exist.
    • The constructor asks for a BookShopDbContext and 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.Books is the DbSet<Book> from lesson 16, the Books table.
    • OrderBy(b => b.Title) sorts by title. The part after => is a small function: "for each book b, use its Title". EF Core turns it into ORDER BY Title in 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. await waits for the answer without blocking the server (lesson 09).
    • FindAsync(id) looks a book up by its primary key. It gives back null if there is none, which matches the Book? 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:

    One request, one DbContext, one service Request 1 Service DbContext its own pair, gone at the end Request 2 Service DbContext a different pair A singleton service would hold one DbContext for ever

    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.

    Two ways to stand in for the database A: a fake DbContext Service our own pretend The query never runs. We test our pretend. B: SQLite, in memory Service real SQLite The real query runs. The database is a throwaway.

    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. Cleanup closes it at the end, which is the whole of "clean up".
    • UseSqlite(_connection) is the same line as Program.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(...) and SaveChangesAsync(), which is the same as an INSERT.
    • GetAllAsync_SomeBooks_ReturnsThemOrderedByTitle saves "Refactoring" before "Clean Code" on purpose. If the service did not sort, "Refactoring" would come out first.
    • GetByIdAsync_UnknownId_ReturnsNull is the QA thought from lesson 11: what happens when the book is not there? The service says null.
    • The tests are async Task because 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 IBookService lists what the service can do. The controller will ask for the interface, so a test can hand it a fake.
    • GetByIdAsync returns null for an unknown book. Turning that into a 404 is the controller's job.
    • AddScoped<IBookService, BookService>() registers it. A service that holds the scoped DbContext must be scoped or transient, never singleton. In Development the 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.

    1. Why is the query not written in the controller? What is each of the two classes good at?
    2. Why is the service registered as scoped, and not as a singleton?
    3. GetByIdAsync returns null for an unknown id and does not say "404". Why?
    4. Why test BookService against an in-memory SQLite database, and not against a hand-written fake of the context?
    5. 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.

    ← A Data Project and the First Table with EF CoreRead Books: GET All and GET One →