Skip to main content
SEMastery

When Your Use Case Half-Succeeds: Designing for Partial Failure in .NET

Learn how to design .NET use cases that survive partial failure using outbox, saga, idempotency and compensation patterns, explained simply.

13 min readUpdated April 3, 2026

When Your Use Case Half-Succeeds: Designing for Partial Failure in .NET

Imagine you go to a small tea shop near your home. You order one chai and one samosa. You pay the full amount. The shopkeeper takes your money, gives you the chai, and then says, "Sorry, samosa is over." Now what? You paid for two things but got only one. The order half-succeeded.

This same thing happens inside software every single day. Your code tries to do a few steps together. Some steps work. One step breaks. Now the work is half done, and your system is in a messy, confusing state. We call this a partial failure.

In this post we will learn, in simple words, why partial failure happens in .NET applications, why a single database transaction is not always enough, and which patterns keep your system honest when things go half-right. We will look at the outbox pattern, idempotency, the saga pattern, and compensating actions. By the end you will know how to design a use case that can take a hit and still stay correct.

What is a "use case" here?

A use case is one complete job your application does for a user. "Place an order." "Register a new student." "Book a movie seat." A use case is usually made of many small steps.

Take "Place an order." Inside, it may do all of this:

  1. Save the order in the database.
  2. Reduce the stock count for each item.
  3. Charge the customer's card through a payment service.
  4. Send a confirmation email.
  5. Tell the warehouse to pack the box.

Five steps. To the user it looks like one button click. But behind the button, five different things must happen, and some of them talk to other systems far away.

One button click hides many steps that can each fail on their own.

Why partial failure is so common

Here is the hard truth. Each of those steps can fail on its own. The network can drop. The payment service can be slow. The email server can be down. The warehouse API can return an error.

When step 3 works but step 4 fails, you are stuck in the middle. The card was charged, but no email went out. The customer paid and heard nothing. That is a partial failure, and it makes users angry and support teams busy.

The more services your use case touches, the more places it can break. In a cloud world with many small services, partial failure is not rare. It is the normal weather. We must design for it, not hope it away.

How a clean use case turns messy

All good
One call fails
Half done
Confused state

Steps

1

All good

Steps run in order

2

One call fails

Network or service breaks

3

Half done

Some steps committed, some not

4

Confused state

Data no longer agrees

Every external call adds a new spot where the work can stop halfway.

The trap of "just use a transaction"

Many students learn about database transactions early. A transaction says: do all these writes, or do none of them. It is all-or-nothing. This sounds like the perfect cure for partial failure.

And inside one database, it is. If saving the order and reducing the stock both happen in the same SQL Server database, you wrap them in one transaction. If either fails, both roll back. Clean.

using var transaction = await db.Database.BeginTransactionAsync();
 
db.Orders.Add(order);
foreach (var item in order.Items)
{
    var product = await db.Products.FindAsync(item.ProductId);
    product.Stock -= item.Quantity;
}
 
await db.SaveChangesAsync();
await transaction.CommitAsync();

This is great. But look closely. Charging the card lives in a payment service. Sending the email lives in an email service. The warehouse lives somewhere else again. A database transaction cannot reach across the network into someone else's system. The moment your use case calls an outside service, the transaction can no longer protect you.

So we need a different way of thinking. We accept that we cannot get perfect "all-or-nothing" across many systems. Instead we aim for eventual consistency: the system may be briefly out of step, but it will sort itself out and end up correct.

ApproachWorks across one DBWorks across servicesResult
Single transactionYesNoStrong, instant consistency
Two-phase commit (2PC)YesSlow and fragileOften a liability in the cloud
Outbox + sagaYesYesEventual consistency

Pattern 1: The outbox pattern

The first big idea fixes a sneaky problem called the dual-write problem. A dual write is when you write to your database and also send a message to a broker (like RabbitMQ or Kafka) as two separate steps. If the database write works but the message send fails, you have a partial failure again.

