Outbox pattern
Queue mutations, sync later.
The outbox is a tiny table that says "these rows haven't been sent to the server yet". When you write a sale locally, you also write an outbox row. When the sync engine runs (next lesson), it drains the outbox.
The pattern
Outbox write flow
The outbox model
type OutboxEntry struct {ID int64 `gorm:"primaryKey;autoIncrement"` // local-onlyEntity string `gorm:"not null;index"` // 'sale', 'product', ā¦EntityID string `gorm:"not null;index"` // the business-row UUIDOperation string `gorm:"not null"` // 'create' | 'update' | 'delete'Payload datatypes.JSON // the row, serialisedAttempts int `gorm:"default:0"`LastError stringCreatedAt time.Time}
Notice: ID is auto-increment here (local-only) ā gives a clean FIFO order. EntityID is the UUID of the actual business row (a Sale, a Product), used to look up the latest state when syncing.
Writing through the outbox
func (s *Service) RecordSale(sale *Sale) error {return s.db.Transaction(func(tx *gorm.DB) error {if err := tx.Create(sale).Error; err != nil {return err}payload, _ := json.Marshal(sale)return tx.Create(&OutboxEntry{Entity: "sale",EntityID: sale.ID,Operation: "create",Payload: payload,}).Error})}
Both inserts in one transaction. If either fails, both roll back. You can never have a sale without a matching outbox entry, or vice versa.
The unsynced indicator
For UX, show the user what hasn't synced yet:
func (s *Service) PendingCount() (int64, error) {var n int64err := s.db.Model(&OutboxEntry{}).Where("attempts < 10").Count(&n).Errorreturn n, err}
function SyncStatus() {const { data } = useQuery({queryKey: ['pending'],queryFn: () => PendingCount(),refetchInterval: 3000,})return data === 0? <span className="text-green-500">ā synced</span>: <span className="text-yellow-500">{data} pending sync</span>}
Cashier glances at the corner of the screen, knows whether they're caught up.
Compacting + squashing
If a product gets updated 50 times offline, do you really need 50 outbox rows? No ā only the final state matters. A compaction step squashes the trail:
Before squash: After squash:outbox: product update 1 outbox: product update (latest)outbox: product update 2outbox: product update 3outbox: product update 4
For create-then-delete, both rows can be dropped ā the server never needed to know. Grit ships a squash function; call it before sync.
Quick check
Try it
Add the outbox to your Note workflow:
- Add the
OutboxEntrymodel + AutoMigrate it. - Wrap your
AddNoteservice in a transaction that inserts both the note AND the outbox entry. - Wire a
PendingCountmethod bound to React. Show the count in the corner. - Add 5 notes. Confirm the count goes from 0 ā 1 ā 2 ā ⦠ā 5.
What's next
Outbox is filling. Last lesson of the chapter ā the sync engine that drains it.
Spot a typo? Have an idea?
Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled ā suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.
Suggest an improvement on GitHub