DEV Community

Cover image for Your Database Said "Success." Your Message Broker Said "Try Again."
Kainat Saricioglu
Kainat Saricioglu

Posted on

Your Database Said "Success." Your Message Broker Said "Try Again."

This is where distributed systems get interesting.

If you've worked with backend systems long enough, you've probably written code that looks roughly like this:

await using var transaction = await db.Database.BeginTransactionAsync();

order.Status = OrderStatus.Paid;

db.Orders.Update(order);

await db.SaveChangesAsync();

await messageBus.PublishAsync(
    new PaymentCompleted(order.Id)
);

await transaction.CommitAsync();
Enter fullscreen mode Exit fullscreen mode

At first glance, it looks reasonable.

The order is updated.

The event is published.

The transaction commits.

Done.

Except there is a problem hiding in the middle.

What happens if PublishAsync() fails?

What happens if RabbitMQ is temporarily unavailable?

What happens if the network connection drops after the broker accepted the message but before your application receives the response?

What happens if the application crashes at exactly the wrong millisecond?

And most importantly:

What does your system believe happened?

This is one of those backend problems that doesn't show up in a happy-path demo.

It shows up at 3:17 AM in production.


The problem with "just use a transaction"

Let's simplify the system.

Imagine an Order Service.

It has:

              ┌──────────────┐
              │  Order API   │
              └──────┬───────┘
                     │
                     ▼
              ┌──────────────┐
              │ Order Service│
              └──────┬───────┘
                     │
             ┌───────┴────────┐
             ▼                ▼
      ┌────────────┐   ┌──────────────┐
      │ PostgreSQL │   │   RabbitMQ   │
      └────────────┘   └──────────────┘
Enter fullscreen mode Exit fullscreen mode

A request comes in:

POST /orders/123/pay
Enter fullscreen mode Exit fullscreen mode

The service needs to do two things:

  1. Update the database.
  2. Tell other services that the payment succeeded.

For example:

Order.Status = Paid
        +
PaymentCompleted event
Enter fullscreen mode Exit fullscreen mode

And this is where the trouble starts.

Your database and your message broker are two different systems.

A database transaction can guarantee atomicity inside the database.

RabbitMQ doesn't magically become part of that transaction.

So this is not really:

BEGIN
    UPDATE DATABASE
    PUBLISH MESSAGE
COMMIT
Enter fullscreen mode Exit fullscreen mode

It is more like:

DATABASE                    MESSAGE BROKER

   │                              │
   │ UPDATE                       │
   │─────────────────────────────>│
   │                              │
   │ COMMIT                       │
   │                              │
   │                              │
   │        ??? PUBLISH ???       │
   │                              │
Enter fullscreen mode Exit fullscreen mode

There is a gap.

And that gap is where distributed systems become difficult.


Failure scenario #1: Database first

Let's say we do the sensible-looking thing:

await db.SaveChangesAsync();

await messageBus.PublishAsync(message);
Enter fullscreen mode Exit fullscreen mode

Suppose the database succeeds.

Then:

Database
──────────────
Order 123
Status = Paid
Enter fullscreen mode Exit fullscreen mode

Everything looks good.

But immediately afterward:

RabbitMQ
──────────────
PaymentCompleted
       ❌
Enter fullscreen mode Exit fullscreen mode

Maybe RabbitMQ is temporarily unavailable.

Maybe DNS failed.

Maybe the pod restarted.

Maybe the process crashed.

Maybe there was a network timeout.

Now your database says:

"Payment completed."

But the rest of your system never heard about it.

The Inventory Service doesn't know.

The Notification Service doesn't know.

The Analytics Service doesn't know.

Whatever depends on PaymentCompleted doesn't know.

And if you simply retry the entire HTTP request, you could create another problem.


Failure scenario #2: Message first

So perhaps we reverse the order:

await messageBus.PublishAsync(message);

await db.SaveChangesAsync();
Enter fullscreen mode Exit fullscreen mode

Now imagine the message is successfully published.

Then the database transaction fails.

Maybe there is a deadlock.

Maybe a constraint violation occurs.

Maybe the database connection disappears.

Now we have the opposite situation:

