When building event-sourced systems with multiple aggregates, read model projections, and integration event sinks, one question keeps coming back: how do you ensure that everything within a single business operation either succeeds together or fails together? The Unit of Work pattern provides an elegant answer. This post walks through a real-world implementation in Go, showing how it coordinates persistence across aggregates and projections within a bounded context while staying idiomatic.

# The Problem: Uncoordinated Persistence

Consider a subscription service. When a user subscribes, the system needs to:

  1. Store domain events in an event store (the source of truth)
  2. Update a read model (for fast queries)
  3. Dispatch a message to an outbox (to notify other bounded contexts)

Without coordination, each operation manages its own transaction:

// Dangerous: three independent transactions
func (h *Handler) Handle(ctx context.Context, cmd Subscribe) error {
    eventStore.Save(subscriber.Events())  // tx 1: commits
    readModel.Upsert(subscriber)          // tx 2: commits
    outbox.Dispatch(confirmationEvent)    // tx 3: fails!
    // Event store and read model are updated,
    // but the confirmation email is never sent.
    return err
}

If the outbox write fails, you're left with an inconsistent system: the subscriber exists but the integration event is lost. You could wrap everything in a single sql.Tx and pass it around, but that leaks infrastructure concerns into your domain logic and creates tight coupling between unrelated repositories.

# Enter Unit of Work

The Unit of Work pattern, originally described by Martin Fowler in Patterns of Enterprise Application Architecture, "maintains a list of objects affected by a business transaction and coordinates the writing out of changes and the resolution of concurrency problems." In practice, it ensures that when multiple repositories participate in the same business operation, they share a single database transaction.

At its core, the interface is minimal:

type UnitOfWork interface {
    Commit() error
    Rollback() error
}

That's the shared contract. Every module in the system agrees: operations happen within a Unit of Work, and the handler decides when to commit. If anything goes wrong, rollback undoes everything.

But the real power comes from what each bounded context adds on top of this base.

# Per-Bounded-Context Interfaces

A common mistake is creating a single, monolithic Unit of Work interface that exposes every repository in the system. This violates the separation that bounded contexts are meant to provide.

Instead, each bounded context defines its own Unit of Work interface, exposing only the repositories relevant to its domain:

// subscriptions/internal/application/unit_of_work.go
type UnitOfWork interface {
    Subscribers() (SubscriberRepository, error)
    SubscriberReadModel() SubscriberReadModelRepository
    Outbox() Outbox
    Commit() error
    Rollback() error
}
// users/internal/application/unit_of_work.go
type UnitOfWork interface {
    Users() (UserRepository, error)
    Sessions() (SessionRepository, error)
    UsersReadOnly() UserRepository
    SessionsReadOnly() SessionRepository
    Commit() error
    Rollback() error
}

This design has several benefits:

  • Explicit dependencies: A handler sees exactly which repositories are available within its transaction boundary.
  • No accidental coupling: The subscriptions context cannot accidentally reach into user repositories.
  • Repository construction is lazy: Calling Subscribers() creates the repository bound to the current transaction. You only pay for what you use.
  • Read vs. write separation: Read-only accessors bypass the transaction entirely, avoiding unnecessary locks.

The repositories returned by the Unit of Work all share the same underlying database transaction. That's the key insight: the Unit of Work is the factory for transactionally-consistent repository instances.

# The SQL Implementation

Under the hood, a concrete implementation holds a write context (the transaction) and a read-only context:

type SQLUnitOfWork struct {
    writeContext    persistence.DatabaseContext
    readOnlyContext persistence.DatabaseContext
    domainEventBus  *messaging.EventBus
    committed       bool
}

Construction opens the write transaction immediately:

func NewSQLUnitOfWork(
    _ context.Context,
    contextFactory persistence.ContextFactory,
    domainEventBus *messaging.EventBus,
) (persistence.UnitOfWork, error) {
    writeContext, err := contextFactory.CreateWriteContext()
    if err != nil {
        return nil, fmt.Errorf("failed to create write context: %w", err)
    }

    return &SQLUnitOfWork{
        writeContext:    writeContext,
        readOnlyContext: contextFactory.CreateReadOnlyContext(),
        domainEventBus:  domainEventBus,
        committed:       false,
    }, nil
}

Each repository accessor creates a repository bound to the shared write context:

func (u *SQLUnitOfWork) Subscribers() (application.SubscriberRepository, error) {
    eventStore := eventstore.NewSQLEventStore(
        schema, u.writeContext, u.readOnlyContext,
    )
    // register event types...

    return repositories.NewEventStoreRepository[subscriber.Subscriber, uuid.UUID](
        eventStore,
        subscriber.NewSubscriber,
        u.domainEventBus,
    ), nil
}

