Event sourcing stores the full history of what happened to your data, not just its current state. In the right context, it eliminates entire categories of audit and debugging problems. In the wrong context, it adds weeks of complexity for no gain. This post covers how to build a simple event store in PostgreSQL with Go, when the pattern is worth it, and the specific points where teams consistently overcomplicate it.
Event sourcing stores the full history of what happened to your data, not just its current state. Instead of updating a row in place, you append an event that describes the change, and the current state of any entity is derived by replaying its events in order. In the right context, this eliminates entire categories of audit and debugging problems. In the wrong context, it adds weeks of complexity for no real gain. This post covers how to build a simple event store in PostgreSQL with Go, when the pattern is worth applying in a MENA SaaS context, and the specific points where teams consistently overcomplicate it.
What problem does event sourcing actually solve?
Consider a SaaS order management system for a Lebanese restaurant chain. An order is created, items are added, the customer requests a modification, the kitchen marks one item unavailable, a partial refund is issued, and the order is finally fulfilled. In a traditional database model, you have one row in the orders table and the final state reflects none of this history.
When the owner asks what was ordered versus what was delivered, and why the refund was issued, you have no data. If you built an audit log alongside the main tables, you have it twice in different formats with inconsistency risk.
Event sourcing solves this by making the history the source of truth. There is no update to the orders row. There is only a sequence of events: OrderCreated, ItemAdded, ItemModified, ItemUnavailable, PartialRefundIssued, OrderFulfilled. The current state of the order is always computed from replaying these events. The history is not a secondary concern. It is the primary data model.
When is event sourcing the right choice for a SaaS backend?
Event sourcing adds real value when all three of these are true:
- The entity lifecycle is complex and the transitions between states matter
- You need a complete, reliable audit trail for compliance or business reasons
- You need the ability to rebuild different views of the same data for different consumers
For a Lebanese clinic management platform, patient records and appointment histories meet all three criteria. For a simple B2B SaaS with a handful of settings tables that rarely change, event sourcing adds friction without benefit.
The common mistake is applying event sourcing to the entire domain because it worked well for one entity. Most domains have 10 to 20 percent of entities that benefit from event sourcing and 80 percent that are better served by a conventional update model.
How do you build an event store in PostgreSQL?
The event store is a single append-only table. The key constraint is that events are never updated or deleted. Once written, they are immutable:
CREATE TABLE event_store (
id BIGSERIAL PRIMARY KEY,
aggregate_id UUID NOT NULL,
tenant_id UUID NOT NULL,
event_type TEXT NOT NULL,
version INT NOT NULL,
payload JSONB NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Enforce version uniqueness per aggregate to prevent concurrent write conflicts
CREATE UNIQUE INDEX event_store_aggregate_version
ON event_store (aggregate_id, version);
-- Fast retrieval of all events for an aggregate
CREATE INDEX event_store_aggregate_history
ON event_store (aggregate_id, version ASC);
-- Tenant isolation
CREATE INDEX event_store_tenant_idx
ON event_store (tenant_id, occurred_at DESC);
The version field per aggregate is critical. It is the optimistic concurrency lock. When two requests try to append event version 5 for the same aggregate simultaneously, the unique index ensures only one succeeds. The other receives a unique constraint violation, retries by loading the latest state, and re-applies its logic.
How do you write events in Go?
The event store client has two methods: Append and LoadHistory.
type Event struct {
AggregateID uuid.UUID
TenantID uuid.UUID
EventType string
Version int
Payload json.RawMessage
Metadata json.RawMessage
OccurredAt time.Time
}
type EventStore struct {
db *sqlx.DB
}
func (es *EventStore) Append(ctx context.Context, events []Event) error {
tx, err := es.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, e := range events {
_, err = tx.ExecContext(ctx, `
INSERT INTO event_store
(aggregate_id, tenant_id, event_type, version, payload, metadata)
VALUES ($1, $2, $3, $4, $5, $6)`,
e.AggregateID, e.TenantID, e.EventType, e.Version, e.Payload, e.Metadata,
)
if err != nil {
if isUniqueViolation(err) {
return ErrConcurrentModification
}
return err
}
}
return tx.Commit()
}
func (es *EventStore) LoadHistory(ctx context.Context, aggregateID uuid.UUID) ([]Event, error) {
rows, err := es.db.QueryContext(ctx, `
SELECT aggregate_id, tenant_id, event_type, version, payload, metadata, occurred_at
FROM event_store
WHERE aggregate_id = $1
ORDER BY version ASC`,
aggregateID,
)
if err != nil {
return nil, err
}
defer rows.Close()
// scan rows into []Event
}
How do you reconstruct an aggregate from its events?
An aggregate is a domain object whose state you rebuild by applying events in sequence:
type Order struct {
ID uuid.UUID
TenantID uuid.UUID
Status string
Items []OrderItem
Total decimal.Decimal
Version int
}
func (o *Order) Apply(event Event) error {
switch event.EventType {
case "OrderCreated":
var p OrderCreatedPayload
if err := json.Unmarshal(event.Payload, &p); err != nil {
return err
}
o.ID = event.AggregateID
o.TenantID = event.TenantID
o.Status = "pending"
o.Total = decimal.Zero
case "ItemAdded":
var p ItemAddedPayload
if err := json.Unmarshal(event.Payload, &p); err != nil {
return err
}
o.Items = append(o.Items, OrderItem{ID: p.ItemID, Price: p.Price, Qty: p.Qty})
o.Total = o.Total.Add(p.Price.Mul(decimal.NewFromInt(int64(p.Qty))))
case "OrderFulfilled":
o.Status = "fulfilled"
}
o.Version = event.Version
return nil
}
func RebuildOrder(events []Event) (*Order, error) {
order := &Order{}
for _, e := range events {
if err := order.Apply(e); err != nil {
return nil, err
}
}
return order, nil
}
What are projections and when do you need them?
Replaying events every time you need the current state is expensive for aggregates with thousands of events. Projections solve this. A projection is a read-optimized view of aggregate state that is updated asynchronously as events are appended.
For an order management system, you maintain a orders_summary table as a projection:
CREATE TABLE orders_summary (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
status TEXT NOT NULL,
item_count INT NOT NULL,
total NUMERIC(12,2) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
A background worker reads new events from the event store and updates the projection. The API reads from the projection for list queries and rebuilds from the event store only for detail views or when you need the full history.
The tradeoff is eventual consistency. The projection is always slightly behind the event store. For most SaaS admin dashboards and reporting screens, this lag (typically under 500ms) is acceptable. For operations that need to immediately reflect a state change, read from the event store directly.
What should you avoid when implementing event sourcing?
Avoid event sourcing for reference data. Lookup tables like categories, product_types, or country_codes do not benefit from event sourcing. You update them rarely, they have no meaningful lifecycle, and storing 40 event rows just to track 4 name changes is overhead without value.
Avoid designing events around technical operations. UserFieldUpdated is not an event. CustomerAddressVerified is. Events should reflect business intent, not data mutations. This distinction matters because business-intent events can be understood and replayed months later. Field-update events lose meaning when the domain changes.
Avoid replaying all events on every read. Use snapshots for aggregates with more than a few hundred events. A snapshot stores the current aggregate state at a specific version, and you only need to replay events from that version forward.
Avoid PostgreSQL as a long-term event store for very high-volume systems. For a Lebanese SaaS with 10,000 daily active users, PostgreSQL handles event sourcing comfortably. At 10 million daily events per tenant, purpose-built event stores like EventStoreDB become relevant. Start with PostgreSQL and migrate when you have data showing you need to.
Key lessons from production
Event sourcing in a Go backend with PostgreSQL works well for complex, audit-critical entities. Keep the implementation simple: one event store table with a unique version constraint, aggregate state rebuilt by replaying events, projections for read-heavy query patterns. Apply it selectively to the entities that genuinely benefit from history. Everything else stays as conventional CRUD. The complexity of event sourcing is justified by the problems it solves, not by architectural elegance.
Not sure where to start?
Voxire designs and builds SaaS backends for companies across Lebanon and the MENA region. If you are deciding whether event sourcing fits your domain or need help structuring a Go backend for a complex operational system, get in touch.
https://voxire.com/get-a-quote/
Enjoying this article?
Enter your email and get a clean, formatted PDF of this article - free, no spam.



