# The False Comfort of the "Happy Path": Decoupling Your Services

> Coupling isn't just about code structure; it's about failure boundaries. Discover how to ensure your critical business logic survives when external dependencies like email or analytics go down.

Published: 2025-11-22. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/the-false-comfort-of-the-happy-path-decoupling-your-services

Calling email and analytics APIs directly from your registration method makes the user wait on them, and a failure halfway through leaves partial state.
Dispatch a domain event instead, write it to an outbox table in the same transaction, and let a background worker publish it.
When the follow-up step is mandatory, use a Saga with compensating transactions.

**Let's be honest: We've all written this code.**

It's Monday morning, you have a deadline, and you need to implement a user registration feature.
It's simple enough: save the user, send a welcome email, and track the signup in your analytics dashboard.

You write this:

```csharp
public class UserService(
        IUserRepository userRepository,
        IEmailService emailService,
        IAnalyticsService analyticsService)
{
    public async Task RegisterUser(string email, string password)
    {
        var user = new User(email, password);
        await userRepository.SaveAsync(user);

        // 1. Directly coupled to email service (external API)
        await emailService.SendWelcomeEmail(user.Email);

        // 2. Directly coupled to analytics (this could be an external API)
        await analyticsService.TrackUserRegistration(user.Id);

        // What if we need to add more features?
        // This method will keep growing...
    }
}
```

It looks clean. It's readable. It works on your machine.

But this method is a **ticking time bomb**.

It assumes the "Happy Path" is the _only_ path.
It assumes the network is reliable, the email provider is up, and the analytics API is fast.
In production, none of these are guaranteed.

Thinking further, I'm sure you can imagine similar code in your own projects.
It might not be this exact scenario, but the pattern is common: a single method that orchestrates multiple side effects in a linear fashion.

Let's break down why this code is dangerous and how we can refactor it into a robust, event-driven architecture.

## The Hidden Dangers of the "God Method"

There are three major issues hiding in those ten lines of code.

### 1. Temporal Coupling (Latency)

When a user clicks "Register," they have to wait for:

1.  The Database **+**
2.  The SMTP Server **+**
3.  The Analytics API

If your analytics provider is having a bad day and takes 3 seconds to respond, **your user waits 3 seconds**.
You are punishing your user for the slowness of a background system they don't even care about.

### 2. The Partial Failure State

This is the **most critical risk**. Imagine this scenario:

1.  `SaveAsync(user)` succeeds. The user is in the DB.
2.  `SendWelcomeEmail` succeeds. The user gets an email.
3.  `TrackUserRegistration` throws a `503 Service Unavailable`.

**What happens now?**
If you wrap this in a transaction and rollback, you have deleted the user from the DB... **but you already sent them a welcome email.**
The user tries to log in, but they don't exist.
Now what?

If you _don't_ rollback, you have a user in your system that is missing from your analytics.
You have data inconsistency.

### 3. Violation of Single Responsibility (SRP)

You might argue that because we are using interfaces (`IEmailService`), we are decoupled.
That is true for _implementation details_, but false for _orchestration_.

The `UserService` currently has two reasons to change:

1.  **Core Domain Logic:** "We now require a username in addition to email."
2.  **Notification Policy:** "Marketing wants to send an SMS in addition to the Email."

The `UserService` should strictly be responsible for the **state change** (creating the user).
It should not be responsible for **orchestrating the side effects** of that change.

## Level 1: Logical Decoupling with Domain Events

The first step to fixing this is to invert the control.
Instead of the `UserService` _commanding_ other services to do things, it should simply _announce_ that something happened.