func (u *SQLUnitOfWork) Outbox() application.Outbox {
    return messaging.NewOutbox(schema, u.writeContext)
}

Both the event store repository and the outbox write to the same writeContext. When Commit() is called, both writes become visible atomically. If Rollback() is called instead, neither persists.

The commit/rollback implementation is straightforward, with a guard to make rollback idempotent:

func (u *SQLUnitOfWork) Commit() error {
    if err := u.writeContext.Commit(); err != nil {
        return err
    }
    u.committed = true
    return nil
}

func (u *SQLUnitOfWork) Rollback() error {
    if u.committed {
        return nil
    }
    return u.writeContext.Rollback()
}

Making Rollback() a no-op after commit is important: it allows callers to unconditionally defer rollback without worrying about whether commit was already called.

# Context Propagation with Generics

The Unit of Work needs to travel from where it's created (typically middleware) to where it's used (command handlers). Go's context.Context is the natural vehicle for request-scoped values.

The challenge: the shared package only knows about the base UnitOfWork interface, but handlers need the context-specific interface (with Subscribers(), Outbox(), etc.). Go generics solve this cleanly:

func GetUnitOfWorkFromContext[T UnitOfWork](ctx context.Context) (T, error) {
    unitOfWork, ok := ctx.Value(unitOfWorkContextKey{}).(T)
    if !ok {
        var t T
        return t, errors.New("UnitOfWork not found in context")
    }
    return unitOfWork, nil
}

func AddUnitOfWorkToContext(ctx context.Context, unitOfWork UnitOfWork) context.Context {
    return context.WithValue(ctx, unitOfWorkContextKey{}, unitOfWork)
}

Storage uses the base interface (any UnitOfWork goes in). Retrieval uses a type parameter, so the handler gets back the exact interface it needs:

// In the subscriptions handler:
uow, err := persistence.GetUnitOfWorkFromContext[application.UnitOfWork](ctx)
// uow is now subscriptions' application.UnitOfWork with Subscribers(), Outbox(), etc.

This is type-safe at compile time. If you accidentally request the wrong bounded context's interface from a context that holds a different implementation, you get a clear error rather than a nil pointer panic.

# Transaction-per-Request Middleware

For HTTP services, the natural transaction boundary is the request. A middleware creates the Unit of Work, injects it into the context, and ensures cleanup:

func UnitOfWorkMiddleware(
    db *sql.DB,
    domainEventBus *messaging.EventBus,
    logger *log.Logger,
    constructor UnitOfWorkConstructor,
) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx := r.Context()

            unitOfWork, err := constructor(
                ctx, NewSQLContextFactory(db), domainEventBus,
            )
            if err != nil {
                logger.Error("Failed to create unit of work", "error", err)
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
                return
            }
            defer func() {
                if err = unitOfWork.Rollback(); err != nil {
                    logger.Error("Failed to rollback unit of work", "error", err)
                }
            }()

            next.ServeHTTP(
                w, r.WithContext(AddUnitOfWorkToContext(ctx, unitOfWork)),
            )
        })
    }
}

The constructor parameter is a function type, allowing each bounded context to provide its own NewSQLUnitOfWork implementation. The middleware doesn't know which repositories exist; it only knows how to manage the lifecycle.

The deferred Rollback() is the safety net: if the handler panics, returns an error, or simply forgets to commit, the transaction is rolled back. Since rollback after commit is a no-op, this is always safe.

# Putting It All Together

Here's how a real command handler uses the Unit of Work to atomically persist events, update a read model, and dispatch an outbox message:

func (h *InitiateSubscribeHandler) Handle(
    ctx context.Context, command InitiateSubscribe,
) (bool, error) {
    unitOfWork, err := persistence.GetUnitOfWorkFromContext[application.UnitOfWork](ctx)
    if err != nil {
        return false, fmt.Errorf("failed to get unit of work: %w", err)
    }

    // Check existing state via read model (no transaction needed)
    existing, err := unitOfWork.SubscriberReadModel().
        FindByAttribute("email_address", string(command.EmailAddress))
    if err == nil && existing.Status == "active" {
        return false, fmt.Errorf("subscriber already active")
    }

    // Create domain aggregate, apply business rules
    newSubscriber := subscriber.Create()
    newSubscriber, err = newSubscriber.InitiateSubscribe(string(command.EmailAddress))
    if err != nil {
        return false, fmt.Errorf("failed to initiate subscribe: %w", err)
    }

    // Persist via event store (writes to shared transaction)
    subscribers, err := unitOfWork.Subscribers()
    if err != nil {
        return false, fmt.Errorf("failed to get subscribers: %w", err)
    }
    id, err := subscribers.Save(*newSubscriber)
    if err != nil {
        return false, fmt.Errorf("failed to save subscriber: %w", err)
    }

    // Dispatch to outbox (writes to same shared transaction)
    err = unitOfWork.Outbox().Dispatch(
        integration.SubscriptionConfirmationRequested{
            EmailAddress: string(command.EmailAddress),
            Subject:      emailTemplate.Subject,
            Body:         emailTemplate.Body,
        },
    )
    if err != nil {
        return false, fmt.Errorf("failed to dispatch event: %w", err)
    }

    // Commit: event store + outbox become visible atomically
    err = unitOfWork.Commit()
    if err != nil {
        return false, fmt.Errorf("failed to commit unit of work: %w", err)
    }

    return true, nil
}