The outbox pattern is a clever trick. Instead of sending the message directly, you write the message into a special table called the outbox, in the same database transaction as your business data. Now both succeed together or fail together, because they are one transaction.

A small background worker then reads the outbox table and actually delivers the messages to the broker. If delivery fails, the worker just tries again later. The message is never lost, because it was safely saved first.

The outbox saves the message with your data, then a worker delivers it later.

Here is a tiny version in code. Notice the order row and the outbox row are saved together.

using var transaction = await db.Database.BeginTransactionAsync();
 
db.Orders.Add(order);
 
db.OutboxMessages.Add(new OutboxMessage
{
    Id = Guid.NewGuid(),
    Type = "OrderPlaced",
    Payload = JsonSerializer.Serialize(new { order.Id, order.Total }),
    CreatedAt = DateTime.UtcNow,
    ProcessedAt = null
});
 
await db.SaveChangesAsync();
await transaction.CommitAsync();
// No broker call here. The message is safe in the table.

The background worker is a BackgroundService that wakes up, grabs unsent rows, and publishes them.

public class OutboxPublisher(IServiceProvider services) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            using var scope = services.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            var bus = scope.ServiceProvider.GetRequiredService<IBus>();
 
            var pending = await db.OutboxMessages
                .Where(m => m.ProcessedAt == null)
                .OrderBy(m => m.CreatedAt)
                .Take(20)
                .ToListAsync(ct);
 
            foreach (var msg in pending)
            {
                await bus.PublishAsync(msg.Type, msg.Payload, ct);
                msg.ProcessedAt = DateTime.UtcNow;
            }
 
            await db.SaveChangesAsync(ct);
            await Task.Delay(TimeSpan.FromSeconds(2), ct);
        }
    }
}

In real .NET projects, libraries like MassTransit and Wolverine give you the outbox built in, including row locking and cleanup. Note that as of 2025, MassTransit moved to a commercial license for new major versions, so check the license before you adopt it. Wolverine is open source. You can also hand-roll a small outbox like the one above when your needs are simple.

Pattern 2: Idempotency, the "do it once" rule

The outbox gives us at-least-once delivery. That phrase is important. It means a message will arrive at least once, but sometimes it may arrive twice, because the worker might retry after a hiccup.

So the same OrderPlaced message could reach the email service two times. If we are careless, the customer gets two emails. Or worse, a payment gets charged twice.

The fix is idempotency. An action is idempotent if doing it many times has the same effect as doing it once. Think of a light switch labelled "ON." Press it once, the light is on. Press it five more times, the light is still just on. No harm.

To make a consumer idempotent, we remember which messages we have already handled. Each message carries a unique id. Before doing the work, we check if we have seen that id.

public async Task HandleAsync(Message message, CancellationToken ct)
{
    bool alreadyHandled = await db.ProcessedMessages
        .AnyAsync(p => p.MessageId == message.Id, ct);
 
    if (alreadyHandled)
        return; // We did this before. Do nothing.
 
    await SendConfirmationEmail(message);
 
    db.ProcessedMessages.Add(new ProcessedMessage { MessageId = message.Id });
    await db.SaveChangesAsync(ct);
}

Idempotent message handling

Message arrives
Check id
New?
Do work
Record id

Steps

1

Message arrives

Has a unique id

2

Check id

Look up processed table

3

New?

If seen, return early

4

Do work

Send email once

5

Record id

Save id so retries skip

Seen this id before? Skip it. Never do the same work twice.

A quick note on APIs too. If you let clients call POST /orders and they retry on a timeout, accept an idempotency key from the header so a retried POST /orders does not create a second order. The same idea, just at the front door.

Pattern 3: The saga, a chain of small steps

Now we come to the big one. When a use case spans many services and each service has its own database, we cannot use one transaction. Instead we use a saga.

A saga breaks the long use case into a sequence of local transactions. Each step does its own small piece in its own service and its own database. If every step succeeds, wonderful, the whole use case is done. But if a step fails, the saga runs compensating actions to undo the earlier steps, in reverse order.