RabbitMQ
──────────────
PaymentCompleted
       ✅

Database
──────────────
Order.Status = Pending
       ❌
Enter fullscreen mode Exit fullscreen mode

The Inventory Service receives:

PaymentCompleted

But the Order Service says:

Actually... no.

Now we're inconsistent in the other direction.


"Can we just use distributed transactions?"

This is where someone usually says:

"Why not use a distributed transaction?"

In theory, we could try to coordinate the database and broker through a distributed transaction protocol.

In practice, this introduces another set of problems.

Distributed transactions can be complex, expensive, operationally awkward, and tightly couple infrastructure components.

The classic transactional outbox pattern exists largely because we want the database update and the intent to publish an event to become atomic without requiring a two-phase commit across the database and broker.

And this is where I think a very simple idea becomes extremely powerful.


The Outbox Pattern

Instead of immediately publishing the message, we save the message inside the same database transaction as the business change.

Something like:

                    ┌─────────────────────────┐
                    │       Transaction       │
                    │                         │
                    │  UPDATE Orders          │
                    │          +              │
                    │  INSERT OutboxMessage   │
                    │                         │
                    └────────────┬────────────┘
                                 │
                              COMMIT
                                 │
                    ┌────────────▼────────────┐
                    │      Database            │
                    │                          │
                    │ Orders                   │
                    │ OutboxMessages           │
                    └────────────┬─────────────┘
                                 │
                                 │
                         Background Worker
                                 │
                                 ▼
                         ┌──────────────┐
                         │   RabbitMQ   │
                         └──────────────┘
Enter fullscreen mode Exit fullscreen mode

Now the important part:

The application isn't trying to atomically update two systems anymore.

It only has to atomically update one: the database.


A simple Outbox table

For example:

CREATE TABLE OutboxMessages
(
    Id              UUID PRIMARY KEY,
    Type            VARCHAR(200) NOT NULL,
    Payload         JSONB NOT NULL,
    OccurredAt      TIMESTAMP NOT NULL,
    ProcessedAt     TIMESTAMP NULL,
    RetryCount      INT NOT NULL DEFAULT 0
);
Enter fullscreen mode Exit fullscreen mode

Now our application transaction becomes:

await using var transaction =
    await db.Database.BeginTransactionAsync();

order.Status = OrderStatus.Paid;

db.Orders.Update(order);

var message = new OutboxMessage
{
    Id = Guid.NewGuid(),
    Type = nameof(PaymentCompleted),
    Payload = JsonSerializer.Serialize(
        new PaymentCompleted(order.Id)
    ),
    OccurredAt = DateTime.UtcNow
};

db.OutboxMessages.Add(message);

await db.SaveChangesAsync();

await transaction.CommitAsync();
Enter fullscreen mode Exit fullscreen mode

Notice something important.

We're not talking to RabbitMQ inside the transaction anymore.

We're just changing the database.

Either both changes succeed:

Orders
   +
OutboxMessages
Enter fullscreen mode Exit fullscreen mode

Or neither does.

That's the part we can make truly atomic.

The transactional outbox pattern specifically works by storing the outgoing message in the same database transaction and having a separate relay publish it to the broker.


But now we have another problem

The outbox worker has to read those messages and publish them.

Something like:

while (!stoppingToken.IsCancellationRequested)
{
    var messages = await db.OutboxMessages
        .Where(x => x.ProcessedAt == null)
        .OrderBy(x => x.OccurredAt)
        .Take(100)
        .ToListAsync();

    foreach (var message in messages)
    {
        await messageBus.PublishAsync(
            message.Type,
            message.Payload
        );

        message.ProcessedAt = DateTime.UtcNow;
    }

    await db.SaveChangesAsync();
}
Enter fullscreen mode Exit fullscreen mode

Looks good.

Until we consider this:

1. Worker reads message
2. Worker publishes message
3. RabbitMQ accepts message
4. Worker crashes
5. Worker never marks message as processed
6. Worker restarts
7. Worker publishes message AGAIN
Enter fullscreen mode Exit fullscreen mode

Congratulations.

We've solved one consistency problem and created another.

Duplicate messages.

And this is one of the most important lessons in distributed systems:

