The Ordering Service: A Rich Domain Model, Its Own Database and the Order API
Ordering keeps the Order aggregate in its own database, reads book titles and prices from a local copy filled once instead of asking Catalog, and rejects an order with 409 when the price the customer saw in the cart doesn't match the copy. Orders are created with 201 even while Catalog is down; how SQL Server sorts Guid v7, a non-clustered primary key and a projection for the order list are shown with real output.
Version note: The code in this post was built and run with .NET 10, Aspire 13.6, Entity Framework Core 10, Asp.Versioning 10.2 and xUnit.net v3 4.0; the database is SQL Server 2025.
Guess First
By the end of this post the Ordering service handles an order request like this: it reads the books' titles and prices from the copy in its own database, builds the Order aggregate and saves it. Two questions. First: if you stop the Catalog service and send an order, what does Ordering return? Second: the customer saw Dune for 489 TRY in the cart, but in the meantime Catalog lowered the price to 459 TRY and Ordering's copy was updated. The request comes with "unitPrice": 489.00. Is the order created at 489, at 459, or not at all? We see the answers in the post with real HTTP responses.
Ordering's Boundary: What Did the Monolith's OrderService Do?
In the monolith of Bookstore, the online bookstore used as the shared example across this site's series, OrderService.PlaceOrderAsync did five jobs in a single transaction: read the cart, get the books from ICatalogService, reserve stock with IInventoryService.ReserveAsync, find or create the customer, write the order and delete the cart. With one database that's natural; once we split into services, each of them is a separate decision.
- The cart doesn't stay in Ordering. In post 11 Angular will keep the cart on the client, and Ordering will only receive an order request saying "these books, these quantities, these prices".
- Book data isn't requested from Catalog; it's read from Ordering's own copy. In this post we fill the copy for the first time, and in post 06 we keep it current with Catalog's events.
- There's no stock reservation in this post. In post 05 we call Inventory over gRPC, and in post 07 this step becomes part of the checkout saga.
- Customer records aren't Ordering's job. The order carries the buyer's name and email as a value; in post 10 that information will come from the token Keycloak issues.
What's left is Ordering's real job: creating a valid order, saving it and listing it. At the end of this post the order stays in the Pending state; neither stock nor payment is settled. Moving to "confirmed" requires the saga in post 07 to wait for the stock and payment results.
Bringing the Order Aggregate into the Service
In the Bookstore Ordering refactoring post of the Domain-Driven Design series, we turned an anemic CRUD order model step by step into a rich Order aggregate: a factory method, protected invariants, value objects, domain events. Here we bring the same model into the service and change it in three places for the service's needs:
// Generated by Ordering, not by the database: the id exists before the order is saved
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.CreateVersion7());
public override string ToString() => Value.ToString();
}
public sealed record Money(decimal Amount, string Currency)
{
public static Money Zero(string currency) => new(0m, currency);
// Each line is rounded to two decimals, midpoint away from zero (Requirements Analysis series)
public Money Times(int quantity) => new(Math.Round(Amount * quantity, 2, MidpointRounding.AwayFromZero), Currency);
public static Money operator +(Money left, Money right) =>
left.Currency == right.Currency
? new Money(left.Amount + right.Amount, left.Currency)
: throw new DomainException("money.currency-mismatch", $"Cannot add {right.Currency} to {left.Currency}.");
}
// Until post 10 the buyer comes from the request; then from the access token
public sealed record Buyer(string Name, string Email);
The first change is identity. A book is no longer identified by BookId but by its ISBN, because, as decided in post 01, Catalog's identity column doesn't travel between services. The order's identity is Guid.CreateVersion7(): time-ordered and generated by Ordering itself. The reservation request to Inventory in post 05 and the saga messages in post 07 will travel with this identity; there's no need to wait for the order to be written to the database.
The second is the state. The model in the DDD series started with Placed; here it's Pending. The name difference is small but the meaning is big: in the monolith, when the order was written, stock was reserved in the same transaction. Here nothing is settled yet.
public static Order Place(Buyer buyer, IReadOnlyCollection<OrderLine> lines, TimeProvider clock)
{
if (lines.Count == 0)
{
throw new DomainException("order.empty", "An order needs at least one item.");
}
DateTimeOffset now = clock.GetUtcNow();
Order order = new()
{
Id = OrderId.New(),
Buyer = buyer,
PlacedAtUtc = now.UtcDateTime,
Status = OrderStatus.Pending
};
foreach (OrderLine line in lines)
{
order.Add(line);
}
order.Total = order._items.Select(i => i.LineTotal).Aggregate((sum, lineTotal) => sum + lineTotal);
// Pending, not confirmed: stock and payment are not settled yet (checkout saga, post 07)
order._domainEvents.Add(new OrderPlaced(order.Id, order.Total, order._items.Sum(i => i.Quantity), now));
return order;
}
The third is the price per line. OrderLine carries the title and the unit price as Money; OrderItem copies them into its own fields and calculates the line total. Whatever happens to the price in Catalog after the order is created, the order's line doesn't change. The Add method merges the same book into a single line if it comes twice, and protects the 10-copies-per-book limit and the single-currency rule. We don't re-explain the invariants here; the approach is the same as in the Always-Valid Domain Model post of the DDD series.
OrderPlaced is a domain event for now: it's produced inside the aggregate and doesn't leave the service. In post 06 it becomes an integration event and goes to RabbitMQ through the outbox. The difference between the two is that a domain event speaks Ordering's internal language (for example with the OrderId and Money types), while an integration event speaks through a plain, versioned contract in Bookstore.Contracts.
Since the aggregate's rules don't need a database, we protect them with unit tests. Two of the tests:
[Fact]
public void The_same_book_twice_becomes_one_line()
{
Order order = Order.Place(Ada, [Dune(2), Dune(3)], Clock);
OrderItem item = Assert.Single(order.Items);
Assert.Equal(5, item.Quantity);
Assert.Equal(new Money(2295.00m, "TRY"), item.LineTotal);
}
[Theory]
[InlineData(11)]
[InlineData(6, 5)]
public void More_than_ten_copies_of_a_book_are_rejected(params int[] quantities)
{
DomainException error = Assert.Throws<DomainException>(
() => Order.Place(Ada, [.. quantities.Select(q => Dune(q))], Clock));
Assert.Equal("order.too-many-copies", error.Code);
}
dotnet test --project tests/Bookstore.Ordering.Tests
Test run summary: Passed!
total: 8
failed: 0
succeeded: 8
skipped: 0
duration: 235ms
EF Core Mapping and How SQL Server Sorts Guid v7
The code that maps the aggregate to orderingdb is written with the techniques from the Persisting Aggregates with EF Core 10 post of the DDD series: a value converter for the strongly typed id, complex properties for Money and Buyer, a backing field for the lines. We don't repeat the details; two lines stand out:
modelBuilder.Entity<Order>(order =>
{
// Guid v7 is time-ordered, but SQL Server sorts uniqueidentifier by its last bytes first:
// a clustered index on it would still fragment. We cluster on the placement time instead
order.HasKey(o => o.Id).IsClustered(false);
order.HasIndex(o => o.PlacedAtUtc).IsClustered();
order.Property(o => o.Id).HasConversion(id => id.Value, value => new OrderId(value));
order.Property(o => o.Status).HasConversion<string>().HasMaxLength(20);
order.Property(o => o.CancellationReason).HasMaxLength(200);
order.ComplexProperty(o => o.Buyer, buyer =>
{
buyer.Property(b => b.Name).HasMaxLength(200);
buyer.Property(b => b.Email).HasMaxLength(256);
});
order.ComplexProperty(o => o.Total, MapMoney);
order.Property<byte[]>("RowVersion").IsRowVersion();
order.HasMany(o => o.Items).WithOne().HasForeignKey("OrderId").IsRequired().OnDelete(DeleteBehavior.Cascade);
order.Navigation(o => o.Items).HasField("_items").AutoInclude();
order.Ignore(o => o.DomainEvents);
});
Guid.CreateVersion7() writes the time at the beginning of the GUID; in .NET and in string comparison, values generated one after another are ordered. SQL Server, however, starts from the last six bytes when it compares uniqueidentifier values, and in v7 that part is random. We tried this in SQL Server: we sorted six v7 values whose timestamps increase by 0, 1, 2, 3, 250 and 1000 ms with ORDER BY:
CreatedOrder Id
------------ --
6 01A1DA2F-DF02-7442-B54A-211A9EA8DAC5
5 01A1DA2F-DC14-755D-9D84-5EDE13DD3884
3 01A1DA2F-DB1C-7011-8648-61B62C59078C
4 01A1DA2F-DB1D-7EB0-8101-67DDD7EB421C
1 01A1DA2F-DB1A-7FF0-896B-7014F772134B
2 01A1DA2F-DB1B-79D1-9F87-84E768C0A72D
The creation order is 1 to 6; SQL Server's order is 6, 5, 3, 4, 1, 2. If these GUIDs were the key of the clustered index, every new order would be written somewhere in the middle of the table and page splits would increase. We made the primary key non-clustered and put the clustered index on PlacedAtUtc; new orders are appended at the end of the table, and the "latest orders" query reads from the same index. If you still want the GUID, there are ways to generate values that are sequential for SQL Server (such as EF Core's own sequential GUID generator), but then you give up the decision to generate the id inside the aggregate.
RowVersion is SQL Server's rowversion column, a concurrency token against the order being changed from two places at the same time. In this post orders are only created; in post 07 this column comes into play when the saga changes the state of the same order.
The Local Copy of the Books: The Initial Load
Ordering needs four things from a book: ISBN, title, price and currency. And a fifth that will pay off in post 06: the version of the row in Catalog.
// Ordering's own copy of the book data it needs. Catalog owns the truth; this copy is filled once
// from Catalog's API and kept current by Catalog's events (post 06)
public sealed class CatalogBook
{
public required string Isbn { get; set; }
public required string Title { get; set; }
public decimal Price { get; set; }
public required string Currency { get; set; }
public int Version { get; set; }
public DateTime UpdatedAtUtc { get; set; }
}
Events only carry changes; when Ordering starts for the first time, it knows none of the 40,000 books. An initial load (a backfill) closes the gap we discussed in the "Ordering's copy of the book" section of the Data Ownership post in the Microservices series. We put the load into the migrator we set up in post 02: after applying the migrations, if the table is empty, the migrator reads Catalog's paged list. The HttpClient that goes to Catalog is registered as a typed client, and its address is resolved through service discovery:
// Only the fields Ordering needs; other fields in Catalog's response are ignored (tolerant reader)
public sealed record CatalogBookDto(string Isbn, string Title, decimal Price, string Currency, int Version);
public sealed record CatalogPage(IReadOnlyList<CatalogBookDto> Items, int Page, int PageSize, int TotalCount);
public sealed class CatalogClient(HttpClient http)
{
public async IAsyncEnumerable<CatalogBookDto> GetAllBooksAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
{
const int pageSize = 50;
for (int page = 1; ; page++)
{
CatalogPage? result = await http.GetFromJsonAsync<CatalogPage>(
$"/api/v1/books?page={page}&pageSize={pageSize}", ct);
if (result is null || result.Items.Count == 0)
{
yield break;
}
foreach (CatalogBookDto book in result.Items)
{
yield return book;
}
if (page * pageSize >= result.TotalCount)
{
yield break;
}
}
}
}
// Used only by the migrator for the initial load; the running service never calls Catalog
builder.Services.AddHttpClient<CatalogClient>(client => client.BaseAddress = new Uri("https+http://catalog"));
CatalogBookDto isn't a copy of Catalog's BookSummaryResponse; it's the subset Ordering needs. Author and category arrive but aren't read, and if Catalog adds a new field tomorrow, Ordering isn't affected. One thing was missing: the list response from post 03 didn't contain Version, only the detail did. Instead of sending 40,000 detail requests for 40,000 books, we added a Version field to Catalog's list response. A consumer's need widened the contract; the check we set up in post 03 says it isn't breaking:
oasdiff breaking catalog-03.json src/Services/Catalog/openapi/catalog.json --fail-on ERR
No breaking changes to report, but the specs are different.
oasdiff changelog catalog-03.json src/Services/Catalog/openapi/catalog.json --level INFO
1 changes: 0 error, 0 warning, 1 info
info [response-required-property-added] at src/Services/Catalog/openapi/catalog.json
in API GET /api/v1/books
added the required property `items/items/version` to the response with the `200` status
In the AppHost, Ordering gets a migrator too; only the migrator gets a reference to Catalog:
// Ordering's migrator also fills the local book copy once, from Catalog's API
IResourceBuilder<ProjectResource> orderingMigrator = builder
.AddProject<Projects.Bookstore_Ordering_Api>("ordering-migrator", options => options.ExcludeLaunchProfile = true)
.WithArgs("--migrate")
.WithEnvironment("CatalogCopy__ImportWhenEmpty", "true")
.WithReference(orderingDb)
.WithReference(catalog)
.WaitFor(orderingDb)
.WaitFor(catalog);
builder.AddProject<Projects.Bookstore_Ordering_Api>("ordering")
.WithReference(orderingDb)
.WaitForCompletion(orderingMigrator)
.WithHttpHealthCheck("/health");
The running ordering resource has no reference to Catalog; at runtime it can't even find Catalog. The dependency is visible in the AppHost and belongs only to the first start. The migrator's log:
[ordering-migrator] Applied 1 migration(s): 20261114131117_InitialCreate
[ordering-migrator] Start processing HTTP request GET https+http://catalog/api/v1/books?*
[ordering-migrator] Sending HTTP request GET http://localhost:5101/api/v1/books?*
[ordering-migrator] Received HTTP response headers after 175.4248ms - 200
[ordering-migrator] End processing HTTP request after 197.2066ms - 200
[ordering-migrator] Imported 20 books from Catalog into the local copy
[ordering-migrator] orderingdb is up to date in 962 ms
In service discovery, the address https+http://catalog means "https first, http otherwise"; because Aspire provides Catalog's http endpoint, the request went to http://localhost:5101. Dune's price in the copy is 459: the price we changed in post 03 arrived as it was during the initial load.
The Command: Placing an Order and Freezing the Price
The write side is a single handler. It loads the data the aggregate needs, leaves the decision to the aggregate and saves; there's no call to another service:
public async Task<PlaceOrderOutcome> HandleAsync(PlaceOrderRequest request, CancellationToken ct)
{
string[] isbns = [.. request.Items.Select(i => i.Isbn).Distinct()];
Dictionary<string, CatalogBook> books = await db.CatalogBooks.AsNoTracking()
.Where(b => isbns.Contains(b.Isbn))
.ToDictionaryAsync(b => b.Isbn, ct);
string[] unknown = [.. isbns.Where(isbn => !books.ContainsKey(isbn))];
if (unknown.Length > 0)
{
return new PlaceOrderOutcome.UnknownBooks(unknown);
}
// The price is frozen here: the order gets the price the customer confirmed, or nothing
PriceChange[] changes =
[
.. request.Items
.Where(i => books[i.Isbn].Price != i.UnitPrice)
.Select(i => new PriceChange(i.Isbn, i.UnitPrice, books[i.Isbn].Price))
];
if (changes.Length > 0)
{
return new PlaceOrderOutcome.PricesChanged(changes);
}
Order order = Order.Place(
new Buyer(request.CustomerName.Trim(), request.CustomerEmail.Trim().ToLowerInvariant()),
[.. request.Items.Select(i => new OrderLine(
i.Isbn, books[i.Isbn].Title, new Money(books[i.Isbn].Price, books[i.Isbn].Currency), i.Quantity))],
clock);
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
// Post 06 turns OrderPlaced into an integration event through the outbox
foreach (object domainEvent in order.DomainEvents)
{
logger.LogInformation("Domain event raised: {EventType} {@Event}", domainEvent.GetType().Name, domainEvent);
}
order.ClearDomainEvents();
return new PlaceOrderOutcome.Placed(order);
}
The UnitPrice in the request is the price the customer saw in the cart. In the Communication Styles post of the Microservices series we set a rule for Bookstore: the price written to the order is the price the customer saw and confirmed. The copy can be a few seconds old, the price in the cart can be minutes old; if the two don't match, the order isn't created and the customer is shown the new price. We neither sell at the old price silently nor change the price without asking the customer. The answer to the second question in Guess First:
POST /api/v1/orders
{"customerName":"Ada Lovelace","customerEmail":"ada@example.com","items":[{"isbn":"9780441172719","quantity":1,"unitPrice":489.00}]}
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
Date: Sat, 14 Nov 2026 13:12:11 GMT
Server: Kestrel
Transfer-Encoding: chunked
api-supported-versions: 1.0
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Prices changed","status":409,"detail":"Some prices changed since the cart was shown. Confirm the new prices.","changes":[{"isbn":"9780441172719","cartPrice":489.00,"currentPrice":459.00}],"traceId":"00-13e059566ff9b43598302510d0471df6-3ee217c4ad6c69ac-03"}
Even though the price went down, the order wasn't created. We chose this on purpose: not asking the customer about a price increase is plainly wrong, and not asking about a decrease leads to "the price I saw in the cart was different" complaints. The changes list is enough for Angular to show the new price and get confirmation with one click. A request with the right prices:
POST /api/v1/orders
{"customerName":"Ada Lovelace","customerEmail":"ada@example.com","items":[{"isbn":"9780441172719","quantity":1,"unitPrice":459.00},{"isbn":"9780141439587","quantity":2,"unitPrice":249.90}]}
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 13:12:11 GMT
Server: Kestrel
Location: http://localhost:5102/api/v1/orders/01a1da2f-db1a-7113-afbf-ea42e9c1b208
Transfer-Encoding: chunked
api-supported-versions: 1.0
{"id":"01a1da2f-db1a-7113-afbf-ea42e9c1b208","status":"Pending","placedAtUtc":"2026-11-14T13:12:11.802652","customerName":"Ada Lovelace","customerEmail":"ada@example.com","items":[{"isbn":"9780441172719","title":"Dune","unitPrice":459.00,"quantity":1,"lineTotal":459.00},{"isbn":"9780141439587","title":"Emma","unitPrice":249.90,"quantity":2,"lineTotal":499.80}],"total":958.80,"currency":"TRY"}
The order is Pending; 01a1da2f-db1a at the start of the id is the v7 timestamp in milliseconds. An ISBN that isn't in the catalog and a quantity the aggregate rejects return two separate 400s; in the second, the domain's error code is in the ProblemDetails too:
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Unknown books","status":400,"detail":"Not in the catalog: 9780000000000.","traceId":"00-420631719b93f5f40344ef1e22b2bfdd-636691b255d50436-03"}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"At most 10 copies of 9780441172719 per order.","status":400,"code":"order.too-many-copies","traceId":"00-e1139fbd85509c615471c5d1a30bc518-523ae287c80e8906-03"}
Placing an Order While Catalog Is Down
We tried the first question from Guess First with the Aspire CLI: we stopped the Catalog resource, saw that Catalog no longer answered, and sent an order at the same time.
aspire resource catalog stop
Stopping resource 'catalog'...
✅ Resource 'catalog' stopped successfully.
curl -s -m 5 -o /dev/null -w "catalog: %{http_code}\n" http://localhost:5101/api/v1/books/9780441172719
catalog: 000
curl -s -w "\n%{http_code}\n" -X POST http://localhost:5102/api/v1/orders -H "Content-Type: application/json" -d '{"customerName":"Alan Turing","customerEmail":"alan@example.com","items":[{"isbn":"9780261102217","quantity":1,"unitPrice":403.00}]}'
{"id":"01a1da32-0868-7ade-a383-fc289c8c3b45","status":"Pending","placedAtUtc":"2026-11-14T13:14:34.472968","customerName":"Alan Turing","customerEmail":"alan@example.com","items":[{"isbn":"9780261102217","title":"The Hobbit","unitPrice":403.00,"quantity":1,"lineTotal":403.00}],"total":403.00,"currency":"TRY"}
201
The request to Catalog got no answer within 5 seconds (000), while the order was created with 201. In the monolith, no order could be placed without the Catalog module; here, a Catalog deployment or failure doesn't affect checkout. We cut the link we called temporal coupling in the Communication Styles post of the Microservices series on this edge. The price is that the copy isn't updated while Catalog is down; when Catalog comes back, the events from post 06 will bring the changes made in between.
The Query: A Projection for the Order List
The "my orders" page doesn't need the aggregate: no rules, no changes, only a few columns. The read side is a separate class and never loads the aggregate:
// The read side: no aggregate, no tracking, only the columns the screen needs
public async Task<IReadOnlyList<OrderSummaryResponse>> ListByCustomerAsync(string email, int page, int pageSize, CancellationToken ct)
{
string normalized = email.Trim().ToLowerInvariant();
var rows = await db.Orders.AsNoTracking()
.Where(o => o.Buyer.Email == normalized)
.OrderByDescending(o => o.PlacedAtUtc)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(o => new
{
o.Id,
o.Status,
o.PlacedAtUtc,
ItemCount = o.Items.Sum(i => i.Quantity),
o.Total.Amount,
o.Total.Currency
})
.ToListAsync(ct);
return [.. rows.Select(r => new OrderSummaryResponse(r.Id.Value, r.Status.ToString(), r.PlacedAtUtc, r.ItemCount, r.Amount, r.Currency))];
}
The anonymous type in the middle step has a reason: because OrderId is mapped with a value converter, we can't translate o.Id.Value to SQL; we read the id as is and convert it to a Guid in memory. Complex properties (o.Buyer.Email, o.Total.Amount) are translated to SQL directly. The aggregate for the write side and a projection for the read side in a single database: the simplest form of the separation we described in the second post of the CQRS and Event Sourcing series. If the need for a separate read model (for example a table that combines order and payment state) arises after post 07, the path from the Separate Read Model post of the same series is open.
GET /api/v1/orders?customerEmail=ada@example.com
[{"id":"01a1da2f-db1a-7113-afbf-ea42e9c1b208","status":"Pending","placedAtUtc":"2026-11-14T13:12:11.802652","itemCount":3,"total":958.80,"currency":"TRY"}]
Taking the customer from the query string is specific to this post; anyone who types someone else's email can see their orders. In post 10 the customer identity will come from the token and this parameter will go away.
In Production
Make the initial load repeatable. Our migrator fills the copy when the table is empty. With 40,000 books, if a price changes in Catalog during paging, that change may enter the copy in its old state; the events in post 06 will close that gap, because the event's version will be higher than the one in the copy. Still, a "rebuild the copy" command helps on the day you suspect the copy is broken.
Measure the copy's freshness. That's what the UpdatedAtUtc column is for. "How many minutes ago was the oldest update?" should go onto the dashboard as a metric; when Catalog's events stop, that's what you notice first.
Watch the price mismatch rate. The ratio of 409 "Prices changed" responses to order requests is an indirect indicator of how far behind the copy is. During campaign days, when prices change often, it's normal for this ratio to rise; if it rises on a quiet day, something is wrong with the event flow.
Common Pitfalls
Asking Catalog on every order. It's the easiest way, and it makes Catalog a precondition of checkout. Above, we could place an order while Catalog was down; with an HTTP call that wouldn't have been possible.
Not writing the price into the order. Keeping only the ISBN on the order line and reading the price from Catalog every time it's displayed ties the totals of past orders to today's price in Catalog. An order should carry a snapshot of the price at that moment.
Making the copy identical to Catalog's table. Author, category, description, publication year... Ordering doesn't use them. Every field that goes into the copy is one more link between Catalog and Ordering.
Not making the order request idempotent. If the network drops and the client sends the same request again, the API in this post creates two orders. The client needs to send an Idempotency-Key header, and Ordering needs to return the first order to a second request with the same key; we described the mechanism in the Idempotent External Calls post of the External Service Integration and Resilience series.
A Small Experiment
★☆☆ Change The Hobbit's price in Catalog, then send an order to Ordering with the old price. What happens, since Ordering's copy hasn't been updated yet? How will this behavior change after post 06?
★★☆ Add a RemoveItem(string isbn) method to Order: it should work only on a Pending order, not allow removing the last line, and recalculate the total. Write its tests first.
★★★ Add Idempotency-Key support to POST /api/v1/orders: store the key and the result in a separate table, and for a second request with the same key, return the first order with the same 201 and Location. Verify that sending the same request twice in parallel creates a single order.
Ordering works with its own data and accepts orders even while Catalog is down, but it never asks about stock: it could sell the last copy to two people at once. In the next post we build the Inventory service with gRPC: the inventory.proto contract, a reservation call from Ordering with a deadline, optimistic concurrency when two orders hit the same last copy, the reservation's lifetime, and what checkout does when Inventory slows down.