Splitting Bookstore into Microservices: Target Architecture, Service List and the Rules of This Series
We split Bookstore into five services: Catalog, Ordering, Inventory, Payments and Notifications; each owns its database, identities between services are ISBNs and Guids, and shared code is limited to contracts and ServiceDefaults. The post shows the target architecture, which edges are sync and which async, the reasons behind the Aspire, YARP, RabbitMQ, WolverineFx and Keycloak decisions, and the architecture test that guards the rule in CI, with real output.
Version note: The code in this post was built and run with .NET 10 and xUnit.net v3 4.0; the other versions used across the series (Aspire 13.6, RabbitMQ 4.3, WolverineFx 6, Keycloak 26.6, SQL Server 2025) are in the decisions table.
Guess First
Bookstore, the online bookstore used as the shared example across this site's series, is a single ASP.NET Core application today. The OrderService that places orders reads book prices with this line:
IReadOnlyDictionary<int, BookInfo> books =
await catalog.GetByIdsAsync([.. cart.Items.Select(i => i.BookId)], ct);
catalog is the interface of the Catalog module in the same process; the call is a method call and the data comes from the same database. When we split Ordering and Catalog into two services, what should this line become? There are three options: Ordering asks Catalog over HTTP on every order; we give Ordering the connection string of Catalog's database as well; or Ordering keeps the part of the book data it needs in its own database. And a second question: BookId is an int, the identity column in Catalog's database. Once the services are separated, can we keep identifying a book by that number? Write your guess down; the answer is in the communication map.
What This Series Does and Where It Starts
In the Microservices series we discussed service boundaries, data ownership, communication styles, the gateway, security and the way out of a monolith as concepts. This series turns the same decisions into working code on Bookstore: the Catalog, Ordering, Inventory, Payments and Notifications services, sync communication with gRPC and async communication with RabbitMQ, the outbox and the checkout saga, a BFF built with YARP, Keycloak, Angular, OpenTelemetry, tests and Kubernetes. In the last post we will audit the system we built against the symptoms of a distributed monolith and ask "what would we merge again".
The series doesn't re-explain the reasoning behind a concept. Why a saga is needed lives in the Messaging and Event-Driven Architecture series, YARP's details in the API Gateway and YARP series, resilience pipelines in the External Service Integration and Resilience series, Dockerfile basics in the Docker series. Each post names the relevant post and shows only how it came together in Bookstore. Before reading on, I recommend the Microservices series posts on service boundaries, data ownership, communication styles and the API gateway in particular.
In Bookstore's reference application, Catalog, Ordering and Inventory are modules: each has its own folder, its own public interface (ICatalogService, IInventoryService) and its own tables. In the Modular Monolith series we tightened that separation with schema per module, architecture tests and an in-memory event bus. The "Extracting a Module into a Service" post of the same series covered the valid reasons to split a module out and a four-step extraction path. This series starts at the end of that path: module boundaries are clear, data is already separated per module, and now we make each one a separate process, a separate database and a separate deployment unit.
In real life you don't do this transformation in one go; as in the Strangler Fig post of the Microservices series, you start with the easiest and least risky module and shrink the monolith a little at each step. In this series we build all services side by side for teaching purposes, but we picked the order with the same logic: Catalog, whose data ownership is the clearest, first; the checkout flow, with the most dependencies, last.
Service List and Data Ownership
Every service has an owner, a database and a contract it exposes. The team column comes from the four stream-aligned teams we planned in the Conway's Law post of the Introduction to Architecture series.
| Service | Project | Data it owns | Data store | Events it publishes (plan) | Team |
|---|---|---|---|---|---|
| Catalog | Bookstore.Catalog.Api | Books, authors, categories, prices | catalogdb (SQL Server) | BookPriceChanged, BookRenamed | Catalog & Search |
| Ordering | Bookstore.Ordering.Api | Orders, order lines, a local copy of books, checkout saga state | orderingdb (SQL Server) | OrderPlaced, OrderConfirmed, OrderCancelled | Ordering & Payment |
| Inventory | Bookstore.Inventory.Grpc | Stock, reservations | inventorydb (SQL Server) | StockReserved, OutOfStock | Fulfillment |
| Payments | Bookstore.Payments.Api | Payment attempts, provider references | paymentsdb (SQL Server) | PaymentSucceeded, PaymentFailed | Ordering & Payment |
| Notifications | Bookstore.Notifications.Api | No persistent data | Redis (connections and duplicate checks) | None | Customer Experience |
Two rules hide in the table. First, only the owner gets a database's connection string. Locally the four databases live in a single SQL Server container, because running four SQL Servers on a laptop buys nothing; but each service sees only its own database. In production the separation can be physical or at the level of users and permissions; of the "private tables, schema or database" options in the Data Ownership post of the Microservices series we pick the strongest. Second, the event names are a plan for now. The places where we actually write the contracts are post 03 (Catalog) and post 06 (integration events); if a name changes, it changes there.
What We Deliberately Don't Turn into a Service
Bookstore has six modules but five services. Shipping stays as a separate module inside the Fulfillment team's Inventory service. The reason is simple: shipping labels and tracking numbers are the same team's work as stock, they change at the same time, and none of the measurable reasons for splitting we listed in the Modular Monolith series (different scaling, a different team, a different release pace) applies to Shipping. "Every module should be a service" is the entity service anti-pattern from the Microservices series one level up. We don't write Shipping's code in this series; the message is that the number of services is a cost, not a goal.
Identity isn't a separate service either. Keycloak takes over users, roles and sessions; in post 10 the BFF becomes an OIDC client and the other services become resource servers that validate tokens. Institutional orders are outside this series' scope; adding one more service to the same architecture is mechanical work once the first five are built properly.
The Communication Map: Which Edges Are Sync, Which Are Async?
All edges of the target architecture are in the figure below. Solid arrows are calls whose answer is awaited, dashed arrows are messages that travel through RabbitMQ.

