If you’ve ever tried to publish messages to Kafka or NATS right after writing to a database, you’ve probably run into that uncomfortable question:
“What if the DB transaction succeeds, but the message publish fails?”
This is one of those subtle yet nasty issues that cause data drift, ghost events, or missing events between your database and your message broker.
The Outbox Pattern fixes this.
In this post, we’ll break down why it exists, how it works, and how to implement it cleanly in Go — both conceptually and with actual code.
The Problem: Dual Writes Are Dangerous
tx := db.Begin()
err := db.Create(&order).Error
if err == nil {
err = nats.Publish("order.created", order)
}
tx.Commit()
Looks innocent. But if your publish call fails after the DB commit, your system just became inconsistent:
- The order exists in the database, but
- Other services (like Payment or Inventory) never got the event.
You can’t easily “replay” the missing event later.
This is a dual-write problem — you’re writing to two systems (DB + message broker) that can’t be part of the same transaction.
💡 The Idea: Store the Message with the Data
The Outbox Pattern says:
“Don’t send messages directly. Write them to your database (Outbox table) in the same transaction as your business data.”
Then, a background process (Outbox Dispatcher) reads the table and publishes the messages asynchronously to the broker.
This ensures atomicity between your data and your events.
Implementing the Outbox Pattern in Go
We’ll use:
- GORM for DB access (works with Postgres, MySQL, etc.)
- NATS as the event broker
- A simple goroutine as our Outbox Dispatcher
Step 1: Define the Models
// models.go
package models
import "time"
type Order struct {
ID uint `gorm:"primaryKey"`
Customer string
Status string
CreatedAt time.Time
}
type Outbox struct {
ID uint `gorm:"primaryKey"`
EventType string
Payload string
Processed bool
CreatedAt time.Time
ProcessedAt *time.Time
}
We keep the outbox simple: one table that stores unsent messages.
Step 2: Write Order and Outbox in the Same Transaction
// repository.go
package repository
import (
"encoding/json"
"gorm.io/gorm"
"saga-outbox/models"
)
func CreateOrderWithEvent(db *gorm.DB, order *models.Order) error {
return db.Transaction(func(tx *gorm.DB) error {
// 1. Insert order
if err := tx.Create(order).Error; err != nil {
return err
}
// 2. Prepare event
payload, _ := json.Marshal(order)
event := models.Outbox{
EventType: "OrderCreated",
Payload: string(payload),
Processed: false,
}
// 3. Insert into outbox table
return tx.Create(&event).Error
})
}
Now, if the DB transaction fails, no event is created.
If the transaction succeeds, the outbox row guarantees we’ll publish later.
Step 3: Outbox Dispatcher (Background Publisher)
The dispatcher periodically polls unprocessed outbox entries and publishes them to NATS.
// dispatcher.go
package dispatcher
import (
"encoding/json"
"fmt"
"log"
"time"
"github.com/nats-io/nats.go"
"gorm.io/gorm"
"saga-outbox/models"
)
func RunOutboxDispatcher(db *gorm.DB, natsURL string, stop <-chan struct{}) {
nc, err := nats.Connect(natsURL)
if err != nil {
log.Fatal("NATS connect error:", err)
}
defer nc.Close()
ticker := time.NewTicker(2 * time.Second)
for {
select {
case <-ticker.C:
var events []models.Outbox
if err := db.Where("processed = ?", false).Find(&events).Error; err != nil {
log.Println("DB query error:", err)
continue
}
for _, evt := range events {
if err := publishEvent(nc, &evt); err == nil {
markProcessed(db, &evt)
} else {
log.Println("Publish failed:", err)
}
}
case <-stop:
return
}
}
}
func publishEvent(nc *nats.Conn, evt *models.Outbox) error {
fmt.Printf("[outbox] publishing %s: %s\n", evt.EventType, evt.Payload)
return nc.Publish(fmt.Sprintf("evt.%s", evt.EventType), []byte(evt.Payload))
}
func markProcessed(db *gorm.DB, evt *models.Outbox) {
now := time.Now()
evt.Processed = true
evt.ProcessedAt = &now
db.Save(evt)
}
This loop runs safely and ensures events are never lost.
If NATS is down temporarily, messages will retry later.
Step 4: Hook It Up
// main.go
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"saga-outbox/models"
"saga-outbox/repository"
"saga-outbox/dispatcher"
)
func main() {
db, _ := gorm.Open(sqlite.Open("outbox.db"), &gorm.Config{})
db.AutoMigrate(&models.Order{}, &models.Outbox{})
// Create a sample order
order := &models.Order{Customer: "Alice", Status: "NEW"}
repository.CreateOrderWithEvent(db, order)
// Run dispatcher in background
stop := make(chan struct{})
go dispatcher.RunOutboxDispatcher(db, "nats://localhost:4222", stop)
// Wait for Ctrl+C
fmt.Println("Outbox dispatcher running...")
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
<-c
close(stop)
}
When you run this with NATS running, you’ll see something like:
Outbox dispatcher running...
[outbox] publishing OrderCreated: {"ID":1,"Customer":"Alice","Status":"NEW"}
Why the Outbox Pattern Works So Well
1. It guarantees consistency.
Database write and event creation happen inside the same transaction.
So either both happen, or neither do.
2. It decouples concerns.
Your business logic doesn’t need to worry about messaging failures.
The dispatcher owns reliability and retries.
3. It’s simple to reason about.
No distributed transactions, no two-phase commit.
Just a background loop reading rows and publishing events.
4. It’s compatible with any broker.
Kafka, NATS, RabbitMQ, SNS, you name it. You only change the dispatcher logic.
5. It scales naturally.
You can run multiple dispatcher instances safely (use SELECT ... FOR UPDATE SKIP LOCKED to prevent duplicates.