Skip to content

About

Shopping cart microservice for the MigApp marketplace: .NET, gRPC, CQRS and DDD, EF Core, MassTransit, multi-currency display.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

CartService

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.

Contents

Responsibilities

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)

Architecture

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
Loading

CartService follows Clean Architecture with CQRS:

  • Writes go through the Cart aggregate and EF Core.
  • Reads are served by query handlers that use AsNoTracking queries on the same DbContext and 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.

Tech stack

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)

Solution layout

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.

Domain model

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
Loading

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.

Business rules

  • 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 Quantity value 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.

How it works

Write path

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
Loading

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.

Read path

  1. The query handler loads the cart (items, quantities, suspension reasons) from the cache or the database. The cached value contains no prices.
  2. One query loads the price snapshots of all variants in the cart: the explicit price in the requested currency and the base price.
  3. 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).
  4. Exchange rates are requested only for chosen prices that are not in the requested currency, one rate per source currency (source → target).
  5. The mapper converts and rounds the unit price once, then computes line = unit × quantity and the totals.
  6. 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.

Integration events (inbound)

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.

gRPC API

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.

Currency handling

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).

Which price the buyer sees

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

Conversion rules

  • 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 (in migApp.Shared) is the only place with this formula.
  • The unit price is converted and rounded first, then multiplied by quantity, so unit × quantity = line always 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.

What the cart amounts mean

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.

Persistence

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.

Caching

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.

Observability

  • Traces and metrics: OpenTelemetry (ASP.NET Core, HTTP client, FusionCache, MassTransit, runtime), exported over OTLP.
  • Application metrics: ICartMetrics for 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.

Getting started

Prerequisites

  • .NET SDK (version from the repository's global.json or project files)
  • SQL Server, Redis and RabbitMQ (use the Compose file below for local development)

Local infrastructure

# 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"]

Configuration

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.

Run

# apply migrations
dotnet ef database update \
  --project src/CartService.Infrastructure \
  --startup-project src/CartService.Api

# start the service
dotnet run --project src/CartService.Api

Adjust the project paths to match the repository layout.

Tests

dotnet test

Design decisions

No 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.

Related services

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

About

Shopping cart microservice for the MigApp marketplace: .NET, gRPC, CQRS and DDD, EF Core, MassTransit, multi-currency display.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages