The Catalog Service: Its Own Database, a REST API and Read-Heavy Optimizations
We turn Catalog into a service that owns its database and is tuned for read traffic: search and paging with AsNoTracking projections, a shared output cache in Redis with tag eviction, URL segment versioning, ProblemDetails, an OpenAPI contract generated at build time and committed, and a breaking change check with oasdiff. Price and title changes become versioned BookPriceChanged and BookRenamed events; the same version, as a concurrency token, stops 4 of 8 parallel requests with 409.
Version note: The code in this post was built and run with .NET 10, Aspire 13.6, Asp.Versioning 10.2, Microsoft.AspNetCore.OpenApi 10.0 and oasdiff 1.37; Redis 8.6 was used for the output cache.
Guess First
You change the price of The Hobbit through the Catalog service's back-office endpoint. At the same time, eight people call the same endpoint with eight different prices: 401, 402, ... 408 TRY. The book's row has a Version column, and EF Core uses it as a concurrency token. How many of the eight requests return 204, and how many 409? What is the book's final price, and what does its version go up to? And one more: does a customer who requests the book detail right after the price change see the old price from the 10-minute output cache? The answers come towards the end of the post, with real output.
What We Moved from the Monolith, and What We Left Behind
In post 02 we moved Catalog's data model as is from the monolith of Bookstore, the online bookstore used as the shared example across this site's series, into its own catalogdb. In this post we make Catalog a real service: one with its own HTTP contract, endpoints tuned for read-heavy traffic, and changes it announces to the outside world.
In the monolith, Catalog had two faces. One was the HTTP endpoints for Angular, the other the ICatalogService interface the other modules called: FindByIsbnAsync, GetByIdsAsync. The second face doesn't move into the service. Ordering will reach book data no longer through a method call, but through its own copy, which Catalog's events feed in post 06. We left one more thing behind: the book detail response in the monolith had a stock count (Available), which Catalog read from the Inventory module. Stock is Inventory's data; it's gone from Catalog's response. Showing price and stock side by side on the book page will be the job of the BFF's aggregation endpoint in post 09.
Both decisions were made against the entity service anti-pattern from the Service Boundary post of the Microservices series. If Catalog becomes "the HTTP front of the books table", every service goes to it on every request, and when Catalog slows down the whole system slows down. Catalog's job is the catalog: search, listing, price and title management.
Read Endpoints: Search and Paging
Most of Catalog's traffic is reads. In the Introduction to Architecture series we set a target of 200 requests per second for catalog search during campaign periods. The search endpoint isn't very different from its monolith version; what changes is the type it returns:
// GET /api/v1/books?search=austen&category=classics&page=1&pageSize=12
private static async Task<Ok<PagedResponse<BookSummaryResponse>>> SearchBooks(
CatalogDbContext db, string? search, string? category, int page = 1, int pageSize = 12, CancellationToken ct = default)
{
page = Math.Max(page, 1);
pageSize = Math.Clamp(pageSize, 1, 50);
IQueryable<Book> query = db.Books.AsNoTracking();
if (!string.IsNullOrWhiteSpace(search))
{
string term = search.Trim();
query = query.Where(b => b.Title.Contains(term) || b.Author.Name.Contains(term) || b.Isbn == term);
}
if (!string.IsNullOrWhiteSpace(category))
{
query = query.Where(b => b.Category.Slug == category);
}
int total = await query.CountAsync(ct);
List<BookSummaryResponse> items = await query
.OrderBy(b => b.Title)
.ThenBy(b => b.Isbn)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(b => new BookSummaryResponse(
b.Isbn, b.Title, b.Author.Name, b.Category.Name, b.Price, Data.Catalog.Currency, b.PublishedYear))
.ToListAsync(ct);
return TypedResults.Ok(new PagedResponse<BookSummaryResponse>(items, page, pageSize, total));
}
There are three details. AsNoTracking and the Select projection read straight into the response type without creating entities at all. pageSize is capped at 50; an unbounded page size is the shortest way to make Catalog read 40,000 rows with a single request. ThenBy(b => b.Isbn) makes the ordering deterministic when two books have the same title; without it, offset pagination can show the same book on two pages. We compared the paging options in the Pagination post of the ASP.NET Core Web API series.
The returned types are the service's public contract. Entities never leave the service:
// Catalog's public HTTP contract: entities never leave the service, these records do
public sealed record BookSummaryResponse(
string Isbn, string Title, string Author, string Category, decimal Price, string Currency, int PublishedYear);
public sealed record BookResponse(
string Isbn, string Title, string Author, string Category, decimal Price, string Currency,
int PublishedYear, string Description, int Version);
public sealed record CategoryResponse(string Name, string Slug);
public sealed record PagedResponse<T>(IReadOnlyList<T> Items, int Page, int PageSize, int TotalCount);
A book's identity is its ISBN; the Id column isn't in the contract. The currency is written explicitly in every response, because when the second-country goal we discussed in post 01 comes true, we don't want consumers to guess which currency a price is in. A search against the running service:
curl -i "http://localhost:5101/api/v1/books?search=austen"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 13:03:07 GMT
Server: Kestrel
Transfer-Encoding: chunked
api-supported-versions: 1.0
{"items":[{"isbn":"9780141439587","title":"Emma","author":"Jane Austen","category":"Classics","price":249.90,"currency":"TRY","publishedYear":1815},{"isbn":"9780141439518","title":"Pride and Prejudice","author":"Jane Austen","category":"Classics","price":239.90,"currency":"TRY","publishedYear":1813}],"page":1,"pageSize":12,"totalCount":2}
We added an index on the Title column, but Contains becomes LIKE '%austen%' in SQL and can't use that index because of the leading wildcard. With 20 books it makes no difference; with 40,000 it does. The index helps with sorting by title and, later, with "title starts with" searches. The right tool for real catalog search is a separate search engine; that's the topic of the upcoming Search with Elasticsearch series.
Output Cache: A Shared Cache in Redis
Catalog data doesn't change every second, but it's read hundreds of times per second. That profile is ideal for an output cache. We covered ASP.NET Core output cache policies, vary-by rules and tag eviction in detail in the Output Cache post of the Caching series; here is what we set up in Catalog:
public static void Configure(OutputCacheOptions options)
{
options.AddPolicy(BookLists, policy => policy
.SetVaryByQuery("search", "category", "page", "pageSize")
.Expire(TimeSpan.FromSeconds(60))
.Tag(BookListsTag));
options.AddPolicy(Book, policy => policy
.AddPolicy<BookTagPolicy>()
.Expire(TimeSpan.FromMinutes(10)));
options.AddPolicy(Categories, policy => policy.Expire(TimeSpan.FromMinutes(30)));
}
List pages are cached separately per query parameter combination and all carry the book-lists tag. Book details are cached for 10 minutes, and BookTagPolicy adds a book-specific tag such as book:9780441172719 from the ISBN in the route, so we can evict a single book from the cache. Categories hardly ever change, so they get 30 minutes.
Where the cache lives matters in a microservice. The default output cache store is in the process's memory; when you run Catalog with three instances, each instance has its own cache, and an eviction on one instance doesn't clear the other two. That's why we moved the store to Redis. Aspire's Redis output cache integration reduces this to one line, and in the AppHost we added a Redis reference to Catalog:
builder.AddSqlServerDbContext<CatalogDbContext>("catalogdb");
builder.AddRedisOutputCache("redis");
builder.Services.AddOutputCache(CachePolicies.Configure);
builder.AddProject<Projects.Bookstore_Catalog_Api>("catalog")
.WithReference(catalogDb)
.WithReference(redis)
.WaitForCompletion(catalogMigrator)
.WaitFor(redis)
.WithHttpHealthCheck("/health");
We requested the same book three times:
GET book #1 -> 200 in 0.025201s
GET book #2 -> 200 in 0.002695s Age: 0
GET book #3 -> 200 in 0.001572s Age: 0
The first request went to the database and wrote the response to Redis (25 ms), the next two came from Redis (2-3 ms). The Age header shows the response came from the cache. Notifications will use Redis too, but the two live in the same Redis instance for different purposes; that's not a shared database, because neither service reads the other's keys. Separating them in production is a load and isolation decision.
API Versioning and ProblemDetails
Catalog's contract will serve at least two consumers: the BFF, and Ordering when it fills its book copy for the first time in post 04. To be able to open a new version without breaking old consumers when a field has to be removed or its meaning changed, we put a version into the URL from the start. We covered Asp.Versioning's options in the Versioning post of the ASP.NET Core Web API series; Catalog's setup is this:
builder.Services.AddProblemDetails();
builder.Services.AddValidation();
builder.Services.AddApiVersioning(options =>
{
options.ApiVersionReader = new UrlSegmentApiVersionReader();
options.ReportApiVersions = true;
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
})
.AddOpenApi(options => options.Document.AddDocumentTransformer((document, _, _) =>
{
document.Info.Title = "Bookstore Catalog API";
document.Info.Description = "Books, authors, categories and prices. Owned by the Catalog & Search team.";
return Task.CompletedTask;
}));
RouteGroupBuilder v1 = app.NewVersionedApi("Catalog")
.MapGroup("/api/v{version:apiVersion}")
.HasApiVersion(1.0);
v1.MapGet("/books", SearchBooks).CacheOutput(CachePolicies.BookLists);
v1.MapGet("/books/{isbn}", GetBook).CacheOutput(CachePolicies.Book).ProducesProblem(StatusCodes.Status404NotFound);
v1.MapGet("/categories", GetCategories).CacheOutput(CachePolicies.Categories);
We hadn't written the ApiVersionReader line on the first try. Asp.Versioning 10.2 ships with an analyzer, and because TreatWarningsAsErrors is on, the build broke: error AV0015: Configure 'ApiVersioningOptions.ApiVersionReader = new UrlSegmentApiVersionReader();' to optimize performance. The default reader also looks for the version in the query string and in headers; once we say we only use the URL segment, that lookup doesn't happen on every request. ReportApiVersions adds the api-supported-versions: 1.0 header to every response.
All error responses are ProblemDetails. An unknown ISBN:
curl -i http://localhost:5101/api/v1/books/9780000000000
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Date: Sat, 14 Nov 2026 13:03:15 GMT
Server: Kestrel
Transfer-Encoding: chunked
api-supported-versions: 1.0
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Book not found","status":404,"detail":"There is no book with ISBN 9780000000000.","traceId":"00-01eda5c7efc6c77d0437795e90321c39-15073beccdc18349-03"}
traceId is the W3C trace context id we will use in post 12 to follow a request across services; a user who tells support this value lets them find exactly that request in the logs. When we request a version that doesn't exist (/api/v2/books), the answer is 404 Not Found: with URL segment versioning, there is no v2 route at all. Sending a price of zero is caught by .NET 10's built-in validation:
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Price":["The field Price must be between 0.01 and 100000."]},"traceId":"00-b95282465bef92b0d510b7bad6c23b40-9428216f60777ae6-03"}
Publishing the Contract: The OpenAPI Document and a Breaking Change Check
How does Catalog's contract reach its consumers? The running service serves the document at /openapi/v1.json, but a consumer shouldn't have to run Catalog to see the contract. The Microsoft.Extensions.ApiDescription.Server package writes the document to a file on every build; we commit that file to the repository:
<PropertyGroup>
<RootNamespace>Bookstore.Catalog</RootNamespace>
<!-- The OpenAPI document is generated on every build and committed: it is the contract consumers read. -->
<OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/../openapi</OpenApiDocumentsDirectory>
<OpenApiGenerateDocumentsOptions>--file-name catalog</OpenApiGenerateDocumentsOptions>
</PropertyGroup>
At the end of the build, src/Services/Catalog/openapi/catalog.json is updated. If the contract changes, the diff of this file shows up in the pull request; "Catalog's contract changed" doesn't slip through code review. The document is generated during the build inside a tool called GetDocument.Insider, by building the application's host. On the first try the title came out as GetDocument.Insider | v1 and the description was that tool's own description. That's why the document transformer sets Info.Title and Info.Description.
The real value of the file is in automated checks. Imagine a developer renames the Title field of BookSummaryResponse to Name; on the C# side everything compiles. In CI we compared the document on main with the new one using oasdiff:
oasdiff breaking base.json catalog.json --fail-on ERR
1 changes: 1 error, 0 warning, 0 info
error [response-required-property-removed] at catalog.json
in API GET /api/v1/books
removed the required property `items/items/title` from the response with the `200` status
exit=1
Adding the new field (name) didn't count as an error; it's a backward compatible change. But title disappearing breaks every consumer that reads it. Without --fail-on ERR, oasdiff writes the same report and exits with 0; the flag is required to stop the pipeline. If a breaking change is really needed, the path is clear: open v2 next to v1, move the consumers, then close v1. That's the HTTP counterpart of the expand-contract approach from the Service Contracts post of the Microservices series. In post 13 we will also test the same contract from the consumer side with Pact.
Turning Changes into Events: BookPriceChanged and BookRenamed
Ordering will keep its own copy of the books (the decision from post 01). For that copy to stay current, Catalog has to say "this book's price changed" and "this book's title changed". The integration event contracts are in Bookstore.Contracts:
namespace Bookstore.Contracts.Catalog;
// Integration events published by Catalog. Version grows by one with every change to the same book,
// so a consumer can ignore an event that is older than the copy it already has.
public sealed record BookPriceChanged(
string Isbn, decimal OldPrice, decimal NewPrice, string Currency, int Version, DateTimeOffset OccurredAtUtc);
public sealed record BookRenamed(
string Isbn, string OldTitle, string NewTitle, int Version, DateTimeOffset OccurredAtUtc);
The events are self-contained: old and new value, currency, time and version. The consumer doesn't need to call Catalog back to process an event; that's the approach we called event-carried state transfer in the Messaging and Event-Driven Architecture series. The Version field will pay off in post 06: if RabbitMQ delivers two events for the same book in reverse order, Ordering will ignore the one whose version is lower than what it already has.
The place that produces the event is Book. In the monolith, the price was a property anyone could write to; now a change goes through a method, and the method both increments the version and records the event:
// Concurrency token and event version in one field: every change increments it
public int Version { get; private set; } = 1;
public void ChangePrice(decimal newPrice, TimeProvider clock)
{
if (newPrice == Price)
{
return;
}
decimal oldPrice = Price;
Price = newPrice;
Version++;
_events.Add(new BookPriceChanged(Isbn, oldPrice, newPrice, Catalog.Currency, Version, clock.GetUtcNow()));
}
In CatalogDbContext, Version is a concurrency token: book.Property(b => b.Version).IsConcurrencyToken().HasDefaultValue(1). For existing rows, the migration fills the column with 1. The UPDATE EF Core generates adds the version to the WHERE clause:
UPDATE [Books] SET [Price] = @p0, [Version] = @p1
OUTPUT 1
WHERE [Id] = @p2 AND [Version] = @p3;
That gives the answer to Guess First. The eight parallel requests read the same row at almost the same time. Of those that read the same version and tried to write, only the first could win; the others' UPDATE updated 0 rows and EF Core threw DbUpdateConcurrencyException. The result of the real run:
price 402 -> 204
price 401 -> 409
price 407 -> 409
price 405 -> 204
price 406 -> 409
price 404 -> 204
price 408 -> 409
price 403 -> 204
When we read the book afterwards, the price was 403 and the version 5. Four requests succeeded because the requests didn't read and write at exactly the same moment, but milliseconds apart: some of them read after a previous successful write and continued with the new version. The four requests that got 409 received a "the book changed after you read it" ProblemDetails. The last writer didn't win: instead of silently overwriting a price, the losing requests got an error. We covered how a concurrency token behaves in the Concurrency post of the Entity Framework Core series; what matters here is that the same number is the event's version. Every BookPriceChanged that goes to Ordering carries the version of Catalog's row after that change.
The shared method that applies the change also clears the cache:
change(book);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException)
{
return TypedResults.Problem(
title: "Book was changed by another request",
detail: $"Book {isbn} was updated in the meantime. Read it again and retry.",
statusCode: StatusCodes.Status409Conflict);
}
// Both the detail page and every list page that may contain this book are stale now
await cache.EvictByTagAsync(CachePolicies.BookTag(isbn), ct);
await cache.EvictByTagAsync(CachePolicies.BookListsTag, ct);
// Post 06 sends these through the transactional outbox. Until then they are only logged:
// a crash between SaveChanges and a direct publish would lose the event (dual write)
foreach (object integrationEvent in book.Events)
{
logger.LogInformation("Integration event ready: {EventType} {@Event}", integrationEvent.GetType().Name, integrationEvent);
}
book.ClearEvents();
return TypedResults.NoContent();
The answer to the second question in Guess First is here: because of the eviction, the customer doesn't see the old price. After we lowered Dune's price from 489 to 459, the detail request came from the database without an Age header and returned "price":459.00,...,"version":2. Catalog's log shows the event ready to be published:
[catalog] Integration event ready: BookPriceChanged BookPriceChanged { Isbn = 9780441172719, OldPrice = 489.00, NewPrice = 459.00, Currency = TRY, Version
= 2, OccurredAtUtc = 11/14/2026 1:03:21 PM +00:00 }
We only log the event for now, and we do that on purpose. If we published to RabbitMQ directly after SaveChangesAsync and the process crashed between the two operations, the price would have changed but the event would never have gone out; Ordering's copy would never be corrected. That's called the dual write problem, and its solution is the transactional outbox: the event is written to the database in the same transaction as the price change, and a separate process carries it to the broker. We discussed the concept in the Transactional Outbox post of the Messaging and Event-Driven Architecture series; in post 06 we set it up in Catalog and Ordering with Wolverine's EF Core outbox.
In Production
Cache duration is a business decision. Ten minutes is safe for the book detail because changes evict it. List pages are evicted too, but the 60-second duration guarantees that, even if an eviction is missed at a moment Redis can't be reached, an old price is visible for at most a minute. The price on an order doesn't come from the cache but from Ordering's copy and the rule of freezing it at order time we will see in post 04; a few seconds of staleness in the cache doesn't mean selling at a wrong price.
The OpenAPI endpoint isn't exposed to the outside in production. Catalog sits behind the BFF, and the BFF publishes only its own routes; since the document is generated at build time, we don't need it served by the running service. If you like, you can limit MapOpenApi() to the Development environment.
Put the breaking change check into every service's pipeline, but read the report too. oasdiff catches a removed field; it can't catch a change in a field's meaning (such as a price switching from tax-included to tax-excluded). Changes like that are the job of contract tests and code review.
Common Pitfalls
Returning the entity. Serializing Book straight to JSON makes the database schema the contract; adding a column sends a new field to every consumer, and a navigation property produces cyclic JSON. Keeping response records separate breaks that link.
Another service connecting to Catalog's database. A connection string handed out "just for reading, for reports" makes it impossible for Catalog to change its schema. If reports are needed, a separate read model fed by events or an export Catalog publishes are the options from the Data Ownership post of the Microservices series.
An instance-local cache. With an in-memory output cache everything looks right on a single instance; the day a second instance starts, eviction works only halfway and a bug report says "sometimes the old price shows up".
Producing the event before the entity changes. Building the event by hand in the endpoint is open to announcing a change that never happened when saving fails, or to writing the wrong version. The method that changes the state produces the event, and it is published only if saving succeeded.
A Small Experiment
★☆☆ Request /api/v1/categories twice and look at the Age header. There's no eviction for categories; if you added an endpoint that renames a category, which tag would you use?
★★☆ Add an ETag header (its value coming from Version) to the GET /api/v1/books/{isbn} response, and change the price endpoint to return 428 when the If-Match header is missing and 412 when it doesn't match. Run the eight parallel requests from Guess First again; how does the result change?
★★★ Add a nullable field named Subtitle to BookSummaryResponse, build, and run oasdiff breaking. Then add the same field to RenameBookRequest as a required request field and run it again. Which one counts as breaking, and why?
Catalog owns its data, is ready for read traffic and describes its changes as events. In the next post we build Ordering: we move the Order aggregate we enriched in the Domain-Driven Design series into its own database, separate the place-order command from the order list projection, fill the local copy of the books for the first time using Catalog's contract from this post, and freeze the price at order time.