Notice what's happening: the event store write, the outbox dispatch, and any read model updates all go through the same writeContext. The single Commit() call at the end makes them all visible atomically. If any step fails before commit, the deferred rollback in the middleware cleans up everything.

The handler has no knowledge of SQL, transactions, or database connections. It works entirely through domain-oriented interfaces. This means you can test it with an in-memory Unit of Work implementation that replaces the SQL machinery entirely.

# Event Sourcing and the Unit of Work

Event sourcing introduces a fundamental coordination problem. The event store is the source of truth — all state changes are captured as an append-only sequence of domain events. But events alone are terrible for queries. You can't efficiently answer "find all active subscribers" by replaying every event in the system. You need materialized views: read models that denormalize the event data into query-friendly structures.

This creates a dual-write problem within a single bounded context. Every command that changes aggregate state must:

  1. Append new events to the event store
  2. Update one or more read models so queries reflect the new state
  3. Optionally write an integration event to an outbox for cross-context communication

Each of these is a separate write to the database. Without transactional coordination, you risk events being persisted while the read model lags, or worse — events persisted but the outbox message lost entirely.

The Unit of Work eliminates this class of inconsistency. Because the event store, read model repository, and outbox all receive the same writeContext (a database transaction), they commit or rollback as a unit. The event store append, the read model upsert, and the outbox insert are all just rows in different tables within the same transaction.

# Aggregate Reconstitution and the Read-Only Context

A subtle but important detail: when the UoW loads an aggregate from the event store, it replays events using the readOnlyContext, not the write transaction. This matters for two reasons:

  1. No lock contention: Reading historical events doesn't acquire write locks. Multiple handlers can reconstitute aggregates concurrently without blocking each other.
  2. Transaction isolation: The write transaction sees only its own uncommitted changes. By reading from a separate connection, you get a consistent snapshot of the committed state without interference from in-flight writes.
func (u *SQLUnitOfWork) Subscribers() (application.SubscriberRepository, error) {
    eventStore := eventstore.NewSQLEventStore(
        schema, u.writeContext, u.readOnlyContext,
    )
    // ...
}

The event store receives both contexts: it reads from readOnlyContext (to replay events and reconstitute aggregates) and writes to writeContext (to append new events). This separation keeps the write transaction short and focused on the actual mutations.

# The Outbox as Just Another Event Sink

From the Unit of Work's perspective, the outbox is no different from a read model — it's another table that receives writes within the same transaction. The distinction is in purpose: read models serve queries within the bounded context, while the outbox bridges to other contexts via eventual consistency. But mechanically, both are transactional event sinks coordinated by the same Commit() call.

Vaughn Vernon, in Implementing Domain-Driven Design, advocates modifying only a single aggregate per transaction. This is sound advice for aggregate-to-aggregate consistency. But read model updates and outbox writes aren't aggregate modifications — they're projections and integration plumbing. The Unit of Work pattern acknowledges this distinction: it coordinates the full set of persistence effects triggered by a single command, not just aggregate state changes.

# Read Model Projections: Synchronous vs. Asynchronous

The Unit of Work enables two distinct approaches to keeping read models consistent with the event store. Each has real trade-offs.

# Synchronous Projections (In-Transaction)

In this approach, the read model is updated within the same transaction that appends events to the event store. The SubscriberReadModel() on the UoW exposes a repository bound to the write transaction:

type UnitOfWork interface {
    Subscribers() (SubscriberRepository, error)
    SubscriberReadModel() SubscriberReadModelRepository  // same transaction
    Outbox() Outbox
    Commit() error
    Rollback() error
}

When the handler commits, both the events and the read model update become visible atomically. If anything fails, the rollback covers everything — the read model never shows data that isn't backed by events in the store.