We can use [**Domain Events**](https://milanjovanovic.tech/blog/how-to-use-domain-events-to-build-loosely-coupled-systems) to achieve this.

Here is the refactored `UserService`:

```csharp
public class UserService(
        IUserRepository userRepository,
        IDomainEventDispatcher dispatcher,
        IUnitOfWork unitOfWork)
{
    public async Task RegisterUser(string email, string password)
    {
        // 1. Create the User Entity
        var user = new User(email, password);

        // 2. Capture the side effect as an event object
        var userRegisteredEvent = new UserRegisteredEvent(user.Id, user.Email);

        // 3. Add the entity to the repository
        await userRepository.AddAsync(user);

        // 4. Dispatch the event (Assuming in-process dispatching here for simplicity)
        // Note: Handlers for Email and Analytics are now completely separate classes.
        await dispatcher.Dispatch(userRegisteredEvent);

        await unitOfWork.SaveChangesAsync();
    }
}
```

The `UserService` is now stable.
Adding a "Loyalty Points" feature later doesn't require touching this method.
You just add a new handler for the `UserRegisteredEvent`.

**However, we haven't solved the reliability problem yet.**
If the process crashes immediately after `Dispatch` but before `SaveChangesAsync` completes, we might send an email for a user that failed to save.
Or, if we save first and dispatch later, we might save the user but lose the event if the server crashes.

## Level 2: Reliability with the Outbox Pattern

To fix this, we need **Atomicity**.
Atomicity means that a set of operations either all succeed or all fail together.

We need to guarantee that if the `User` is saved, the `UserRegisteredEvent` is also saved.

Enter the [**Outbox Pattern**](https://milanjovanovic.tech/blog/implementing-the-outbox-pattern).

Instead of publishing the event immediately to a message bus, we save the event to an `OutboxMessages` table in the **same database transaction** as the user.

Here is the complete implementation logic:

```csharp
public async Task RegisterUser(string email, string password)
{
    // 1. Create the Domain Event
    var user = new User(email, password);
    var domainEvent = new UserRegisteredEvent(user.Id, user.Email);

    // 2. Open a Transaction
    using var transaction = dbContext.Database.BeginTransaction();

    try
    {
        // 3. Save the User to the Users Table
        dbContext.Users.Add(user);

        // 4. Serialize the Event and Save to Outbox Table
        var outboxMessage = new OutboxMessage
        {
            Id = Guid.NewGuid(),
            Type = nameof(UserRegisteredEvent),
            Content = JsonSerializer.Serialize(domainEvent),
            OccurredOn = DateTime.UtcNow,
            ProcessedOn = null // Null means it hasn't been handled yet
        };

        dbContext.OutboxMessages.Add(outboxMessage);

        // 5. Commit BOTH changes atomically
        await dbContext.SaveChangesAsync();
        await transaction.CommitAsync();
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
}
```

Now, a [**background worker**](https://milanjovanovic.tech/blog/scheduling-background-jobs-with-quartz-net) (running in a separate process) polls the `OutboxMessages` table.
It picks up the message and publishes it to your message bus (RabbitMQ, Azure Service Bus, etc.).

If the email service is down, the background worker just retries later.
**We have achieved At-Least-Once delivery.**

## Level 3: Distributed Consistency with Sagas

The Outbox pattern is perfect for side effects (fire-and-forget actions like emails).
But what if the subsequent action is **mandatory**?

**Scenario:** When a user registers, we _must_ create a crypto-wallet for them in the `WalletService`.
If the wallet creation fails (e.g., due to regulations), we cannot allow the user to exist in our system.

We can't just "retry later" if the `WalletService` says "Fraud Detected."
We need to **undo** the user creation.

This is a distributed transaction, and we handle it with the [**Saga Pattern**](https://milanjovanovic.tech/blog/implementing-the-saga-pattern-with-masstransit).
A Saga coordinates a series of steps.
If one fails, it executes **Compensating Transactions** to undo the previous work.

Here is how the failure scenario looks when using a [**Choreography-based Saga**](https://milanjovanovic.tech/blog/orchestration-vs-choreography):

![A Saga Sequence Diagram showing UserService creating a user, WalletService attempting to create a wallet, failing, and UserService deleting the user as a compensation action.](https://milanjovanovic.tech/blogs/mnw_169/saga_sequence_diagram.png)

Here's the step-by-step breakdown of the flow:

1.  **UserService:** Creates User → Publishes `UserCreated`
2.  **WalletService:** Listens to `UserCreated` → Tries to create wallet
    - _Failure:_ Wallet creation fails
    - _Action:_ Publishes `WalletCreationFailed`
3.  **UserService:** Listens to `WalletCreationFailed` → **Deletes/Deactivates the User**

This ensures **Eventual Consistency**.
The system might be inconsistent for a few seconds (the user exists without a wallet),
but it will eventually settle into a valid state (the user is removed).

## Summary: A Heuristic for Decision Making

You don't need Sagas for everything.
Over-engineering is just as bad as tight coupling.
Use this simple rule of thumb:

1.  **Is it a simple notification?** (Email, Analytics, Cache Invalidation)
    - **Use Domain Events + Outbox.** It's okay if it happens 5 seconds later.
2.  **Is it a critical business dependency?** (Payments, Inventory, Account Status)
    - **Use a Saga.** If step B fails, step A must be reverted.

Coupling isn't just about code structure.
It's about understanding and managing **failure boundaries**.
If your Analytics Service goes down, it shouldn't prevent a user from registering.
Build your systems to survive the unhappy path.

Hope this was helpful.

See you next week.

---

## Frequently asked questions

### Why is calling external services directly from business logic a problem?

Three reasons: the user waits on the database plus every external API in the request path, a failure halfway through leaves the system in a partial, inconsistent state, and the method takes on a second responsibility, orchestrating side effects instead of just changing state.

### What is a partial failure state?

It is when some side effects succeed and others fail. For example, the user is saved and the welcome email sent, but the analytics call fails. Rolling back deletes a user who already received an email; not rolling back leaves inconsistent data between systems.

### How do domain events decouple services?

Instead of commanding other services, the code announces that something happened by dispatching an event like UserRegisteredEvent. Handlers for email and analytics become separate classes, so adding a new feature means adding a new handler without touching the original method. Reliability still needs the outbox on top.

### What is the outbox pattern?

The outbox pattern saves the event to an OutboxMessages table in the same database transaction as the business change. A background worker polls the table and publishes the message to a bus like RabbitMQ or Azure Service Bus, retrying on failure, which gives you at-least-once delivery.

### When do you need the Saga pattern?

Use a Saga when a subsequent action is mandatory and cannot simply be retried later, like creating a wallet during registration. If a step fails, the Saga runs compensating transactions to undo the previous work, which gives you eventual consistency across services.

### Should I use the outbox pattern or a Saga?

For simple notifications like emails, analytics, or cache invalidation, use domain events with the outbox; it is fine if they happen a few seconds later. For critical business dependencies like payments, inventory, or account status, use a Saga, because a failed step must revert the earlier ones.
