WESLEY WITSKEN

Outbox Domain Events in Catan

Tue Sep 02 2025

Building domain events into Catan with the Outbox pattern - using Redis Streams.

Written by: Wesley Witsken

A knight being signaled by people in a castle with flags.

Building a Catan App with Redis: Outbox Pattern + Redis Streams

In my first post, I introduced my side project: building a Settlers of Catan app in .NET with Redis as the primary database. This time, I want to dive into something that might sound buzzwordy at first—but is actually pretty simple once you see why I need it: the outbox pattern and domain events.

Why Do I Need Events for Catan?

If you’ve ever played Catan, you know it’s not just about your own moves—it’s about keeping everyone else up to date. When I build a road, every other player should see that road appear on their board. When someone rolls a 7, everyone has to discard cards. And when I steal your longest road (sorry not sorry), you need to know immediately.

In app terms:

I can’t just respond to the user making the request.

I need to notify all other players in the game.

That’s where domain events come in. In Clean Architecture, a “domain event” is just a way of saying: something happened in the game world, and other parts of the system should react to it.

What’s the Outbox Pattern?

Here’s the tricky bit: if I just fire off notifications at the same time I save game state, I risk losing consistency. What if the game state is saved but the notification fails? Or vice versa?

The outbox pattern solves this:

  • When I save a change (e.g., “road built”), I also save a record of the event in an “outbox.”

  • Another process (the “outbox processor”) reads those events and delivers them reliably.

That way, game state and events always stay in sync. No missing roads. No ghost notifications.

Redis as My Database (and Event Store!)

Here’s where things get unusual. Most apps using the outbox pattern store state in a relational database (SQL Server, Postgres, etc.) and then push events to a queue like Kafka or RabbitMQ.

But I’m already storing my game state in Redis. And Redis has two killer features for me:

  1. Lua scripting → lets me atomically update game state and append outbox events in one go.

  2. Streams → a built-in data structure for log-like event delivery with consumer groups.

So instead of bolting on Kafka or RabbitMQ, I just… kept it in Redis. It’s all in one place, it’s fast, and it simplifies my architecture.

A Concrete Example: PlayerBuiltRoadEvent

Let’s say Player A builds a road. Inside my domain, I raise a PlayerBuiltRoadEvent. When I persist that to Redis, my Lua script stores both:

The updated game state snapshot

A new entry in the outbox stream

Here’s what that stream entry might look like in Redis:

{
  "id": "1735928439421-0",
  "values": {
    "payload": {
      "EventType": "SettlersOfCrutan.Domain.Events.PlayerBuiltRoadEvent",
      "Payload": {
        "GameId": "46a1afb6-a6c6-4157-b01b-b20f06c19e30",
        "PlayerId": "a4f24eeb-c7d6-4d75-b8f9-97a782b9fd68",
        "From": {
          "X": -2,
          "Y": 0,
          "Z": 2
        },
        "To": {
          "X": -3,
          "Y": 1,
          "Z": 2
        }
      }
    }
  }
}

My outbox processor picks this up from the Redis stream, deserializes it into a PlayerBuiltRoadEvent object, and then publishes it to whoever cares—maybe a SignalR hub that notifies all players in the same game.

Redis Streams: Event Bus Built In

Redis Streams turn out to be a surprisingly great fit for domain events:

  • Low latency: Consumers can block on reads and pick up new messages instantly.

  • Concurrency: Consumer groups let me scale horizontally—multiple workers can handle the same stream safely.

  • Retries: Messages that fail stay in the “pending entries list” until processed, and I can move stubborn ones to a poison queue.

  • Simplicity: I don’t need extra infrastructure—I’m already running Redis anyway.

So my pipeline looks like this:

  • API Layer (.NET)
    Save Game + Outbox EventRedis

  • Redis
    Stream EntriesOutbox Processor (Aspire container)

  • Outbox Processor (Aspire container)
    Publish Domain EventsHandlers (SignalR / Projections / Notifications)

Aspire to the Rescue

Rather than stuffing outbox logic inside my API project, I used Aspire to spin up a separate container dedicated to processing Redis streams. That keeps responsibilities clear:

  • API service → handles requests, saves game state, appends events.

  • Outbox service → reads the stream, publishes events, retries when needed.

The two don’t block each other, and I can scale or restart them independently. It feels like a nice sweet spot between “all in one” and “full-blown distributed event bus.”

Putting It All Together

When I roll a 7:

  1. The game state change and the SevenRolledEvent go into Redis together.

  2. The outbox service picks up that event from the Redis stream.

  3. It publishes the event to all connected players via SignalR (coming soon in my next post).

Everyone’s board stays in sync, and I don’t have to worry about missed updates.

Why I Love This Setup

  • Consistency: State and events are always in sync thanks to the outbox.

  • Real-time friendly: Events give me the hook I need to broadcast updates.

  • Redis simplicity: One database doubles as my event bus.

  • Clean separation: Aspire keeps my event processor cleanly decoupled from my API.

It’s an unusual stack, but it’s been a great way to learn both Redis and Clean Architecture by practice.

What’s Next?

Next up, I’ll work on defining the business logic for all the Catan behavior that needs to happen. Once that’s in place and tested, I will start hooking up those domain events to SignalR for publishing to consumers, keeping players in sync with their game.