Lesson 27 of 45 advanced

Service-to-Service Communication

REST, messages, retries, the outbox and why consumers must expect duplicates

Guess first

Pick an answer. You will see if you were right at the end of the lesson.

Why must a message consumer usually be idempotent?

Open interactive version (quiz + challenge)

Real-world analogy

Phoning a shop is synchronous: you wait on the line, and if nobody picks up, your whole afternoon waits. Dropping a letter in their mailbox is asynchronous: you walk away, and they read it when they can. But the postman may deliver the same letter twice after a mix-up, so the shop must notice "I already handled this one". And if you write the letter but forget to post it, the shop never learns about your order. The phone call is a request, the mailbox is a broker, and the duplicate check is idempotency.

What is it?

When a system is split into services, they must talk over a network, and networks fail. Synchronous calls such as REST or gRPC are simple but couple the caller to the callee being up. Asynchronous messaging through a broker decouples them in time, but brings duplicates, ordering questions and delayed results. Good designs add timeouts, retries and circuit breakers, make consumers idempotent, and use an outbox so that saving data and announcing it cannot get out of step.

Real-world relevance

A common failure is an order that is saved in the database while the "order placed" message is lost because the broker was down for a second. The customer paid, but no email, no stock update and no invoice follow. Another is a message delivered twice and a customer charged twice. Teams fix these with an outbox table and idempotent consumers, and they accept that other services see the order slightly later.

Key points

Quick check

Answer from memory — scrolling up first makes it stick less.

What problem does the outbox pattern solve?

What does a circuit breaker do?

Code example

public record OrderPlaced(Guid MessageId, Guid OrderId);
public class OutboxMessage { public Guid Id { get; set; } public string Type { get; set; } = ""; public string Payload { get; set; } = ""; public DateTime? SentAt { get; set; } }
public class ProcessedMessage { public Guid Id { get; set; } }   // primary key blocks duplicates

public class PlaceOrderService(ShopContext db)
{
    public async Task PlaceAsync(Order order, CancellationToken ct)   // Order.Id is a Guid set at creation
    {
        var messageId = Guid.NewGuid();
        db.Orders.Add(order);
        db.Outbox.Add(new OutboxMessage
        {
            Id = messageId,
            Type = nameof(OrderPlaced),
            Payload = JsonSerializer.Serialize(new OrderPlaced(messageId, order.Id))
        });
        await db.SaveChangesAsync(ct);   // order and outbox row commit together
    }
}

public class OrderPlacedConsumer(ReceiptsContext db)
{
    public async Task HandleAsync(OrderPlaced msg, CancellationToken ct)
    {
        if (await db.Processed.AnyAsync(p => p.Id == msg.MessageId, ct))
            return;                                          // duplicate: already handled

        db.Receipts.Add(new Receipt { OrderId = msg.OrderId });
        db.Processed.Add(new ProcessedMessage { Id = msg.MessageId });
        await db.SaveChangesAsync(ct);                       // work and marker commit together
    }
}

Line-by-line walkthrough

  1. 1. OrderPlaced carries a MessageId, so any consumer can recognise the same message if it arrives again.
  2. 2. OutboxMessage is a row in the same database as the orders, holding the message until a publisher sends it. ShopContext exposes it as an Outbox DbSet, not shown here.
  3. 3. ProcessedMessage has the message id as its primary key. If two copies run at the same moment, the second insert fails and its receipt rolls back with it.
  4. 4. PlaceAsync adds the order and the outbox row, then calls SaveChangesAsync once, so both are in one transaction.
  5. 5. A separate publisher (not shown) reads unsent outbox rows, sends them to the broker, and sets SentAt.
  6. 6. The consumer first checks for the message id and returns early on a duplicate. ReceiptsContext, with its Receipts and Processed DbSets and a simple Receipt entity, is not shown.
  7. 7. Receipts and Processed are saved in one SaveChangesAsync, so a crash cannot leave the work done but unmarked.

Spot the bug

public async Task<Guid> PlaceAsync(Order order, CancellationToken ct)
{
    db.Orders.Add(order);
    await db.SaveChangesAsync(ct);

    // broker is an IMessageBroker
    await broker.PublishAsync(new OrderPlaced(Guid.NewGuid(), order.Id), ct);   // broker down or app crashes here
    return order.Id;
}
Need a hint?
What state are the two systems in if the process stops between the two awaits?
Show answer
The save and the publish are two separate steps with no shared transaction. If the broker is down or the process crashes after SaveChangesAsync, the order exists but no message is ever sent. Fix: use an outbox. Add an OutboxMessage in the same SaveChangesAsync as the order, and let a background publisher send it and retry until it succeeds.

Explain like I'm 5

Sometimes you talk to a friend and wait for the answer. Sometimes you leave a note and go play. Notes can be delivered twice, so the friend keeps a list of notes already read. And you write the note and put it in the same drawer as your homework, so you cannot do one without the other.

Fun fact

End-to-end "exactly once" delivery across a network is very hard to guarantee, and most brokers do not promise it. The practical goal is at-least-once delivery plus idempotent consumers, which together give the same visible effect as exactly once.

Hands-on challenge

Say it out loud in 30 seconds: "Service A saves an order and must tell Service B. How do you make sure the message is not lost or processed twice?" Then check: did you mention the outbox in the same transaction, at-least-once delivery, an idempotent consumer with a processed-message id, and eventual consistency?

More resources

Open interactive version (quiz + challenge) Download the whole course as PDF / Word

Lock it in

← Back to course: .NET + React Playbook