E-Commerce Microservices

Build a distributed e-commerce system in C#: three microservices over gRPC and RabbitMQ, using the saga pattern for transactions.

Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

Expert Project — gRPC, MassTransit, Saga Pattern, Docker

🧠 Project Overview

You are building the back end of an online shop as three separate programs instead of one. A product service owns the catalogue, an order service owns orders, an inventory service owns stock levels, and each keeps its own database that the other two are not allowed to open. They cooperate in two deliberately different ways. When the order service needs to know a price right now, it asks the product service a direct question over gRPC and waits for the answer. When an order has been placed, nobody waits: the order service announces the fact to RabbitMQ and carries on, and the inventory service finds out a few milliseconds later by picking the message off a queue.

When the project works, one docker compose up brings the three services, their databases and the broker up together. You POST a basket to the order service; it confirms each product exists and reads the current price straight from the catalogue, writes an order with status Pending, publishes an OrderCreatedEvent, and answers the HTTP request immediately. The order has been accepted, not yet confirmed — and that distinction is the whole lesson. Moments later the inventory service moves the quantities from available to reserved inside a single database transaction and publishes StockReservedEvent, or, if a line cannot be filled, publishes StockReservationFailed instead and the order is cancelled. Open RabbitMQ's management interface while you place an order and you can watch the messages move between queues.

This is the shape almost every shop grows into once it is bigger than one team. Catalogue reads outnumber order writes by a wide margin, so the catalogue wants to scale on its own. The pricing team wants to deploy on Tuesday without asking the checkout team's permission. Checkout should keep taking money when the recommendation service falls over. None of that is free — you are trading a straightforward monolith for network calls that can fail, message ordering you do not control, and three sets of logs — so treat this project as a way to learn what that trade actually costs, not as proof that more services are better.

The hard part has a name. In a single database you would wrap "record the order" and "decrement the stock" in one transaction and let the engine guarantee both or neither. Across three databases that transaction does not exist. So you replace one atomic operation with a sequence of local ones, each committed on its own, plus a plan for undoing the earlier steps when a later one refuses. That plan is the saga, and the events listed below are its steps.

🎯 Requirements

🧩 Core Concepts

A service owns its data

Each service has a database only it connects to. The order service never runs a SQL query against the product tables, even though that would be the fastest way to get a price. This is the rule that makes the rest of it work: if two services share a table, neither can change its schema without breaking the other, and you have a monolith with extra network hops. The cost of the rule is that you now duplicate a little data — the order stores the product's name and price at the moment it was placed — and that duplication is correct, because an order should not change its own history when a price changes tomorrow.

Two kinds of conversation

Synchronous means the caller waits and the answer comes back on the same connection; that is gRPC here. Asynchronous means the sender hands the message to a broker and forgets about it; that is RabbitMQ. Pick synchronous when you need an answer to continue and cannot proceed without it, and accept that the callee's downtime becomes your downtime. Pick asynchronous when you are stating a fact rather than requesting a result — the broker holds the message while the consumer is restarting, and nothing upstream notices.

gRPC is contract-first

You do not write the client in this project; you write a .proto file that describes the service's methods and message shapes, and the build generates both the server base class and the strongly typed client from it. Messages travel as compact binary over HTTP/2 rather than as JSON text, which is why it is worth using on an internal hot path and pointless for a browser. The generated names follow the proto — a service declared as ProductService is where the ProductService.ProductServiceClient in the order service comes from, and it appears the moment the proto is added to the project, not because somebody hand-wrote it.

Commands and events are not the same message

A command is an instruction to one specific service, named in the imperative: CreateOrderCommand. It has exactly one handler and it can be refused. An event is an announcement that something already happened, named in the past tense: OrderCreatedEvent. It has any number of subscribers, including zero, and refusing it is meaningless — the thing has already occurred. Getting this distinction into your naming keeps the design honest, because the moment a publisher starts caring who consumes its event, it has really sent a command.

The saga, and compensation instead of rollback

A saga is a business transaction split into local transactions, each of which commits immediately, paired with a compensating action that semantically undoes it. There is no rollback across services: once the order row is committed, the only way to "undo" it is to write another row that cancels it. This project uses the choreographed style — each service reacts to the previous service's event and there is no central coordinator. It is the simplest form and the right one for three steps; the alternative, orchestration, puts one state machine in charge of the whole flow and earns its keep once the flow has eight steps and branching.

Eventual consistency

