Shopping cart microservice of MigApp, an e-commerce marketplace platform built on microservices and CQRS.
CartService owns one thing: the buyer's cart. It stores which product variants a buyer wants, in what quantity, and whether each line can still be bought. It does not store prices: they are resolved from local price snapshots every time the cart is read. It does not reserve stock and does not create orders.
- Responsibilities
- Architecture
- Tech stack
- Solution layout
- Domain model
- How it works
- gRPC API
- Currency handling
- Persistence
- Caching
- Observability
- Getting started
- Design decisions
- Related services
| CartService does | CartService does not |
|---|---|
| Keep one active cart per buyer | Reserve or decrement stock (done at order creation) |
| Add, change, remove and clear items (one line per product variant) | Create or pay orders |
| Resolve each item's price at read time from local price snapshots | Decide prices (PricingService does) |
| Mark items unavailable and explain why (including "no price") | Own product data such as names, photos and attributes |
| Return the cart with totals in the buyer's display currency | Fix the final price or amount to pay (OrderService does, using PricingService) |
flowchart LR
Client[Web / mobile client] --> BFF[BFF / API Gateway]
BFF -- gRPC --> Cart[CartService]
BFF -. "name, photo, attributes" .-> PVRS[ProductVariantReadService]
Cart --> DB[(SQL Server)]
Cart --> Cache[(Redis / FusionCache)]
Catalog[CatalogService] -- "product and variant events" --> MQ{{RabbitMQ}}
Pricing[PricingService] -- "price events" --> MQ
Stock[Stock events] --> MQ
Currency[CurrencyService] -- "exchange rates" --> MQ
MQ --> Cart
CartService follows Clean Architecture with CQRS:
- Writes go through the
Cartaggregate and EF Core. - Reads are served by query handlers that use
AsNoTrackingqueries on the sameDbContextand map the result to read DTOs. Reads never go through aggregate behaviour. - Other services' data arrives as integration events and is kept in local snapshots, so the cart never calls other services on the hot path.
| Area | Technology |
|---|---|
| Runtime | .NET (see global.json / TargetFramework) |
| API | gRPC (cart.v1) |
| CQRS | MediatR with validation and tracing pipeline behaviours |
| Writes | EF Core, SQL Server |
| Reads | EF Core (AsNoTracking queries mapped to read DTOs), SQL Server |
| Messaging | MassTransit over RabbitMQ |
| Cache | FusionCache (memory + Redis, backplane) |
| Observability | OpenTelemetry (traces, metrics), Serilog (JSON logs) |
| Errors | Result pattern with error codes, no exceptions for business failures |
| Shared code | migApp.Shared (results, value objects, money conversion, behaviours, caching, gRPC helpers) |
CartService
├── CartService.Domain Aggregates, entities, value objects, domain events, errors
├── CartService.Application Commands, queries, integration event handlers, DTOs, interfaces
├── CartService.Infrastructure EF Core configurations, repositories, messaging, cache, DI
└── CartService.Api gRPC services, proto files, mappers, Program.cs
Dependencies point inwards: Api → Application → Domain, and Infrastructure → Application.
classDiagram
class Cart {
+Guid BuyerId
+long Version
+AddItem()
+ChangeQuantity()
+RemoveItem()
+Clear()
+RemoveUnavailable()
+SuspendItem()
+TryRestoreItem()
}
class CartItem {
+Guid VariantId
+Guid ProductId
+Guid VendorId
+Quantity Quantity
+bool IsAvailable
}
class CartItemSuspensionReason {
<<enumeration>>
}
Cart "1" *-- "0..100" CartItem
CartItem --> "0..*" CartItemSuspensionReason
Cart is the aggregate root. There is one cart per buyer. It owns its items and protects the invariants below. It carries a Version used for optimistic concurrency and raises a domain event for every change.
CartItem is one line, identified by VariantId inside the cart. It stores only what the cart needs for business rules: variant, product and vendor ids, quantity and a set of suspension reasons. It holds no price. The price is resolved when the cart is read (see Currency handling).
Value objects: Quantity. Money and Currency come from migApp.Shared and are used by price snapshots and by read-side conversion, not by the aggregate.
- A cart holds at most
Cart.MaxDistinctItems(100) distinct variants. - Adding a variant that is already in the cart increases its quantity instead of creating a duplicate line.
- Quantity is validated by the
Quantityvalue object, including the per-item upper limit. - An item is available only when it has no suspension reasons. Unavailable items cannot be increased or changed, only removed.
- Suspension reasons are a set, not a single status. Each source (product visibility, variant visibility, stock) adds or removes only its own reason, so a product becoming visible again never hides the fact that the variant is out of stock.
- An item whose variant has no price snapshot is shown as unavailable with the reason
PriceUnavailable. This is computed on read and is not stored in the aggregate. - The subtotal and the totals count only available items.
- Domain methods receive the current time as a parameter and never call
DateTime.UtcNow.
sequenceDiagram
participant C as Client / BFF
participant G as gRPC API
participant H as Command handler
participant A as Cart aggregate
participant DB as SQL Server
C->>G: AddCartItem(buyer, variant, quantity, currency)
G->>H: AddCartItemCommand
H->>DB: load cart and variant, product, price and stock snapshots
H->>A: AddItem(...)
A-->>H: Result
H->>DB: SaveChanges
G->>G: GetCartQuery
G-->>C: CartResponse
Command handlers return only a Result. The API layer then runs GetCartQuery, so every mutation answers with the fresh cart (same database, so it reads its own writes). Totals, availability and prices are always computed on the server.
Adding an item requires that the variant has a price snapshot, so an item without any price cannot enter the cart.
- The query handler loads the cart (items, quantities, suspension reasons) from the cache or the database. The cached value contains no prices.
- One query loads the price snapshots of all variants in the cart: the explicit price in the requested currency and the base price.
- For every item the price is chosen: the explicit price in the buyer's currency if there is one, otherwise the base price. Items with no snapshot become unavailable (
PriceUnavailable). - Exchange rates are requested only for chosen prices that are not in the requested currency, one rate per source currency (
source → target). - The mapper converts and rounds the unit price once, then computes
line = unit × quantityand the totals. - If a required rate is unavailable, the query fails with
ExchangeRateUnavailable. The cart never returns a mix of currencies in one response.
Names, photos and attributes of variants are not part of this response. The BFF composes them from ProductVariantReadService, using the variant ids in the cart.
CartService keeps local snapshots so it can validate and update carts without calling other services.
| Source | What changes in CartService |
|---|---|
| CatalogService | Product and variant snapshots (visibility), suspension reasons on affected cart items |
| PricingService | Price snapshots: one per Pricing price (variant and currency), with an IsBase flag. Created, updated or removed by price events. Cart items are not touched. |
| Stock events | Out-of-stock suspension reason on affected cart items |
| CurrencyService | Exchange rates in the cache |
Snapshot handlers are idempotent and version-guarded: an event whose Version is not newer than the stored one is ignored and counted in a metric. An event for a snapshot that does not exist yet raises SnapshotNotFoundException so the broker retries it later.
Contract: Grpc/V1/Protos/cart.proto, package cart.v1.
| RPC | Purpose |
|---|---|
GetCart |
Return the cart in the requested currency (empty cart if none exists) |
AddCartItem |
Add a variant or increase its quantity |
ChangeCartItemQuantity |
Set a new quantity for an item |
RemoveCartItem |
Remove one item |
RemoveUnavailableCartItems |
Remove all unavailable items |
ClearCart |
Remove everything |
Every RPC takes buyer_id and a currency code, and returns CartResponse:
CartResponse
├── cart_id
├── items[] variant_id, product_id, vendor_id, quantity,
│ unit_price_minor, line_total_minor,
│ is_available, suspension_reasons[]
└── totals distinct_items, total_quantity, subtotal_minor, currency
All money values are integers in minor units of totals.currency, which is always the requested currency. An item without a price has unit_price_minor = 0 and the reason PriceUnavailable.
Business failures are returned as Result errors with stable error codes and translated into gRPC status codes by a server interceptor. Clients should rely on the error code, not on the message.
Prices are owned by PricingService and are stored in the seller's currency. For one variant PricingService can hold a base price (always) and explicit prices for other markets, one per currency. CartService mirrors them as price snapshots keyed by (variant, currency).
| Situation | Result |
|---|---|
| An explicit price exists in the buyer's currency | Used as is, no conversion |
| No explicit price, the base price exists | Base price converted to the buyer's currency at read time |
| No snapshot for the variant | Item is unavailable (PriceUnavailable) and excluded from totals |
- The rate is requested from the price's own currency straight to the target (
source → target). Prices are never routed through a third currency, so there is no intermediate rounding. - Rounding happens once, at the end, to the minor unit of the target currency, away from zero. The shared helper
ConvertToMinor(inmigApp.Shared) is the only place with this formula. - The unit price is converted and rounded first, then multiplied by quantity, so
unit × quantity = linealways holds on screen and the subtotal equals the sum of the lines. - Rates are cached for an hour with fail-safe, so a short outage of the rate source does not break the cart. When no rate is available for a price that needs conversion, the whole query fails instead of mixing currencies.
Amounts in the cart are an estimate. OrderService takes authoritative prices from PricingService at checkout and records the price, currency and exchange rate used. The cart is never a source of truth for payment.
SQL Server, schema CartWrite.
| Table | Notes |
|---|---|
Carts |
Unique index on BuyerId. Version is the concurrency token. |
CartItems |
Unique (CartId, VariantId). Indexes on ProductId and VariantId for snapshot synchronisation. No price columns. Suspension reasons are stored as a primitive collection. |
ProductVariantPriceSnapshots |
Key PriceId (the Pricing price id). ProductVariantId, Currency, AmountMinor, IsBase and the source Version are plain columns. Unique index on (ProductVariantId, Currency) and a filtered unique index on ProductVariantId where IsBase, so a variant has exactly one base price. UnitPrice is a computed Money, not a column. |
Product and variant snapshots live in their own tables and also carry the source Version.
Writes use EF Core and the aggregate. Reads go through the same DbContext but bypass aggregate behaviour: AsNoTracking queries mapped to read DTOs.
FusionCache with a memory layer, Redis as the distributed layer and a Redis backplane. The cache has its own name so that backplane channels do not collide with other services sharing the same Redis.
- Cart state: cached per buyer under one key. It holds items, quantities and suspension reasons, and no prices and no currency, so one entry serves every currency and price changes never invalidate it. The entry must be invalidated after every successful change to the cart, including changes made by snapshot synchronisation (for example a new suspension reason).
- Price snapshots: not cached. They are read with one indexed query per cart read.
- Exchange rates: cached for one hour with fail-safe enabled.
Cache hits and misses come from FusionCache's own OpenTelemetry instrumentation.
- Traces and metrics: OpenTelemetry (ASP.NET Core, HTTP client, FusionCache, MassTransit, runtime), exported over OTLP.
- Application metrics:
ICartMetricsfor things the infrastructure cannot see, for example outdated or missing snapshots. - Logs: Serilog, JSON to the console and OTLP, enriched with service name, machine name and thread id.
- Health: gRPC health checks for SQL Server, Redis and RabbitMQ.
- .NET SDK (version from the repository's
global.jsonor project files) - SQL Server, Redis and RabbitMQ (use the Compose file below for local development)
# docker-compose.yml (example for local development only)
services:
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
ACCEPT_EULA: "Y"
MSSQL_SA_PASSWORD: "<strong-password>"
ports: ["1433:1433"]
redis:
image: redis:7
ports: ["6379:6379"]
rabbitmq:
image: rabbitmq:3-management
ports: ["5672:5672", "15672:15672"]Use user secrets or environment variables for anything sensitive.
{
"ConnectionStrings": {
"Database": "Server=localhost,1433;Database=CartService;User Id=sa;Password=<strong-password>;TrustServerCertificate=True",
"Redis": "localhost:6379",
"OtlpEndpoint": "http://localhost:4317"
},
"RabbitMQ": {
"Host": "localhost",
"Port": 5672,
"VirtualHost": "/",
"Username": "<username>",
"Password": "<password>"
}
}The service validates this configuration on startup and fails fast with a clear message if a required value is missing.
# apply migrations
dotnet ef database update \
--project src/CartService.Infrastructure \
--startup-project src/CartService.Api
# start the service
dotnet run --project src/CartService.ApiAdjust the project paths to match the repository layout.
dotnet testNo stock reservation in the cart. Most carts are abandoned, so reserving on add would block real sales. The cart does a soft check for the buyer's convenience; the authoritative check and the reservation happen when the order is created.
Suspension reasons instead of a status enum. With one status, restoring a product to "available" could overwrite an out-of-stock state. A set of reasons makes each event independent of the order in which events arrive.
Local snapshots instead of synchronous calls. The cart keeps working when other services are slow or down, and checks stay in one database. The price is eventual consistency, which the checkout re-validation covers.
Prices are resolved on read, not stored in the cart. The price shown always matches the storefront for the buyer's currency, and a price change does not have to be pushed into every cart that contains the variant. It also keeps the cached cart free of prices, so price events never invalidate it.
Explicit market price first, converted base price second. A seller's own price for a market is shown exactly as set. Only when there is none is the base price converted, so a seller's number is never replaced by a rate-dependent one without need.
Convert from the price's own currency, round once. Converting through a pivot currency and rounding in between makes a round trip lose a unit. A direct source → target conversion with one rounding at the end avoids that.
Never mix currencies in one response. If a rate is missing, the query fails. A partial conversion would make the subtotal meaningless.
Cache the cart without prices, convert after the cache. One cache entry per buyer instead of one per buyer and currency, and one invalidation point.
Commands return Result, the API layer returns the cart. The command side stays free of read concerns, while clients still get the full, fresh cart in one round trip.
Product details come from the read service, not from the cart. Names and photos live in one place and cannot drift. The BFF composes them, and variants hidden from the public simply come back without details and are shown as unavailable.
| Service | Relationship |
|---|---|
| CatalogService / ProductVariantReadService | Source of product and variant data, and of display details used by the BFF |
| PricingService | Source of variant prices: a base price and explicit prices per market |
| CurrencyService | Source of exchange rates |
| OrderService | Consumes the cart at checkout, takes authoritative prices from PricingService and re-validates stock |