A compensating action is the "undo" for a step. The undo for "charge the card" is "refund the card." The undo for "reserve a seat" is "release the seat." Note that undo is not the same as a database rollback. The earlier work already committed. We cannot erase history, so instead we add a new action that cancels out the old one.

A saga runs forward; if a step fails it walks back undoing each finished step.

There are two common ways to run a saga.

Saga styleWho decides next stepGood forWatch out for
ChoreographyEach service reacts to eventsFew steps, loose couplingHard to see the whole flow
OrchestrationOne coordinator gives ordersMany steps, clear controlCoordinator is a key piece

In choreography, each service listens for events and publishes its own. There is no boss. In orchestration, a single coordinator (the orchestrator) tells each service what to do and listens for the result. Beginners usually find orchestration easier to follow because the whole flow lives in one place.

Here is a very small orchestrator sketch. Note how each step has a matching undo if a later step fails.

public async Task<bool> PlaceOrderSaga(Order order, CancellationToken ct)
{
    var done = new Stack<Func<Task>>();
    try
    {
        await _orders.Save(order, ct);
        done.Push(() => _orders.Cancel(order.Id));
 
        await _stock.Reserve(order, ct);
        done.Push(() => _stock.Release(order.Id));
 
        await _payments.Charge(order, ct);
        done.Push(() => _payments.Refund(order.Id));
 
        await _warehouse.Ship(order, ct);
        return true;
    }
    catch (Exception)
    {
        while (done.Count > 0)      // undo in reverse order
            await done.Pop()();
        return false;
    }
}

This little stack-of-undos shows the heart of a saga. Each time a step succeeds, we remember how to undo it. If a later step throws, we pop the stack and run every undo we collected. Real systems persist this saga state to a database so it survives a restart, and they make each undo idempotent so retries are safe.

Forward path versus the compensation path when payment fails.

Putting it together: a resilient use case

Let us join the three patterns into one mental picture for "Place an order."

  • Save the order and an outbox message in one local transaction. This pair is safe.
  • A background worker publishes the message. Delivery is at-least-once.
  • Each consumer (stock, payment, email, warehouse) is idempotent, so duplicate messages do no harm.
  • The whole multi-service flow is a saga. If a step fails, compensating actions undo the earlier steps.

With this design, a partial failure no longer leaves a mess. Either the use case finishes fully, or it cleanly unwinds itself. The system always lands in a correct state, even if it takes a few extra seconds.

PatternProblem it solvesKey idea
OutboxDual-write between DB and brokerSave the message with the data
IdempotencyDuplicate or retried messagesDo the work only once
SagaMulti-service "all or nothing"Local steps plus undo steps
CompensationCannot roll back committed workAdd an action that cancels it

Things to remember while building

A few friendly tips that save a lot of pain.

First, give every message and every command a unique id early. Idempotency is almost impossible to add later if ids are missing.

Second, log each step of your saga with that id. When something goes wrong at 2 a.m., a clear trail of "step 3 failed, ran refund, released stock" is worth gold.

Third, decide what should be instant and what can be eventual. Charging a card may need a quick answer to the user. Sending a marketing email can wait. Use strong consistency only where it truly matters, and let the rest be eventual.

Fourth, test the failure paths on purpose. Make the payment service throw, and check that the refund really runs. Happy-path code is easy. The undo code is what keeps you safe, so it deserves real tests.

References and further reading

Quick recap

  • A partial failure is when a use case half-succeeds: some steps work, one fails, and the data no longer agrees.
  • A single database transaction only protects writes inside one database. It cannot reach across services.
  • The outbox pattern saves your message with your business data in one transaction, so the message is never lost. A worker delivers it later, giving at-least-once delivery.
  • Because messages can arrive twice, your consumers must be idempotent: doing the work many times has the same effect as doing it once.
  • A saga runs a use case as a chain of local steps across services, and uses compensating actions to undo finished steps when a later step fails.
  • Aim for eventual consistency: the system may be briefly out of step, but it always settles into a correct state.
  • Give everything a unique id, log every saga step, and test the failure paths on purpose.

Related Patterns