"Exactly once" is usually much harder than it sounds.

The outbox relay itself can publish a message more than once if it crashes after publishing but before recording that it was published. The standard pattern therefore expects consumers to be able to process duplicate messages safely.

Which brings us to my favorite word in distributed systems:

Idempotency

An operation is idempotent when performing it multiple times has the same effect as performing it once.

For example:

Set status = Paid
Enter fullscreen mode Exit fullscreen mode

is naturally easier to make idempotent than:

balance += 100
Enter fullscreen mode Exit fullscreen mode

because:

Set Paid
Set Paid
Set Paid
Enter fullscreen mode Exit fullscreen mode

still results in:

Paid
Enter fullscreen mode Exit fullscreen mode

But:

+100
+100
+100
Enter fullscreen mode Exit fullscreen mode

doesn't.


Make the consumer idempotent

Suppose our PaymentCompleted event reaches the Notification Service.

We could create an inbox/processed-message table:

CREATE TABLE ProcessedMessages
(
    MessageId UUID PRIMARY KEY,
    ProcessedAt TIMESTAMP NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

Then:

await using var transaction =
    await db.Database.BeginTransactionAsync();

var alreadyProcessed =
    await db.ProcessedMessages
        .AnyAsync(x => x.MessageId == message.Id);

if (alreadyProcessed)
{
    return;
}

await notificationService.SendPaymentConfirmationAsync(
    message.OrderId
);

db.ProcessedMessages.Add(
    new ProcessedMessage
    {
        MessageId = message.Id,
        ProcessedAt = DateTime.UtcNow
    });

await db.SaveChangesAsync();

await transaction.CommitAsync();
Enter fullscreen mode Exit fullscreen mode

The MessageId should also be protected by a database-level unique constraint.

Why?

Because this is not enough:

if (!exists)
{
    insert();
}
Enter fullscreen mode Exit fullscreen mode

Two instances can execute the check concurrently:

Instance A                 Instance B

    │                          │
    │── Does it exist? ───────>│
    │                          │
    │<────── No ───────────────│
    │                          │
    │                          │
    │── Does it exist? ───────>│
    │                          │
    │<────── No ───────────────│
    │                          │
    ▼                          ▼
  INSERT                     INSERT
Enter fullscreen mode Exit fullscreen mode

Now both think they're the first.

That's why the database should enforce the invariant.

For example:

ALTER TABLE ProcessedMessages
ADD CONSTRAINT PK_ProcessedMessages
PRIMARY KEY (MessageId);
Enter fullscreen mode Exit fullscreen mode

PostgreSQL's unique constraints are enforced through unique indexes, making the database itself responsible for preventing duplicate keys.

This is a pattern I really like:

Application logic decides what should happen.
The database enforces what must never happen.


But wait... there's another race condition

Let's make the system more realistic.

We have multiple instances:

                    ┌───────────────┐
                    │ Load Balancer │
                    └───────┬───────┘
                            │
                 ┌──────────┼──────────┐
                 ▼          ▼          ▼
             API Pod 1  API Pod 2  API Pod 3
                 │          │          │
                 └──────────┼──────────┘
                            ▼
                        Database
Enter fullscreen mode Exit fullscreen mode

Now imagine all three workers poll the outbox at the same time.

They might all see:

Message #123
ProcessedAt = NULL
Enter fullscreen mode Exit fullscreen mode

What stops them from all publishing it?

This is where things get interesting.

One approach is to atomically claim rows.

For example, conceptually:

SELECT *
FROM OutboxMessages
WHERE ProcessedAt IS NULL
ORDER BY OccurredAt
FOR UPDATE SKIP LOCKED
LIMIT 100;
Enter fullscreen mode Exit fullscreen mode

The exact implementation depends on your database, workload and broker semantics, but the underlying idea is important:

Reading a message and claiming responsibility for it are not necessarily the same operation.

And once you start thinking about multiple instances, retries and crashes, the design becomes much more than:

"Let's add RabbitMQ."


What about retries?

The worker will fail.

That's normal.

A production system should expect failure.

So instead of:

try
{
    await Publish(message);
}
catch
{
    // ¯\_(ツ)_/¯
}
Enter fullscreen mode Exit fullscreen mode

we need an actual retry strategy.

For example:

Attempt 1
   ↓
failure
   ↓
wait 1 second
   ↓
Attempt 2
   ↓
failure
   ↓
wait 5 seconds
   ↓
Attempt 3
   ↓
failure
   ↓
wait 30 seconds
   ↓
Attempt 4
   ↓
failure
   ↓
Dead Letter / Failed State
Enter fullscreen mode Exit fullscreen mode

I generally prefer exponential backoff rather than hammering a dependency that is already unhealthy.

Something like:

var delay = TimeSpan.FromSeconds(
    Math.Pow(2, retryCount)
);
Enter fullscreen mode Exit fullscreen mode

But retrying forever is not a strategy.

Eventually you need to know:

"This message is failing too many times. What do we do with it?"

That's where a dead-letter mechanism, failed state, alerting and operational tooling become important.

A message stuck for 30 seconds might be normal.

A message stuck for 30 minutes might be a problem.

A message stuck for 3 days is probably an incident.


Observability is part of the architecture

This is another thing I think backend developers sometimes underestimate.

Having an outbox is not enough.

You need to be able to answer:

How many messages are waiting?

How old is the oldest message?

How many have failed?

How many retries happened?

Which message is failing?

Which service consumed it?

How long did it take?

Are duplicates increasing?

Is the broker unavailable?

Enter fullscreen mode Exit fullscreen mode

For example, one metric I'd absolutely want is:

outbox_oldest_message_age_seconds
Enter fullscreen mode Exit fullscreen mode

Because:

Queue size = 10
Enter fullscreen mode Exit fullscreen mode

doesn't tell me much.

Those 10 messages might have been created 100 milliseconds ago.

But:

Queue size = 10
Oldest message = 47 minutes old
Enter fullscreen mode Exit fullscreen mode

is a very different story.


There is no "perfect" outbox implementation

And this is probably the most important point.

The outbox pattern isn't magic.

It introduces its own costs.

You now have:

  • Another table
  • Another background process
  • Retry logic
  • Duplicate handling
  • Cleanup/retention
  • Monitoring
  • Potential ordering problems
  • Additional database load
  • Operational complexity

The polling-publisher approach, for example, is straightforward and works with SQL databases, but ordering and efficient publishing become concerns. Transaction-log-based approaches can reduce some polling concerns but introduce database-specific infrastructure and their own duplicate-publishing considerations.

So I don't think the answer should be:

"Always use the Outbox Pattern."

The better question is:

"What consistency guarantee does this particular business operation actually require?"

That's the architectural question.


Payment systems make this especially interesting

Imagine this:

Customer
   │
   ▼
Payment API
   │
   ├──────────────► Payment Provider
   │
   ▼
Database
   │
   ▼
Outbox
   │
   ▼
Message Broker
   │
   ├────────► Order Service
   │
   ├────────► Notification Service
   │
   └────────► Analytics
Enter fullscreen mode Exit fullscreen mode

Now think about retries.

The customer clicks:

Pay

The request times out.

They click again.

The first payment might have succeeded even though the client never received the response.

Now you potentially have:

HTTP retry
     +
Payment retry
     +
Message retry
     +
Consumer retry
Enter fullscreen mode Exit fullscreen mode

Every layer can independently retry.

And suddenly:

Idempotency isn't an optimization.

It's a business requirement.

You might need an idempotency key such as:

Idempotency-Key: 4f1a7c...
Enter fullscreen mode Exit fullscreen mode

and persist that key with the operation.

Now your system can distinguish:

New request
Enter fullscreen mode Exit fullscreen mode

from:

Same request being retried
Enter fullscreen mode Exit fullscreen mode

This is one reason I find payment and identity systems particularly interesting: the cost of "doing the same thing twice" can be much higher than simply returning a duplicate record.


The architecture I would start with

For a typical .NET microservice, I'd be comfortable starting with something like:

                         ┌───────────────┐
                         │     Client    │
                         └───────┬───────┘
                                 │
                                 ▼
                         ┌───────────────┐
                         │ ASP.NET Core  │
                         │      API      │
                         └───────┬───────┘
                                 │
                       ┌─────────▼─────────┐
                       │   DB Transaction  │
                       │                   │
                       │ Business Data     │
                       │        +          │
                       │ Outbox Message    │
                       └─────────┬─────────┘
                                 │
                              COMMIT
                                 │
                                 ▼
                         ┌───────────────┐
                         │ Outbox Worker │
                         └───────┬───────┘
                                 │
                           retry/backoff
                                 │
                                 ▼
                         ┌───────────────┐
                         │   RabbitMQ    │
                         └───────┬───────┘
                                 │
                ┌────────────────┼────────────────┐
                ▼                ▼                ▼
          Order Service    Notification       Analytics
                              Service
                │                │
                └───────┬────────┘
                        ▼
                 Idempotent Consumer
Enter fullscreen mode Exit fullscreen mode

And I'd explicitly design for these failure modes:

Failure Expected behavior
Database unavailable Request fails; nothing is committed
Business transaction fails No event exists
Broker unavailable Event remains in outbox
Worker crashes before publish Event is retried
Worker crashes after publish Duplicate is possible
Consumer receives duplicate Duplicate is safely ignored
Consumer fails Message is retried
Message permanently fails Dead-letter/failed state + alert
Multiple workers process same event Database constraint protects invariant

That's a much more realistic definition of "reliable" than:

"It works when everything works."


The part I like most about backend engineering

This is why I still find backend development so interesting.

The difficult part usually isn't writing:

await db.SaveChangesAsync();
Enter fullscreen mode Exit fullscreen mode

The difficult part is asking:

What happens if the next line never executes?

And then:

What if it executes but the response is lost?

And:

What if the application crashes immediately afterward?

And:

What if two instances do it simultaneously?

And:

What if the message arrives twice?

And:

What if the database succeeds but the broker doesn't?

And eventually:

Can the system recover without someone manually fixing the data at 3 AM?

That's where architecture stops being a collection of boxes and arrows.

It becomes a set of guarantees.


My current rule of thumb

When designing distributed backend systems, I try to think in terms of failure boundaries, not just components.

Instead of asking:

"Should we use RabbitMQ?"

I'd ask:

"What happens if RabbitMQ disappears for 10 minutes?"

Instead of:

"Should this be a microservice?"

I'd ask:

"What happens when this service is unavailable?"

Instead of:

"Should we add retries?"

I'd ask:

"What happens when the operation succeeds but the response is lost and we retry it?"

Instead of:

"Can we process this asynchronously?"

I'd ask:

"What consistency guarantee does the business actually need?"

Those questions usually lead to much better architecture decisions.


One final thought

I think one of the biggest misconceptions about backend engineering is that reliability comes from adding more infrastructure.

More services.

More queues.

More replicas.

More caching.

More Kubernetes.

More distributed components.

Sometimes it does.

But sometimes the best reliability improvement is much simpler:

Make the important invariant explicit.

Then decide where that invariant should be enforced.

Sometimes that's the application.

Sometimes that's the database.

Sometimes it's both.

And sometimes the correct answer is to avoid distributing the operation in the first place.

The more distributed our systems become, the more valuable these fundamentals become.

Because eventually every distributed system has the same question waiting for us:

"What happens when things fail between step A and step B?"

That's the question I'm increasingly interested in.

And I suspect it's one of the questions that separates code that works from software that can actually survive production.


What would you choose?

Suppose you have:

Database update
      +
Event publication
Enter fullscreen mode Exit fullscreen mode

and you cannot use a distributed transaction.

Would you choose:

A) Transactional Outbox
B) Direct publish + retry
C) Event sourcing
D) Something else

And more importantly:

What failure scenario would drive your decision?

I'd genuinely like to hear how other backend engineers approach this.


I'm planning to write more about the practical side of backend engineering—especially .NET, distributed systems, microservices, databases, identity/security, and the kinds of problems that only become obvious when software meets production.

#BackendDevelopment #DotNet #CSharp #Microservices #DistributedSystems #SoftwareArchitecture #RabbitMQ #PostgreSQL #Database #SystemDesign

Top comments (0)