For a short window after your HTTP response, the order exists and the stock has not moved. The system is inconsistent, and that is by design rather than a bug to be fixed. What this changes is the user interface: the checkout page cannot say "purchased", it says "processing", and something has to tell the customer when the state actually settles. Nearly every ugly failure in an event-driven system comes from a screen that was written as if the work were already done.

At-least-once delivery, so handlers must be idempotent

A broker guarantees that a message is delivered at least once, not exactly once. If a consumer crashes after doing its work but before acknowledging the message, the broker redelivers it and the work happens twice. Idempotent means running the handler a second time with the same message changes nothing further — and reserving stock is not naturally idempotent, so you have to make it so, usually by recording which message identifiers you have already processed and returning early when one comes round again.

Docker Compose is the topology

Compose is not decoration on this project; it is the only sane way to run five or six processes at once. Each service becomes a container on a shared network where the service name is the hostname, so a connection string points at the database container by name rather than at localhost. Configuration arrives as environment variables, which is exactly why the code below reads its connection string through configuration instead of hard-coding it — the same binary runs on your laptop and in the cluster with different values.

Step 1: Product Service (gRPC + REST)

This service has two front doors onto one database, and that is the point of the file. The REST endpoints are for the outside world — a browser, a mobile app, a curl command you type while debugging — and speak JSON over ordinary HTTP. The gRPC service registered by MapGrpcService is for the other services, and speaks a binary contract that the compiler checks. Two doors, one owner: whichever way a caller arrives, the product table is read and written by this service alone.

AddDbContext registers the context so ASP.NET Core builds one per request and disposes it when the request ends, which is what you want for a short-lived unit of work. Notice that the connection string is fetched by the name Products rather than written into the code: under Compose that value arrives as an environment variable pointing at a container hostname, and on your machine it points at a local database, with no rebuild in between.

The list endpoint filters on IsActive instead of deleting rows, because orders reference products and a product that vanishes turns old orders into gaps. Deactivating hides it from the catalogue while keeping it fetchable by the services that need it. The single-product endpoint uses a pattern match — is Product p — so a missing row becomes a real 404 rather than a 200 carrying null, which is the response that quietly breaks clients. And the POST binds a CreateProductDto, not the entity: the request body is allowed to carry a name, description, price and stock, and nothing else. The identifier, the created timestamp and the active flag are decided by the server, in UTC, because a client that can set its own primary key or backdate a row is a client that will.

// ProductService/Program.cs — Minimal API
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ProductDbContext>(o =>
    o.UseNpgsql(builder.Configuration.GetConnectionString("Products")));
builder.Services.AddGrpc();

var app = builder.Build();
app.MapGrpcService<ProductGrpcService>();

// Minimal API endpoints for REST clients
app.MapGet("/api/products", async (ProductDbContext db) =>
    await db.Products.Where(p => p.IsActive).ToListAsync());

app.MapGet("/api/products/{id:int}", async (int id, ProductDbContext db) =>
    await db.Products.FindAsync(id) is Product p
        ? Results.Ok(p)
        : Results.NotFound());

app.MapPost("/api/products", async (CreateProductDto dto, ProductDbContext db) =>
{
    var product = new Product
    {
        Name = dto.Name,
        Description = dto.Description,
        Price = dto.Price,
        Stock = dto.Stock,
        IsActive = true,
        CreatedAt = DateTime.UtcNow
    };
    db.Products.Add(product);
    await db.SaveChangesAsync();
    return Results.Created($"/api/products/{product.Id}", product);
});

app.Run();

The mistake to look for here is the one this code has not yet fixed: the two read endpoints disagree. The list filters out inactive products; fetching by id returns them. A discontinued product is therefore invisible in the catalogue and perfectly visible to anyone who guesses the URL. Neither behaviour is wrong on its own — inter-service lookups genuinely do need to resolve retired products — but having both without deciding which is which is how a bug is born. Make the REST route apply the same filter and let the gRPC method be the one that sees everything, since its callers are your own services.

The second thing missing is validation. Nothing rejects an empty name, a negative price or a negative stock count, so the first bad row is created happily and lives in your database forever, and every downstream service inherits it. Validate at the edge, where the data enters the system, and return a 400 that says which field was wrong — the alternative is discovering it later as a negative order total that nobody can explain.

Step 2: Order Service

The handler is a plain class rather than a lambda inside an endpoint, and that is worth copying. All three of its collaborators — the database context, the generated gRPC client, and the publisher — arrive through the constructor, so the whole of the order-placement rule can be exercised from a test with fakes in their place and no web server running at all. An endpoint that reads a request and calls this method is then trivial and boring, which is what endpoints should be.