- Angular talks only to
Bookstore.Bff. The BFF forwards catalog and order requests to Catalog and Ordering over REST, and for the book detail page it calls Catalog and Inventory in parallel and builds one response (09). - Ordering calls Inventory over gRPC to reserve stock while placing an order (05). We build this synchronously on purpose and measure what happens to checkout when Inventory slows down; in post 07 the reservation becomes a message-driven step of the checkout saga. In the target architecture gRPC stays for stock queries.
- Catalog publishes price and title changes as events; Ordering uses them to update its own copy of the books (06).
- Ordering owns the checkout saga. It exchanges commands and events with Inventory and Payments and runs compensation on the failure path (07). Payments calls the external payment provider over HTTP and receives the result via a webhook (08).
- Notifications calls no service; it listens to events, pushes to the user with SignalR and sends emails (11).
Now the answer to the question from Guess First. Ordering keeps the part of the book data it needs (ISBN, title, price) in its own database and updates that copy with Catalog's events. Asking Catalog on every order makes Catalog a precondition for checkout: while Catalog is being deployed or is slow, orders stop too. Giving Ordering a second connection string to Catalog's database means joining back what we separated; when Catalog renames a column, Ordering breaks in production. The price of the copy is eventual consistency: a price can be a few seconds old. That's why the price written to the order is the price the customer saw in the cart; in post 04 we freeze it at order time.
The answer to the second question is also no. BookId is an internal detail of Catalog: in another environment, after a data migration or when a table is rebuilt, the same book can get a different number. Between services, a book's identity is its ISBN-13. An order's identity isn't a number generated by the database either, but a Guid generated by Ordering (version 7, ordered by time); that way an order can travel to Inventory and Payments with the same identity before it is even written to the database. No service's identity column goes into a contract.
Technical Decisions and Their Alternatives
Each row is a candidate for an ADR. Here we write only the decision and a one-sentence reason; the details are in the relevant post.
| Topic | Choice | Alternative | Why this choice? |
|---|---|---|---|
| Local environment | Aspire 13.6 AppHost | Docker Compose | Services, containers, connections and the dashboard from one C# file; service discovery and OpenTelemetry work without extra setup (02). |
| External API | REST + OpenAPI | GraphQL | A single client (Angular, behind the BFF) and simple read needs; GraphQL's flexibility doesn't pay for its cost here. |
| Sync calls between services | gRPC | REST | A strict contract with .proto files, HTTP/2 and deadline propagation (05). |
| Async communication | RabbitMQ 4.3 + WolverineFx 6 | Azure Service Bus, Kafka; MassTransit | A broker that runs locally as a container; a messaging layer that provides outbox, inbox and sagas in one MIT-licensed library (06, 07). |
| Gateway and BFF | YARP 2.3 inside our own project | Nginx, a managed API gateway | The BFF holds session, token and aggregation code; that belongs in code, not in a config file (09). |
| Identity | Keycloak 26.6 | Microsoft Entra ID, Duende IdentityServer, OpenIddict | A container locally and standard OIDC; the client side doesn't change when moving to the cloud (10). |
| Observability | OpenTelemetry + Aspire dashboard | Vendor SDKs | Vendor-neutral; the same telemetry goes to a different backend in production (12). |
| Database | SQL Server 2025, a database per service | PostgreSQL | Bookstore's common rule; the database engine isn't the topic of this series. |
| Deployment | Kubernetes | Azure Container Apps, App Service | Built on the platform from the Kubernetes series; Aspire's Kubernetes publishing is still in preview (14). |
The choice of messaging library deserves a paragraph, because the published Working with Message Queues series used MassTransit. MassTransit v9 shipped in January 2026 under a commercial license; v8 remains open source, but according to its owner's announcement its official maintenance ends at the end of 2026. For a reference application that should still be alive in 2027, that means either a license cost or an unmaintained version. Wolverine is MIT-licensed and offers a RabbitMQ transport, a transactional outbox with EF Core and persisted sagas in the same package. When the series was planned, version 5.x was the target; today the latest stable version on NuGet is 6.64, 6.x supports .NET 10, and we will use 6.x. The messaging mechanisms themselves (outbox, idempotent consumer, retry) don't depend on the library; what the Working with Message Queues series explained still applies.
Repository Layout: A Mono-repo and Central Package Management
All services live in a single Git repository. A mono-repo doesn't couple services; shared deployments, a shared database and shared business code do. A single repo gives us atomic commits, one search and one CI definition; we will get independent deployments in post 14 with path filters and a separate pipeline per service. We set up the skeleton with these commands:
mkdir bookstore
cd bookstore
dotnet new globaljson --sdk-version 10.0.100 --roll-forward latestFeature
dotnet new sln --name Bookstore
dotnet new buildprops
dotnet new packagesprops
dotnet new gitignore
dotnet new web -o src/Services/Catalog/Bookstore.Catalog.Api
dotnet new web -o src/Services/Ordering/Bookstore.Ordering.Api
dotnet new web -o src/Services/Inventory/Bookstore.Inventory.Grpc
dotnet new web -o src/Services/Payments/Bookstore.Payments.Api
dotnet new web -o src/Services/Notifications/Bookstore.Notifications.Api
dotnet new classlib -o src/BuildingBlocks/Bookstore.Contracts
dotnet sln add (Get-ChildItem -Recurse -Filter *.csproj)
The last line is PowerShell; in bash, dotnet sln add $(find . -name "*.csproj") does the same. The test project (tests/Bookstore.ArchitectureTests) is an ordinary console project that uses the xUnit.net v3 package; the "test": { "runner": "Microsoft.Testing.Platform" } line in global.json makes dotnet test run it with Microsoft.Testing.Platform. In .NET 10, dotnet new sln produces a readable XML file in the .slnx format by default. Because dotnet sln add puts a project into a solution folder that follows its directory path, each service sits in its own solution folder:
<Solution>
<Folder Name="/src/" />
<Folder Name="/src/BuildingBlocks/">
<Project Path="src/BuildingBlocks/Bookstore.Contracts/Bookstore.Contracts.csproj" />
</Folder>
<Folder Name="/src/Services/" />
<Folder Name="/src/Services/Catalog/">
<Project Path="src/Services/Catalog/Bookstore.Catalog.Api/Bookstore.Catalog.Api.csproj" />
</Folder>
<Folder Name="/src/Services/Inventory/">
<Project Path="src/Services/Inventory/Bookstore.Inventory.Grpc/Bookstore.Inventory.Grpc.csproj" />
</Folder>
<Folder Name="/src/Services/Notifications/">
<Project Path="src/Services/Notifications/Bookstore.Notifications.Api/Bookstore.Notifications.Api.csproj" />
</Folder>
<Folder Name="/src/Services/Ordering/">
<Project Path="src/Services/Ordering/Bookstore.Ordering.Api/Bookstore.Ordering.Api.csproj" />
</Folder>
<Folder Name="/src/Services/Payments/">
<Project Path="src/Services/Payments/Bookstore.Payments.Api/Bookstore.Payments.Api.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Bookstore.ArchitectureTests/Bookstore.ArchitectureTests.csproj" />
</Folder>
</Solution>
Shared build settings are in Directory.Build.props and package versions in Directory.Packages.props. With Central Package Management turned on, projects don't put a version on PackageReference; the version lives in one place. This stops services from using the same package in different versions and removes the "Inventory is still on the old gRPC package" surprise:
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup Label="Tests">
<PackageVersion Include="xunit.v3" Version="4.0.3" />
</ItemGroup>
</Project>
A single-version policy is a preference, not a law. If a service really needs a different version, it can use VersionOverride in its own .csproj; treating that as an exception and asking for the reason in code review is enough. The services are still empty: each one is a web project that returns a single endpoint. In post 02 we bring them all up with Aspire.
The Shared Code Policy and the Test That Guards It
The most common drift in microservice projects goes like this: first a Bookstore.Common project is created, a few extension methods go into it, then a base entity, then a "shared" validation rule. A year later every service depends on that project, and one change requires all services to be deployed at the same time. That's the sneakiest road to the distributed monolith we discussed in the Strangler Fig post of the Microservices series.
In this series shared code is limited to two projects: Bookstore.Contracts (definitions of integration events and messages) and Bookstore.ServiceDefaults (telemetry, health checks and resilience settings we add in post 02). A service can't reference another service's implementation project; it can only reference the .Contracts projects their owners publish. We wrote the rule as a test instead of a document, so it breaks in CI. It's a small example of what we called a fitness function in the Architecture Erosion post of the Introduction to Architecture series. The test reads the .csproj files as XML:
public sealed record ProjectFile(string Name, string FullPath, string Folder)
{
public IEnumerable<string> ProjectReferences =>
XDocument.Load(FullPath).Descendants("ProjectReference")
.Select(r => Path.GetFullPath(Path.Combine(Path.GetDirectoryName(FullPath)!,
r.Attribute("Include")!.Value.Replace('\\', Path.DirectorySeparatorChar))));
public IEnumerable<string> PackageReferences =>
XDocument.Load(FullPath).Descendants("PackageReference").Select(p => p.Attribute("Include")!.Value);
public override string ToString() => Name;
}
public sealed class SharedCodeRules
{
// The only shared code: contracts and ServiceDefaults. No "Common" or "Shared" business library.
private static readonly string[] AllowedBuildingBlocks = ["Bookstore.Contracts", "Bookstore.ServiceDefaults"];
// A contracts project is a description of messages, not a place for logic or infrastructure.
private static readonly string[] AllowedContractPackages = [];
public static TheoryData<string> ServiceProjectNames() => [.. RepositoryLayout.ServiceProjects().Select(p => p.Name)];
[Theory]
[MemberData(nameof(ServiceProjectNames))]
public void A_service_references_only_its_own_projects_building_blocks_and_contracts(string projectName)
{
ProjectFile project = RepositoryLayout.ServiceProjects().Single(p => p.Name == projectName);
string buildingBlocks = RepositoryLayout.Src("BuildingBlocks") + Path.DirectorySeparatorChar;
string[] violations =
[
.. project.ProjectReferences.Where(reference =>
!reference.StartsWith(project.Folder + Path.DirectorySeparatorChar, StringComparison.Ordinal) &&
!reference.StartsWith(buildingBlocks, StringComparison.Ordinal) &&
!Path.GetFileNameWithoutExtension(reference).EndsWith(".Contracts", StringComparison.Ordinal))
.Select(Path.GetFileNameWithoutExtension)!
];
Assert.True(violations.Length == 0,
$"{project.Name} references another service's implementation: {string.Join(", ", violations)}");
}
[Fact]
public void Building_blocks_contain_only_contracts_and_service_defaults()
{
string[] unexpected = [.. RepositoryLayout.BuildingBlocks().Select(p => p.Name).Except(AllowedBuildingBlocks)];
Assert.True(unexpected.Length == 0, $"Unexpected shared projects: {string.Join(", ", unexpected)}");
}
[Fact]
public void Contract_projects_have_no_logic_dependencies()
{
ProjectFile[] contracts =
[
.. RepositoryLayout.BuildingBlocks().Concat(RepositoryLayout.ServiceProjects())
.Where(p => p.Name.EndsWith(".Contracts", StringComparison.Ordinal))
];
foreach (ProjectFile contract in contracts)
{
Assert.Empty(contract.ProjectReferences);
Assert.All(contract.PackageReferences, package => Assert.Contains(package, AllowedContractPackages));
}
}
}
RepositoryLayout finds the repository root by walking up from the test output folder until it finds Bookstore.slnx, and returns every .csproj under src/Services/<Service>/ together with its service folder. To try the rule, we added a reference from Ordering to Catalog and ran the tests:
dotnet add src/Services/Ordering/Bookstore.Ordering.Api reference src/Services/Catalog/Bookstore.Catalog.Api
dotnet test
failed Bookstore.ArchitectureTests.SharedCodeRules.A_service_references_only_its_own_projects_building_blocks_and_contracts(projectName: "Bookstore.Ordering.Api") (4ms)
Bookstore.Ordering.Api references another service's implementation: Bookstore.Catalog.Api
Test run summary: Failed!
total: 7
failed: 1
succeeded: 6
skipped: 0
duration: 296ms
When we remove the reference, all seven tests pass. Seven, because the [Theory] runs separately for the five service projects and there are two [Fact]s. When a new service is added, the test covers it automatically; nobody has to update a list by hand.
There are things the test can't see. If two services connect to the same database, or one service reads another's table with SQL, it leaves no trace in a .csproj. We close that gap with database user permissions (each service is authorized only in its own database) and with the "give the connection string only to its owner" rule we will see in post 02.
In Production
Watch the files that change together, not the number of services. If the code of two services often changes in the same pull request, either the contract is too wide or the boundary is in the wrong place. Extracting that from Git history takes a few minutes with the analysis from the Conway's Law post of the Introduction to Architecture series; looking once a month is enough.
Put versions into contracts from the start. Event names, their fields and the gRPC package name (bookstore.inventory.v1) should be versioned from day one. When a breaking change is needed, publishing a new version next to the old one is always cheaper than deploying all consumers on the same day.
Measure the cost of the local environment. Five services, four containers and the dashboard use a significant share of a developer machine's memory. In post 15 we will compare those numbers with the modular monolith version; note already that "my machine can't cope" is also a cost of the architecture.
Common Pitfalls
Turning every module into a service. Modules are cheap in a modular monolith; services aren't. As in the Shipping example, if there's no measurable reason to split, let it stay a module.
Leaving the database for last. Splitting the code while still sharing the database means two deployment units and a single schema: one migration breaks two services at once. In this series every service starts with its own database from day one.
Putting a database identity into a contract. BookId = 17 means something inside Catalog, not outside it. Natural or generated identities such as ISBNs and Guids can cross a service boundary; identity columns shouldn't.
Starting a "shared" project small and letting it grow. The test above exists for this. If a helper method is needed in two services, copying it is usually cheaper than moving it into a shared project.
A Small Experiment
★☆☆ Add a project under src/BuildingBlocks with dotnet new classlib -o src/BuildingBlocks/Bookstore.Common and run the tests. Which test breaks, and what does the message say?
★★☆ Fill in the columns of the service table above for the modules of a monolith you work on: the data it owns, the events it would publish, the team. In which row does the "team" column stay empty? Do you have a measurable reason to turn that module into a service?
★★★ Add a fourth rule to SharedCodeRules: if a project's .csproj contains VersionOverride, the test should accept it only for packages in an AllowedVersionOverrides list. First break the rule by adding <PackageReference Include="xunit.v3" VersionOverride="4.0.2" /> to Catalog, read the message, then revert it. This rule shows why Central Package Management alone doesn't protect the "single version" decision.
The decisions are written down and the repository skeleton and the test that guards it are ready, but the services are still web projects that know nothing about each other. In the next post we will define the SQL Server, Redis, RabbitMQ and Keycloak containers, the services and the connections between them in a single C# file with an Aspire AppHost; add telemetry and health checks to every service with one line through ServiceDefaults, run migrations as a separate process and see everything green on the dashboard.