Benefits:

  • Strong consistency: Queries always reflect the latest committed state. No stale reads within the same context.
  • Simplicity: No background workers, no retry logic, no idempotency concerns.
  • Atomicity for free: The UoW's existing transaction coordination handles everything.

Costs:

  • Longer transactions: The write transaction now includes read model table writes, which may involve index updates and additional I/O.
  • Coupling write and read schemas: Schema changes to the read model affect the write path's transaction.
  • Scaling limits: High-write workloads may bottleneck on transaction duration.

# Asynchronous Projections (Event-Driven)

In this approach, read model updates happen after the command transaction commits. A projector subscribes to domain events and processes them independently, creating its own Unit of Work for each projection batch:

func (p *IssueProjector) Handle(ctx context.Context, event domain.Event) error {
    unitOfWork, err := p.unitOfWorkFactory(ctx)
    if err != nil {
        return err
    }
    defer unitOfWork.Rollback()

    readModel := unitOfWork.IssueReadModel()
    // project the event into the read model...

    return unitOfWork.Commit()
}

The projector runs in its own transaction, decoupled from the original command's transaction. The original command only appends events and writes to the outbox. The read model catches up asynchronously.

Benefits:

  • Short write transactions: The command path only writes events and outbox messages. Fast commits, fewer locks.
  • Independent scaling: Projectors can run on separate infrastructure, process events in batches, and rebuild read models from scratch without affecting the write path.
  • Schema independence: Read model schema changes don't affect the write path at all.

Costs:

  • Eventual consistency: Queries may return stale data until the projector catches up. Within a single bounded context, this window is typically milliseconds, but it's non-zero.
  • Idempotency required: If projector processing fails and retries, it must handle duplicate events gracefully.
  • Ordering guarantees: Events must be processed in order per aggregate to avoid corrupting the read model.

# When to Choose Each

Use synchronous projections when query freshness is critical to the business operation itself — for example, the subscription handler that checks "is this email already active?" before creating a new subscriber. If the read model lags, you might create duplicate subscriptions.

Use asynchronous projections when the read model serves reporting, dashboards, or queries that tolerate brief staleness. This is the common case for CQRS systems with high write throughput.

Both approaches rely on the Unit of Work. Synchronous projections use the command's UoW. Asynchronous projections create their own UoW per batch, giving them the same atomicity guarantees (projector updates and checkpoint advances commit together).

# Trade-offs and Alternatives

The Unit of Work pattern is not free. Here's what you're signing up for:

Benefits:

  • Atomic consistency across aggregates, read models, and event sinks within a bounded context
  • Clean separation between domain logic and transaction management
  • Natural integration point for both synchronous projections and the outbox pattern
  • Simplified testing through interface substitution
  • Safety net via deferred rollback

Costs:

  • Transactions are held open for the duration of the request. Long-running handlers hold database connections and locks longer.
  • The pattern implies a single database (or at least a single transaction coordinator). If your aggregates live in different databases, you need different approaches (sagas, eventual consistency).
  • There's a layer of indirection: repositories are obtained from the UoW rather than injected directly. This is unfamiliar to developers used to constructor-injected repositories.

When to skip it:

  • Simple CRUD services with a single repository per operation don't need transaction coordination.
  • Read-heavy services that rarely write can get by with explicit transactions at the repository level.
  • If your bounded context genuinely only touches one aggregate and has no read model or outbox, a simpler "repository wraps its own transaction" approach works fine.

Alternatives:

  • Explicit transaction passing: Pass *sql.Tx directly to repositories. Simpler, but leaks infrastructure into your domain layer.
  • Decorated repositories: Wrap repositories with transaction-aware decorators. Works but gets complex with multiple repositories.
  • Commit in middleware (instead of handler): The middleware auto-commits if no error occurs. Simpler for handlers, but you lose explicit control over when the transaction boundary ends.

# Conclusion

The Unit of Work pattern gives you a single coordination point for all persistence operations within a business transaction. In Go, it maps naturally to interfaces, context propagation, and generics for type-safe retrieval.

The key design choices that make this implementation work:

  1. Minimal base interface (Commit + Rollback) shared across the system
  2. Per-bounded-context interfaces that expose only relevant repositories
  3. Repositories created from the UoW, guaranteeing they share the same transaction
  4. Generics for type-safe context retrieval, bridging the shared infrastructure and specific bounded contexts
  5. Middleware for lifecycle management, with deferred rollback as a safety net

The real payoff comes when you combine it with event sourcing: domain events, read model updates, and integration messages all commit atomically. No partial writes, no lost messages, no inconsistent state.

# Further Reading