Read the price handling carefully, because it is the security decision in this file. The request says which products and how many; it does not say what they cost. The handler goes and asks the product service for the current price of each line and computes Total from what it got back. Trusting a client-supplied price is not a shortcut, it is a discount coupon anyone can print. Then it copies the name and unit price into the order rather than storing only a product id, so the order records what was actually bought at what it actually cost — a repricing next month must not rewrite last month's receipts.

Notice equally what the handler does not do: it never looks at stock. Availability belongs to the inventory service, and asking here would mean two services holding an opinion about the same number. The order service records intent and lets the owner of the stock decide whether that intent can be honoured. Finally, the order is saved before the event is published, and the order of those two lines is not cosmetic — publish first and a consumer can start reacting to an order that no query will find yet.

// OrderService/Handlers/CreateOrderHandler.cs
using MassTransit;

public class CreateOrderHandler
{
    private readonly OrderDbContext _db;
    private readonly ProductService.ProductServiceClient _products;
    private readonly IPublishEndpoint _publisher;

    public CreateOrderHandler(OrderDbContext db,
        ProductService.ProductServiceClient products,
        IPublishEndpoint publisher)
    {
        _db = db;
        _products = products;
        _publisher = publisher;
    }

    public async Task<Order> HandleAsync(CreateOrderCommand cmd)
    {
        // 1. Validate products via gRPC
        var items = new List<OrderItem>();
        foreach (var item in cmd.Items)
        {
            var product = await _products.GetProductAsync(
                new ProductRequest { Id = item.ProductId });

            if (product == null)
                throw new NotFoundException($"Product {item.ProductId} not found");

            items.Add(new OrderItem
            {
                ProductId = item.ProductId,
                ProductName = product.Name,
                UnitPrice = (decimal)product.Price,
                Quantity = item.Quantity
            });
        }

        // 2. Create order
        var order = new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = cmd.CustomerId,
            Items = items,
            Total = items.Sum(i => i.UnitPrice * i.Quantity),
            Status = OrderStatus.Pending,
            CreatedAt = DateTime.UtcNow
        };

        _db.Orders.Add(order);
        await _db.SaveChangesAsync();

        // 3. Publish event for other services
        await _publisher.Publish(new OrderCreatedEvent
        {
            OrderId = order.Id,
            CustomerId = cmd.CustomerId,
            Total = order.Total,
            Items = items.Select(i => new OrderItemEvent
            {
                ProductId = i.ProductId,
                Quantity = i.Quantity
            }).ToList()
        });

        return order;
    }
}

The pitfall hiding in plain sight is the null check. A generated gRPC client does not hand you null when the server could not find a row — it throws. A server that reports a missing product with a not-found status makes the await throw an RpcException, and this if never runs; a server written to return an empty message instead gives you a non-null object with a blank name and a zero price, and the if still never runs, which is worse because the order is created for nothing. Decide which convention the proto uses, then catch RpcException and inspect its status code, or check a found flag on the response. Do not check for null.

Two more, both structural. The loop makes one network round trip per basket line, so a twenty-item order is twenty sequential calls — the N+1 problem, moved from a database onto a network where each hop costs far more. Add a method to the proto that takes a list of ids and returns a list of products, and fetch them in one call. And the cast on product.Price is a warning sign: protobuf has no decimal type, so money is crossing the wire as a floating-point number and being converted back on arrival. Floating point cannot represent every value a price can take, and the error compounds once you multiply by quantity and sum. Carry money as an integer count of minor units, or as a string the receiver parses into a decimal.

The deepest issue is that saving and publishing are two systems and one of them can fail. If the process dies between SaveChangesAsync and Publish, the order sits at Pending forever with nobody told to reserve anything. The standard fix is the transactional outbox: write the message into a table in the same transaction as the order, and let a separate process deliver it to the broker afterwards. Then the only thing that can be lost is a delivery you can retry, not the intent itself.

Step 3: Inventory Consumer (Saga)

Implementing IConsumer<OrderCreatedEvent> is the whole subscription. MassTransit sees the interface, binds a queue for this consumer to the exchange that carries the event type, and for every message creates a fresh dependency-injection scope with its own database context before calling Consume. You never write connection code, and you never share a context between messages — which matters, because two orders can be consumed concurrently.

