{"schemaVersion":1,"id":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons","url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons","title":"Event Sourcing + CQRS: Event Store Design, Projections, Snapshots, and When NOT to Use It","description":"Production lessons from building event-sourced systems, event store design, projection rebuilds, snapshot strategies, saga orchestration, and an honest assessment of when event sourcing makes things worse.","language":"en","datePublished":"2026-02-22","dateModified":null,"contentFormat":"text/markdown","content":"Architecture\n\n# Event Sourcing + CQRS: Event Store Design, Projections, Snapshots, and When NOT to Use It\n\n2026-02-228 min read\n\n[Lucio Durán](<https://lucioduran.com/>)\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/event-sourcing-cqrs-production-lessons>)\n\n## Event Sourcing Fundamentals\n\nInstead of storing the current state of an entity, you store the sequence of events that led to that state. The current state is derived by replaying events. This pattern is typically implemented with [PostgreSQL](<https://www.postgresql.org/>) or a dedicated event store.\n\n```python\n# event_store.py - Core event sourcing abstractions\nfrom dataclasses import dataclass, field\nfrom datetime import datetime\nfrom typing import Any\nimport json\nimport uuid\n\n@dataclass(frozen=True)\nclass Event:\n event_id: str\n stream_id: str\n event_type: str\n data: dict\n metadata: dict\n version: int\n timestamp: datetime\n schema_version: int = 1\n\n@dataclass\nclass EventStream:\n stream_id: str\n events: list[Event] = field(default_factory=list)\n version: int = 0\n\nclass EventStore:\n \"\"\"PostgreSQL-backed event store.\"\"\"\n\n def __init__(self, connection):\n self.conn = connection\n\n def append(self, stream_id: str, events: list[Event],\n expected_version: int) -> None:\n \"\"\"\n Append events to a stream with optimistic concurrency.\n Fails if expected_version doesn't match current version.\n \"\"\"\n with self.conn.cursor() as cur:\n # Check current version (optimistic concurrency)\n cur.execute(\n \"SELECT MAX(version) FROM events WHERE stream_id = %s FOR UPDATE\",\n (stream_id,)\n )\n current_version = cur.fetchone()[0] or 0\n\n if current_version != expected_version:\n raise ConcurrencyError(\n f\"Expected version {expected_version}, \"\n f\"got {current_version} for stream {stream_id}\"\n )\n\n # Append events\n for i, event in enumerate(events):\n new_version = expected_version + i + 1\n cur.execute(\"\"\"\n INSERT INTO events\n (event_id, stream_id, event_type, data, metadata,\n version, timestamp, schema_version)\n VALUES (%s, %s, %s, %s, %s, %s, %s, %s)\n \"\"\", (\n event.event_id, stream_id, event.event_type,\n json.dumps(event.data), json.dumps(event.metadata),\n new_version, event.timestamp, event.schema_version\n ))\n\n self.conn.commit()\n\n def read_stream(self, stream_id: str,\n from_version: int = 0) -> list[Event]:\n with self.conn.cursor() as cur:\n cur.execute(\"\"\"\n SELECT event_id, stream_id, event_type, data, metadata,\n version, timestamp, schema_version\n FROM events\n WHERE stream_id = %s AND version > %s\n ORDER BY version ASC\n \"\"\", (stream_id, from_version))\n\n return [\n Event(\n event_id=row[0], stream_id=row[1],\n event_type=row[2], data=json.loads(row[3]),\n metadata=json.loads(row[4]), version=row[5],\n timestamp=row[6], schema_version=row[7]\n )\n for row in cur.fetchall()\n ]\n```\n\n### The Event Store Schema\n\n```sql\n-- event_store.sql\nCREATE TABLE events (\n event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n stream_id TEXT NOT NULL,\n event_type TEXT NOT NULL,\n data JSONB NOT NULL,\n metadata JSONB NOT NULL DEFAULT '{}',\n version INTEGER NOT NULL,\n timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),\n schema_version INTEGER NOT NULL DEFAULT 1,\n\n -- Optimistic concurrency: unique stream + version\n UNIQUE (stream_id, version)\n);\n\nCREATE INDEX idx_events_stream ON events (stream_id, version);\nCREATE INDEX idx_events_type ON events (event_type, timestamp);\nCREATE INDEX idx_events_timestamp ON events (timestamp);\n\n-- Snapshots table\nCREATE TABLE snapshots (\n stream_id TEXT NOT NULL,\n version INTEGER NOT NULL,\n state JSONB NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n PRIMARY KEY (stream_id, version)\n);\n```\n\n## Aggregates: Where Business Logic Lives\n\n```python\n# order_aggregate.py\nclass OrderAggregate:\n def __init__(self):\n self.order_id = None\n self.status = None\n self.items = []\n self.total = 0\n self.version = 0\n\n # Command handlers - validate and produce events\n def place_order(self, order_id: str, items: list, customer_id: str):\n if self.status is not None:\n raise InvalidOperationError(\"Order already exists\")\n if not items:\n raise ValidationError(\"Order must have at least one item\")\n\n total = sum(item['price'] * item['quantity'] for item in items)\n\n return [Event(\n event_id=str(uuid.uuid4()),\n stream_id=f\"order-{order_id}\",\n event_type=\"OrderPlaced\",\n data={\n 'order_id': order_id,\n 'customer_id': customer_id,\n 'items': items,\n 'total': total,\n },\n metadata={'causation_id': str(uuid.uuid4())},\n version=self.version + 1,\n timestamp=datetime.utcnow()\n )]\n\n def confirm_payment(self, payment_id: str, amount: float):\n if self.status != 'placed':\n raise InvalidOperationError(f\"Cannot pay for order in status {self.status}\")\n if amount < self.total:\n raise ValidationError(f\"Payment {amount} less than total {self.total}\")\n\n return [Event(\n event_id=str(uuid.uuid4()),\n stream_id=f\"order-{self.order_id}\",\n event_type=\"PaymentConfirmed\",\n data={'payment_id': payment_id, 'amount': amount},\n metadata={},\n version=self.version + 1,\n timestamp=datetime.utcnow()\n )]\n\n # Event handlers - apply events to rebuild state\n def apply(self, event: Event):\n handler = getattr(self, f'_on_{event.event_type}', None)\n if handler:\n handler(event.data)\n self.version = event.version\n\n def _on_OrderPlaced(self, data):\n self.order_id = data['order_id']\n self.status = 'placed'\n self.items = data['items']\n self.total = data['total']\n\n def _on_PaymentConfirmed(self, data):\n self.status = 'paid'\n\n def _on_OrderShipped(self, data):\n self.status = 'shipped'\n\n def _on_OrderCancelled(self, data):\n self.status = 'cancelled'\n\n @classmethod\n def from_events(cls, events: list[Event]) -> 'OrderAggregate':\n aggregate = cls()\n for event in events:\n aggregate.apply(event)\n return aggregate\n```\n\n## Projections: Building Read Models\n\nProjections transform the event log into query-optimized read models. This is the \"Q\" in CQRS.\n\n```python\n# projections.py\nclass OrderDashboardProjection:\n \"\"\"Projects events into a denormalized read model for the order dashboard.\"\"\"\n\n def __init__(self, read_db):\n self.db = read_db\n self.handlers = {\n 'OrderPlaced': self._handle_order_placed,\n 'PaymentConfirmed': self._handle_payment_confirmed,\n 'OrderShipped': self._handle_order_shipped,\n 'OrderCancelled': self._handle_order_cancelled,\n }\n\n def handle(self, event: Event):\n handler = self.handlers.get(event.event_type)\n if handler:\n handler(event)\n # Track projection position for restart\n self._update_checkpoint(event.version, event.timestamp)\n\n def _handle_order_placed(self, event):\n data = event.data\n self.db.execute(\"\"\"\n INSERT INTO order_dashboard\n (order_id, customer_id, status, total, item_count,\n placed_at, updated_at)\n VALUES (%s, %s, 'placed', %s, %s, %s, %s)\n ON CONFLICT (order_id) DO UPDATE SET\n status = 'placed',\n total = EXCLUDED.total,\n updated_at = EXCLUDED.updated_at\n \"\"\", (\n data['order_id'], data['customer_id'],\n data['total'], len(data['items']),\n event.timestamp, event.timestamp\n ))\n\n def _handle_payment_confirmed(self, event):\n self.db.execute(\"\"\"\n UPDATE order_dashboard\n SET status = 'paid', paid_at = %s, updated_at = %s\n WHERE order_id = %s\n \"\"\", (event.timestamp, event.timestamp, event.data.get('order_id', event.stream_id.replace('order-', ''))))\n\n def rebuild(self, event_store: EventStore):\n \"\"\"Rebuild the entire projection from scratch.\"\"\"\n self.db.execute(\"TRUNCATE order_dashboard\")\n self.db.execute(\"DELETE FROM projection_checkpoints WHERE projection = 'order_dashboard'\")\n\n events = event_store.read_all(batch_size=1000)\n for batch in events:\n for event in batch:\n self.handle(event)\n self.db.commit()\n```\n\n## Snapshots: Taming Rehydration Cost\n\nWhen an aggregate has thousands of events, replaying them all for every command is expensive. Snapshots capture the aggregate state at a point in time:\n\n```python\n# snapshot_strategy.py\nclass SnapshotRepository:\n def __init__(self, event_store: EventStore, snapshot_store, interval: int = 100):\n self.event_store = event_store\n self.snapshot_store = snapshot_store\n self.interval = interval\n\n def load(self, stream_id: str) -> OrderAggregate:\n # Try to load from snapshot first\n snapshot = self.snapshot_store.get_latest(stream_id)\n\n if snapshot:\n aggregate = OrderAggregate()\n aggregate.__dict__.update(snapshot['state'])\n aggregate.version = snapshot['version']\n # Load only events after the snapshot\n events = self.event_store.read_stream(stream_id, from_version=snapshot['version'])\n else:\n aggregate = OrderAggregate()\n events = self.event_store.read_stream(stream_id)\n\n for event in events:\n aggregate.apply(event)\n\n return aggregate\n\n def save(self, stream_id: str, aggregate: OrderAggregate, new_events: list[Event]):\n self.event_store.append(stream_id, new_events, aggregate.version - len(new_events))\n\n # Take snapshot every N events\n if aggregate.version % self.interval == 0:\n self.snapshot_store.save(\n stream_id=stream_id,\n version=aggregate.version,\n state=aggregate.__dict__\n )\n```\n\n## When NOT to Use Event Sourcing\n\nThis section addresses common scenarios where event sourcing makes things worse:\n\n**1. Simple CRUD applications.** If your domain is \"user fills form, data goes in database, someone reads it later,\" event sourcing adds complexity with no benefit. A PostgreSQL table with ` created_at ` and ` updated_at ` columns covers 90% of audit needs.\n\n**2. When your team doesn't understand DDD.** Event sourcing without proper aggregate boundaries becomes a nightmare. If your team can't answer \"what is the consistency boundary here?\", you'll end up with a distributed monolith that's harder to reason about than the database it replaced.\n\n**3. When you need ad-hoc queries.** Event stores are optimized for reading a single stream. Cross-aggregate queries require projections. If your primary use case is \"show me all orders from customer X that were placed in March and contain product Y,\" you need a projection for every new query pattern. With a relational database, it's a WHERE clause.\n\n**4. Prototypes and MVPs.** The time you spend building event store infrastructure, projection rebuilding, snapshot strategies, and upcasting is time you're not spending on validating your business hypothesis. Ship the MVP with PostgreSQL. Migrate to event sourcing later if the domain complexity justifies it.\n\n**5. When eventual consistency is unacceptable.** Projections are eventually consistent with the event store. There's always a delay between an event being stored and the read model being updated. If your business requires that a write is immediately visible in queries (e.g., a financial transaction), you need to work around this, and the workarounds add complexity.\n\nA cautionary example: a user management service where users sign up, update their profile, and change their email. With perhaps five event types, the \"audit trail\" requirement that motivated event sourcing could have been solved with a simple ` user_audit_log ` table. Instead, the system had an event store, three projections, a snapshot strategy, and an upcasting pipeline for when a ` display_name ` field was added. Architecturally interesting and practically terrible.\n\n## When Event Sourcing Shines\n\nSystems where event sourcing succeeds tend to share common traits:\n\n- **Complex domain logic** with many state transitions and business rules\n- **Audit requirements** that went beyond \"who changed what\" to \"what was the state at time T and why\"\n- **Event-driven integrations** where downstream systems needed to react to domain events\n- **Temporal queries** like \"show me the order as it was at 3pm yesterday\"\n\nConsider a trading platform where regulatory requirements demand a complete, immutable audit trail of every state change. Or a logistics system where dozens of downstream services need to react to shipment events. In both cases, event sourcing is the natural fit, and the alternative (change data capture on a relational database) would be more complex, not less.\n\nThe reality is that most systems do not need event sourcing. But when the use case demands it, nothing else comes close.\n\n## Frequently asked questions\n\n### When should I NOT use event sourcing?\n\nCRUD-heavy applications with simple data models, systems where audit trails aren't important, teams without DDD experience, prototypes and MVPs, and any system where the added complexity isn't justified by the benefits. Most applications are better served by a traditional database with change data capture for audit needs.\n\n### What's the difference between event sourcing and event-driven architecture?\n\nEvent-driven architecture uses events for communication between services. Event sourcing stores state as a sequence of events. You can have event-driven architecture without event sourcing (most do), and you can have event sourcing without an event-driven architecture (single-service ES).\n\n### How do I handle schema evolution in an event store?\n\nThree approaches: upcasting (transform old events to new schema on read), versioned event handlers (handle both v1 and v2 in your projections), or copy-transform (migrate events to a new store with the new schema). Upcasting is the most common and least disruptive.\n\n## Tips\n\n1. **Tip:** Store events as immutable facts with ` schema_version ` fields. You will change event schemas, upcasting from v1 to v2 is much easier than rewriting the event store.\n2. **Tip:** Snapshot every 100-200 events per aggregate. Without snapshots, rehydrating an aggregate with 10K events takes seconds, users notice.\n3. **Tip:** Projections should be idempotent and rebuildable from scratch. If you can't delete a projection and rebuild it from the event log, your architecture has a hidden dependency.\n4. **Tip:** Use ` optimistic concurrency ` on the event stream version. If two commands try to append to the same stream simultaneously, the second one must fail and retry, this is your consistency boundary.\n\n- event-sourcing\n- cqrs\n- event-store\n- projections\n- sagas\n- architecture\n- ddd\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Event Sourcing + CQRS: Event Store Design, Projections, Snapshots, and When NOT to Use It","url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#title"},{"level":2,"text":"Event Sourcing Fundamentals","url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#event-sourcing-fundamentals"},{"level":3,"text":"The Event Store Schema","url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#the-event-store-schema"},{"level":2,"text":"Aggregates: Where Business Logic Lives","url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#aggregates-where-business-logic-lives"},{"level":2,"text":"Projections: Building Read Models","url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#projections-building-read-models"},{"level":2,"text":"Snapshots: Taming Rehydration Cost","url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#snapshots-taming-rehydration-cost"},{"level":2,"text":"When NOT to Use Event Sourcing","url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#when-not-to-use-event-sourcing"},{"level":2,"text":"When Event Sourcing Shines","url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#when-event-sourcing-shines"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#questions"},{"level":3,"text":"When should I NOT use event sourcing?","url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#question-1"},{"level":3,"text":"What's the difference between event sourcing and event-driven architecture?","url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#question-2"},{"level":3,"text":"How do I handle schema evolution in an event store?","url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#article-tips"}],"sections":[{"heading":"Event Sourcing + CQRS: Event Store Design, Projections, Snapshots, and When NOT to Use It","level":1,"url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons","content":"Architecture"},{"heading":"Event Sourcing + CQRS: Event Store Design, Projections, Snapshots, and When NOT to Use It","level":1,"url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#title","content":"2026-02-228 min read\n\n[Lucio Durán](<https://lucioduran.com/>)\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/event-sourcing-cqrs-production-lessons>)"},{"heading":"Event Sourcing Fundamentals","level":2,"url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#event-sourcing-fundamentals","content":"Instead of storing the current state of an entity, you store the sequence of events that led to that state. The current state is derived by replaying events. This pattern is typically implemented with [PostgreSQL](<https://www.postgresql.org/>) or a dedicated event store.\n\n```python\n# event_store.py - Core event sourcing abstractions\nfrom dataclasses import dataclass, field\nfrom datetime import datetime\nfrom typing import Any\nimport json\nimport uuid\n\n@dataclass(frozen=True)\nclass Event:\n event_id: str\n stream_id: str\n event_type: str\n data: dict\n metadata: dict\n version: int\n timestamp: datetime\n schema_version: int = 1\n\n@dataclass\nclass EventStream:\n stream_id: str\n events: list[Event] = field(default_factory=list)\n version: int = 0\n\nclass EventStore:\n \"\"\"PostgreSQL-backed event store.\"\"\"\n\n def __init__(self, connection):\n self.conn = connection\n\n def append(self, stream_id: str, events: list[Event],\n expected_version: int) -> None:\n \"\"\"\n Append events to a stream with optimistic concurrency.\n Fails if expected_version doesn't match current version.\n \"\"\"\n with self.conn.cursor() as cur:\n # Check current version (optimistic concurrency)\n cur.execute(\n \"SELECT MAX(version) FROM events WHERE stream_id = %s FOR UPDATE\",\n (stream_id,)\n )\n current_version = cur.fetchone()[0] or 0\n\n if current_version != expected_version:\n raise ConcurrencyError(\n f\"Expected version {expected_version}, \"\n f\"got {current_version} for stream {stream_id}\"\n )\n\n # Append events\n for i, event in enumerate(events):\n new_version = expected_version + i + 1\n cur.execute(\"\"\"\n INSERT INTO events\n (event_id, stream_id, event_type, data, metadata,\n version, timestamp, schema_version)\n VALUES (%s, %s, %s, %s, %s, %s, %s, %s)\n \"\"\", (\n event.event_id, stream_id, event.event_type,\n json.dumps(event.data), json.dumps(event.metadata),\n new_version, event.timestamp, event.schema_version\n ))\n\n self.conn.commit()\n\n def read_stream(self, stream_id: str,\n from_version: int = 0) -> list[Event]:\n with self.conn.cursor() as cur:\n cur.execute(\"\"\"\n SELECT event_id, stream_id, event_type, data, metadata,\n version, timestamp, schema_version\n FROM events\n WHERE stream_id = %s AND version > %s\n ORDER BY version ASC\n \"\"\", (stream_id, from_version))\n\n return [\n Event(\n event_id=row[0], stream_id=row[1],\n event_type=row[2], data=json.loads(row[3]),\n metadata=json.loads(row[4]), version=row[5],\n timestamp=row[6], schema_version=row[7]\n )\n for row in cur.fetchall()\n ]\n```"},{"heading":"The Event Store Schema","level":3,"url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#the-event-store-schema","content":"```sql\n-- event_store.sql\nCREATE TABLE events (\n event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),\n stream_id TEXT NOT NULL,\n event_type TEXT NOT NULL,\n data JSONB NOT NULL,\n metadata JSONB NOT NULL DEFAULT '{}',\n version INTEGER NOT NULL,\n timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),\n schema_version INTEGER NOT NULL DEFAULT 1,\n\n -- Optimistic concurrency: unique stream + version\n UNIQUE (stream_id, version)\n);\n\nCREATE INDEX idx_events_stream ON events (stream_id, version);\nCREATE INDEX idx_events_type ON events (event_type, timestamp);\nCREATE INDEX idx_events_timestamp ON events (timestamp);\n\n-- Snapshots table\nCREATE TABLE snapshots (\n stream_id TEXT NOT NULL,\n version INTEGER NOT NULL,\n state JSONB NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n PRIMARY KEY (stream_id, version)\n);\n```"},{"heading":"Aggregates: Where Business Logic Lives","level":2,"url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#aggregates-where-business-logic-lives","content":"```python\n# order_aggregate.py\nclass OrderAggregate:\n def __init__(self):\n self.order_id = None\n self.status = None\n self.items = []\n self.total = 0\n self.version = 0\n\n # Command handlers - validate and produce events\n def place_order(self, order_id: str, items: list, customer_id: str):\n if self.status is not None:\n raise InvalidOperationError(\"Order already exists\")\n if not items:\n raise ValidationError(\"Order must have at least one item\")\n\n total = sum(item['price'] * item['quantity'] for item in items)\n\n return [Event(\n event_id=str(uuid.uuid4()),\n stream_id=f\"order-{order_id}\",\n event_type=\"OrderPlaced\",\n data={\n 'order_id': order_id,\n 'customer_id': customer_id,\n 'items': items,\n 'total': total,\n },\n metadata={'causation_id': str(uuid.uuid4())},\n version=self.version + 1,\n timestamp=datetime.utcnow()\n )]\n\n def confirm_payment(self, payment_id: str, amount: float):\n if self.status != 'placed':\n raise InvalidOperationError(f\"Cannot pay for order in status {self.status}\")\n if amount < self.total:\n raise ValidationError(f\"Payment {amount} less than total {self.total}\")\n\n return [Event(\n event_id=str(uuid.uuid4()),\n stream_id=f\"order-{self.order_id}\",\n event_type=\"PaymentConfirmed\",\n data={'payment_id': payment_id, 'amount': amount},\n metadata={},\n version=self.version + 1,\n timestamp=datetime.utcnow()\n )]\n\n # Event handlers - apply events to rebuild state\n def apply(self, event: Event):\n handler = getattr(self, f'_on_{event.event_type}', None)\n if handler:\n handler(event.data)\n self.version = event.version\n\n def _on_OrderPlaced(self, data):\n self.order_id = data['order_id']\n self.status = 'placed'\n self.items = data['items']\n self.total = data['total']\n\n def _on_PaymentConfirmed(self, data):\n self.status = 'paid'\n\n def _on_OrderShipped(self, data):\n self.status = 'shipped'\n\n def _on_OrderCancelled(self, data):\n self.status = 'cancelled'\n\n @classmethod\n def from_events(cls, events: list[Event]) -> 'OrderAggregate':\n aggregate = cls()\n for event in events:\n aggregate.apply(event)\n return aggregate\n```"},{"heading":"Projections: Building Read Models","level":2,"url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#projections-building-read-models","content":"Projections transform the event log into query-optimized read models. This is the \"Q\" in CQRS.\n\n```python\n# projections.py\nclass OrderDashboardProjection:\n \"\"\"Projects events into a denormalized read model for the order dashboard.\"\"\"\n\n def __init__(self, read_db):\n self.db = read_db\n self.handlers = {\n 'OrderPlaced': self._handle_order_placed,\n 'PaymentConfirmed': self._handle_payment_confirmed,\n 'OrderShipped': self._handle_order_shipped,\n 'OrderCancelled': self._handle_order_cancelled,\n }\n\n def handle(self, event: Event):\n handler = self.handlers.get(event.event_type)\n if handler:\n handler(event)\n # Track projection position for restart\n self._update_checkpoint(event.version, event.timestamp)\n\n def _handle_order_placed(self, event):\n data = event.data\n self.db.execute(\"\"\"\n INSERT INTO order_dashboard\n (order_id, customer_id, status, total, item_count,\n placed_at, updated_at)\n VALUES (%s, %s, 'placed', %s, %s, %s, %s)\n ON CONFLICT (order_id) DO UPDATE SET\n status = 'placed',\n total = EXCLUDED.total,\n updated_at = EXCLUDED.updated_at\n \"\"\", (\n data['order_id'], data['customer_id'],\n data['total'], len(data['items']),\n event.timestamp, event.timestamp\n ))\n\n def _handle_payment_confirmed(self, event):\n self.db.execute(\"\"\"\n UPDATE order_dashboard\n SET status = 'paid', paid_at = %s, updated_at = %s\n WHERE order_id = %s\n \"\"\", (event.timestamp, event.timestamp, event.data.get('order_id', event.stream_id.replace('order-', ''))))\n\n def rebuild(self, event_store: EventStore):\n \"\"\"Rebuild the entire projection from scratch.\"\"\"\n self.db.execute(\"TRUNCATE order_dashboard\")\n self.db.execute(\"DELETE FROM projection_checkpoints WHERE projection = 'order_dashboard'\")\n\n events = event_store.read_all(batch_size=1000)\n for batch in events:\n for event in batch:\n self.handle(event)\n self.db.commit()\n```"},{"heading":"Snapshots: Taming Rehydration Cost","level":2,"url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#snapshots-taming-rehydration-cost","content":"When an aggregate has thousands of events, replaying them all for every command is expensive. Snapshots capture the aggregate state at a point in time:\n\n```python\n# snapshot_strategy.py\nclass SnapshotRepository:\n def __init__(self, event_store: EventStore, snapshot_store, interval: int = 100):\n self.event_store = event_store\n self.snapshot_store = snapshot_store\n self.interval = interval\n\n def load(self, stream_id: str) -> OrderAggregate:\n # Try to load from snapshot first\n snapshot = self.snapshot_store.get_latest(stream_id)\n\n if snapshot:\n aggregate = OrderAggregate()\n aggregate.__dict__.update(snapshot['state'])\n aggregate.version = snapshot['version']\n # Load only events after the snapshot\n events = self.event_store.read_stream(stream_id, from_version=snapshot['version'])\n else:\n aggregate = OrderAggregate()\n events = self.event_store.read_stream(stream_id)\n\n for event in events:\n aggregate.apply(event)\n\n return aggregate\n\n def save(self, stream_id: str, aggregate: OrderAggregate, new_events: list[Event]):\n self.event_store.append(stream_id, new_events, aggregate.version - len(new_events))\n\n # Take snapshot every N events\n if aggregate.version % self.interval == 0:\n self.snapshot_store.save(\n stream_id=stream_id,\n version=aggregate.version,\n state=aggregate.__dict__\n )\n```"},{"heading":"When NOT to Use Event Sourcing","level":2,"url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#when-not-to-use-event-sourcing","content":"This section addresses common scenarios where event sourcing makes things worse:\n\n**1. Simple CRUD applications.** If your domain is \"user fills form, data goes in database, someone reads it later,\" event sourcing adds complexity with no benefit. A PostgreSQL table with ` created_at ` and ` updated_at ` columns covers 90% of audit needs.\n\n**2. When your team doesn't understand DDD.** Event sourcing without proper aggregate boundaries becomes a nightmare. If your team can't answer \"what is the consistency boundary here?\", you'll end up with a distributed monolith that's harder to reason about than the database it replaced.\n\n**3. When you need ad-hoc queries.** Event stores are optimized for reading a single stream. Cross-aggregate queries require projections. If your primary use case is \"show me all orders from customer X that were placed in March and contain product Y,\" you need a projection for every new query pattern. With a relational database, it's a WHERE clause.\n\n**4. Prototypes and MVPs.** The time you spend building event store infrastructure, projection rebuilding, snapshot strategies, and upcasting is time you're not spending on validating your business hypothesis. Ship the MVP with PostgreSQL. Migrate to event sourcing later if the domain complexity justifies it.\n\n**5. When eventual consistency is unacceptable.** Projections are eventually consistent with the event store. There's always a delay between an event being stored and the read model being updated. If your business requires that a write is immediately visible in queries (e.g., a financial transaction), you need to work around this, and the workarounds add complexity.\n\nA cautionary example: a user management service where users sign up, update their profile, and change their email. With perhaps five event types, the \"audit trail\" requirement that motivated event sourcing could have been solved with a simple ` user_audit_log ` table. Instead, the system had an event store, three projections, a snapshot strategy, and an upcasting pipeline for when a ` display_name ` field was added. Architecturally interesting and practically terrible."},{"heading":"When Event Sourcing Shines","level":2,"url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#when-event-sourcing-shines","content":"Systems where event sourcing succeeds tend to share common traits:\n\n- **Complex domain logic** with many state transitions and business rules\n- **Audit requirements** that went beyond \"who changed what\" to \"what was the state at time T and why\"\n- **Event-driven integrations** where downstream systems needed to react to domain events\n- **Temporal queries** like \"show me the order as it was at 3pm yesterday\"\n\nConsider a trading platform where regulatory requirements demand a complete, immutable audit trail of every state change. Or a logistics system where dozens of downstream services need to react to shipment events. In both cases, event sourcing is the natural fit, and the alternative (change data capture on a relational database) would be more complex, not less.\n\nThe reality is that most systems do not need event sourcing. But when the use case demands it, nothing else comes close."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#questions","content":""},{"heading":"When should I NOT use event sourcing?","level":3,"url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#question-1","content":"CRUD-heavy applications with simple data models, systems where audit trails aren't important, teams without DDD experience, prototypes and MVPs, and any system where the added complexity isn't justified by the benefits. Most applications are better served by a traditional database with change data capture for audit needs."},{"heading":"What's the difference between event sourcing and event-driven architecture?","level":3,"url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#question-2","content":"Event-driven architecture uses events for communication between services. Event sourcing stores state as a sequence of events. You can have event-driven architecture without event sourcing (most do), and you can have event sourcing without an event-driven architecture (single-service ES)."},{"heading":"How do I handle schema evolution in an event store?","level":3,"url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#question-3","content":"Three approaches: upcasting (transform old events to new schema on read), versioned event handlers (handle both v1 and v2 in your projections), or copy-transform (migrate events to a new store with the new schema). Upcasting is the most common and least disruptive."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#article-tips","content":"1. **Tip:** Store events as immutable facts with ` schema_version ` fields. You will change event schemas, upcasting from v1 to v2 is much easier than rewriting the event store.\n2. **Tip:** Snapshot every 100-200 events per aggregate. Without snapshots, rehydrating an aggregate with 10K events takes seconds, users notice.\n3. **Tip:** Projections should be idempotent and rebuildable from scratch. If you can't delete a projection and rebuild it from the event log, your architecture has a hidden dependency.\n4. **Tip:** Use ` optimistic concurrency ` on the event stream version. If two commands try to append to the same stream simultaneously, the second one must fail and retry, this is your consistency boundary.\n\n- event-sourcing\n- cqrs\n- event-store\n- projections\n- sagas\n- architecture\n- ddd\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Lucio Durán","url":"https://lucioduran.com/"},{"text":"Español","url":"https://lucioduran.com/es/blog/event-sourcing-cqrs-production-lessons"},{"text":"PostgreSQL","url":"https://www.postgresql.org/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com/"},{"@type":"WebSite","@id":"https://lucioduran.com/#website","url":"https://lucioduran.com/","name":"Lucio Durán","inLanguage":["en","es"],"publisher":{"@id":"https://lucioduran.com/#person"}},{"@type":"WebPage","@id":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#page","url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons","name":"Event Sourcing + CQRS: Event Store Design, Projections, Snapshots, and When NOT to Use It | Lucio Durán","description":"Production lessons from building event-sourced systems, event store design, projection rebuilds, snapshot strategies, saga orchestration, and an honest assessment of when event sourcing makes things worse.","inLanguage":"en","author":{"@id":"https://lucioduran.com/#person"},"publisher":{"@id":"https://lucioduran.com/#person"},"isPartOf":{"@id":"https://lucioduran.com/#website"},"isAccessibleForFree":true,"datePublished":"2026-02-22","mainEntity":{"@id":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#article"},"breadcrumb":{"@id":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#breadcrumb"}},{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#article","mainEntityOfPage":{"@id":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#page"},"headline":"Event Sourcing + CQRS: Event Store Design, Projections, Snapshots, and When NOT to Use It","description":"Production lessons from building event-sourced systems, event store design, projection rebuilds, snapshot strategies, saga orchestration, and an honest assessment of when event sourcing makes things worse.","datePublished":"2026-02-22","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@id":"https://lucioduran.com/#person"},"url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Architecture","keywords":"event-sourcing, cqrs, event-store, projections, sagas, architecture, ddd","wordCount":1391,"timeRequired":"PT8M","workTranslation":[{"@type":"BlogPosting","@id":"https://lucioduran.com/es/blog/event-sourcing-cqrs-production-lessons#article","inLanguage":"es","url":"https://lucioduran.com/es/blog/event-sourcing-cqrs-production-lessons"}],"isAccessibleForFree":true},{"@type":"Blog","@id":"https://lucioduran.com/blog#blog","url":"https://lucioduran.com/blog","name":"Blog","inLanguage":"en","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com/"},"publisher":{"@id":"https://lucioduran.com/#person"},"isPartOf":{"@id":"https://lucioduran.com/#website"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Event Sourcing + CQRS: Event Store Design, Projections, Snapshots, and When NOT to Use It","item":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons"}],"@id":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#breadcrumb"},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"When should I NOT use event sourcing?","acceptedAnswer":{"@type":"Answer","text":"CRUD-heavy applications with simple data models, systems where audit trails aren't important, teams without DDD experience, prototypes and MVPs, and any system where the added complexity isn't justified by the benefits. Most applications are better served by a traditional database with change data capture for audit needs."}},{"@type":"Question","name":"What's the difference between event sourcing and event-driven architecture?","acceptedAnswer":{"@type":"Answer","text":"Event-driven architecture uses events for communication between services. Event sourcing stores state as a sequence of events. You can have event-driven architecture without event sourcing (most do), and you can have event sourcing without an event-driven architecture (single-service ES)."}},{"@type":"Question","name":"How do I handle schema evolution in an event store?","acceptedAnswer":{"@type":"Answer","text":"Three approaches: upcasting (transform old events to new schema on read), versioned event handlers (handle both v1 and v2 in your projections), or copy-transform (migrate events to a new store with the new schema). Upcasting is the most common and least disruptive."}}],"@id":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#questions","url":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#questions","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons#page"},"author":{"@id":"https://lucioduran.com/#person"}}]}],"contentHash":"aa2168a9bdb59d4a05c68da7bf756530f147f7c349982ed6efe58f5c82702577","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/event-sourcing-cqrs-production-lessons","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":68328,"contentBytes":13977}}