The explicit transaction wraps the entire loop rather than each line, and that is the design. An order for three items either reserves all three or reserves none; a half-reserved order is worse than a rejected one, because now stock is held for a purchase that will never complete and nothing in the system is tracking it. Notice too that stock is modelled as two numbers rather than one. Reserved units are neither sellable nor shipped, and keeping them separate is what lets you release them cleanly when an order is cancelled and convert them into a real decrement when it ships.

Every path out of this method publishes exactly one outcome, and that is the saga's next step whichever way it went. Success publishes StockReservedEvent; insufficient stock publishes StockReservationFailed with a reason, which the order service consumes to cancel the order it accepted a moment ago. The failure event is a normal business result, not an error — the exception path below it is for the different case where the database itself would not cooperate. The logging uses named placeholders rather than string interpolation, so the order id is captured as a queryable field and you can pull one order's journey out of three services' logs.

// InventoryService/Consumers/OrderCreatedConsumer.cs
using MassTransit;

public class OrderCreatedConsumer : IConsumer<OrderCreatedEvent>
{
    private readonly InventoryDbContext _db;
    private readonly ILogger<OrderCreatedConsumer> _logger;
    private readonly IPublishEndpoint _publisher;

    public OrderCreatedConsumer(InventoryDbContext db,
        ILogger<OrderCreatedConsumer> logger,
        IPublishEndpoint publisher)
    {
        _db = db; _logger = logger; _publisher = publisher;
    }

    public async Task Consume(ConsumeContext<OrderCreatedEvent> context)
    {
        var order = context.Message;
        _logger.LogInformation("Reserving stock for order {OrderId}", order.OrderId);

        using var transaction = await _db.Database.BeginTransactionAsync();
        try
        {
            foreach (var item in order.Items)
            {
                var stock = await _db.StockItems
                    .FirstOrDefaultAsync(s => s.ProductId == item.ProductId);

                if (stock == null || stock.Available < item.Quantity)
                {
                    // Publish failure event
                    await _publisher.Publish(new StockReservationFailed
                    {
                        OrderId = order.OrderId,
                        Reason = $"Insufficient stock for product {item.ProductId}"
                    });
                    return;
                }

                stock.Available -= item.Quantity;
                stock.Reserved += item.Quantity;
            }

            await _db.SaveChangesAsync();
            await transaction.CommitAsync();

            // Publish success event
            await _publisher.Publish(new StockReservedEvent
            {
                OrderId = order.OrderId
            });

            _logger.LogInformation("Stock reserved for order {OrderId}", order.OrderId);
        }
        catch (Exception ex)
        {
            await transaction.RollbackAsync();
            _logger.LogError(ex, "Stock reservation failed for {OrderId}", order.OrderId);
            throw;
        }
    }
}

The bug this code will hit under load is the read-modify-write race. stock.Available -= item.Quantity reads a value into memory, subtracts in C#, and writes it back. Two consumers handling two orders for the same product at the same time both read 10, both write 8, and you have sold twelve units of a ten-unit product. The fix is not a lock in C#, because a second instance of the service would not see it — you need the database in the argument. Add a concurrency token to the stock row so a stale update fails loudly, or perform the decrement as a conditional update that only succeeds while enough stock remains.

The second problem is that this consumer is not idempotent. Redelivery is normal — a broker restart, a network blip, an acknowledgement lost after the commit — and running this method twice for one order reserves the stock twice. Keep a table of processed order identifiers, written inside the same transaction as the reservation, and return immediately when a message you have already handled comes round again. It is a small amount of code and it is the difference between a system that survives an outage and one that quietly corrupts itself during one.

Two smaller things worth fixing while you are in here. The early return on insufficient stock leaves the transaction to be undone by disposal rather than saying so, which happens to work but hides the intent from the next reader — roll back explicitly. And rethrowing at the end of the catch is correct, because that is how MassTransit learns the message failed and applies its retry policy before moving it aside — but it means a genuinely poisonous message will be retried and then parked in an error queue that nobody is looking at. Decide now who watches that queue.

📋 Common Errors

❌ Grpc.Core.RpcException: Status(StatusCode="Unavailable", ...)

The order service could not reach the product service at all — wrong host, wrong port, container not started yet, or the callee still booting while the caller was already taking traffic. The detail text usually names the underlying connection failure. Under Compose the address must be the product service's container name on the shared network, and starting in a defined order does not guarantee the process inside is ready, so the client needs a retry rather than an assumption.

❌ The remote certificate is invalid because of errors in the certificate chain: UntrustedRoot

A gRPC call over TLS to a service using the local development certificate, from inside a container that has never been told to trust it. It works on your host machine because the dev certificate is trusted there. For container-to-container traffic on a private network, either mount and trust a real certificate, or terminate TLS at the edge and let internal calls run over plaintext HTTP/2 on the internal network only.

❌ gRPC calls fail immediately with an HTTP/2 protocol error

gRPC requires HTTP/2 end to end, and the two sides have not agreed on it. Either the server's endpoint is configured for HTTP/1.1, or the client is pointed at a plaintext address while the server negotiates HTTP/2 only over TLS. Configure the Kestrel endpoint that hosts the gRPC service to speak HTTP/2 and give the client an address with a matching scheme. A proxy or load balancer in the middle that does not forward HTTP/2 produces the same symptom.

❌ Npgsql.NpgsqlException: Failed to connect to 127.0.0.1:5432

The connection string still says localhost, and inside a container that means the container itself, where nothing is listening. Point the host at the database's service name from your Compose file. This is the single most common first-run failure of this whole project, and it looks like a database problem when it is a networking one.

❌ RabbitMQ.Client.Exceptions.BrokerUnreachableException: None of the specified endpoints were reachable

The bus could not connect to RabbitMQ: wrong hostname, wrong credentials, or the broker is still starting — it takes noticeably longer to become ready than an application container does. The client-facing port is not the same one as the management interface, so check which of the two your configuration is pointing at before assuming the credentials are wrong.

❌ Unable to resolve service for type 'MassTransit.IPublishEndpoint' while attempting to activate 'CreateOrderHandler'.

The container has nothing registered for a constructor parameter. Either the bus was never added to the service collection in this service's startup, or it was added after the application was built. The same message with the generated client type in it means the gRPC client was never registered with an address, which is a separate registration from AddGrpc on the server side.

❌ No error at all — messages pile up in a queue whose name ends in _skipped

The publisher is publishing and no consumer is bound to that message type, so the broker has nowhere to route it and MassTransit moves it aside. Almost always the two services do not agree on the message contract: the event class is declared separately in each project with a different namespace, so they are different types as far as routing is concerned. Put the contracts in one shared project that both services reference.

❌ Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: Database operation expected to affect 1 row(s) but actually affected 0 row(s).

You added a concurrency token to the stock row and it is doing its job: another consumer changed that row between your read and your write, so the update matched nothing. This is a success, not a fault — it is the oversell being prevented. Catch it, reload the current values, and retry the reservation a bounded number of times before publishing the failure event.

❌ Npgsql.PostgresException: 40P01: deadlock detected

Two consumers each hold a lock the other wants, because two orders touched the same two products in opposite orders. Postgres breaks the tie by killing one transaction. Sort the order lines by product id before the loop so every consumer takes its locks in the same sequence, and keep the transaction as short as possible — which is another argument for not publishing to the broker while it is still open.

❌ CS0266: Cannot implicitly convert type 'double' to 'decimal'. An explicit conversion exists (are you missing a cast?)

Raised at build time when you drop the cast on the price coming back from gRPC, and it is the compiler telling you something real: the two types do not mean the same thing, and the conversion loses information. The cast silences it. The proper answer is to stop putting money in a floating-point field in the contract at all.

❌ System.InvalidOperationException: The entity type 'OrderItem' requires a primary key to be defined.

Entity Framework found a collection of order items hanging off the order and does not know how to store them. Either give the item type its own key and a foreign key back to the order, or configure it as owned by the order so its rows are managed as part of the parent. Thrown when the model is first built, which is usually the first request rather than startup.

🚀 Enhancement Ideas & Next Steps

1. Close the saga in the order service

The three steps above publish the events but nothing consumes the last two, so orders never leave Pending. Add two consumers to the order service: one for StockReservedEvent that moves the order to confirmed, one for StockReservationFailed that cancels it and records the reason. That is the compensating half of the saga, and until it exists you have an event-driven system that only tells one side of the story.

2. Make every consumer idempotent

Add a processed-messages table with the message identifier as its primary key, insert into it inside the same transaction as the work, and treat a duplicate-key violation as "already done, acknowledge and move on". Then prove it: run the service, take the same event, and redeliver it deliberately from the management interface. If stock moves twice, you have found the bug before production did.

3. Add a transactional outbox

Persist outgoing messages into a table in the same database transaction that writes the order, and deliver them to the broker from that table afterwards. This removes the last window in which a crash can lose an event, and MassTransit ships an outbox implementation so you are configuring a supported feature rather than inventing a delivery mechanism.

Related lessons