Introduction
Combining a relational SQL database with an event store is a powerful architecture: SQL gives you rich querying, constraints, and transactions; the event store gives you auditability, replayability, and an immutable history. But this hybrid approach raises an important question: what consistency guarantees does your system provide, and how do you achieve them in a reliable, maintainable way?
This article describes a production-ready consistency model for hybrid systems. It covers patterns (Transactional Outbox, Event Sourcing, CDC), concrete implementation recipes with .NET and SQL Server, projection strategies, UI patterns for Angular to handle eventual consistency, testing approaches, monitoring, and an operational playbook for production incidents.
Goals And Trade-Offs
Before designing a model, be explicit about what you want:
Atomicity of intent: When an application updates state, an event reflecting that change must not be lost.
Deterministic replay: Replaying events should reproduce state exactly.
Ordering: Events for the same aggregate must be applied in order.
Idempotency: Consumers must tolerate duplicate events.
Latency vs correctness: Immediate read-after-write correctness is expensive; eventual consistency is cheaper and more scalable.
Trade-offs
Strong consistency (synchronous cross-service transactions) reduces scalability and availability.
Eventual consistency is scalable but requires careful UX and compensating logic. Choose the model per bounded context.
High-Level Architecture (Block Diagram)
┌───────────────┐ ┌────────────────────┐ ┌───────────────┐
│ Client App │ -> │ API / Command Gate │ -> │ SQL Master │
│ (Angular) │ │ (.NET) │ │ (Write Side) │
└───────────────┘ └──────┬───────────────┘ └──────┬────────┘
│ │
│ Write changes + Outbox │
▼ ▼
┌──────────────┐ ┌────────────────┐
│ Outbox Table │ ----poll-> │ Event Broker │
└──────────────┘ └────────────────┘
│ │
▼ ▼
Publisher Worker Projection Workers
│ │
▼ ▼
External Consumers Read Models (SQL)
Key idea: make event emission atomic with the SQL write by using a transactional outbox. Publish asynchronously to the event broker and power read-model updates via projections.
Pattern 1 — Transactional Outbox (Recommended Default)
Why Outbox?
If an operation must both update SQL and emit an event, doing them separately risks loss (update succeeds but event fails) or duplication (event succeeded but update failed). The Transactional Outbox guarantees the intent is recorded within the same DB transaction as the state change.
Implementation Steps
In the same DB transaction, update domain tables and insert an outbox row (JSON payload + metadata).
Commit transaction.
A background publisher polls outbox rows and publishes them to the event broker.
Publisher marks outbox rows as processed.
Outbox Table Example (SQL Server)
CREATE TABLE Outbox (
OutboxId BIGINT IDENTITY PRIMARY KEY,
AggregateId UNIQUEIDENTIFIER NOT NULL,
EventType NVARCHAR(200) NOT NULL,
Payload NVARCHAR(MAX) NOT NULL,
CreatedAt DATETIME2 DEFAULT SYSUTCDATETIME(),
Processed BIT DEFAULT 0,
ProcessedAt DATETIME2 NULL,
Attempt INT DEFAULT 0
);
CREATE INDEX IX_Outbox_Processing ON Outbox(Processed, CreatedAt);
.NET Save Pattern (EF Core)
public async Task SaveWithEventAsync(DbContext ctx, DomainEvent evt)
{
using var tx = await ctx.Database.BeginTransactionAsync();
// 1. update domain state
await ctx.SaveChangesAsync();
// 2. insert into Outbox
ctx.Set<Outbox>().Add(new Outbox { AggregateId = evt.AggregateId, EventType = evt.Type, Payload = JsonSerializer.Serialize(evt) });
await ctx.SaveChangesAsync();
await tx.CommitAsync();
}
Publisher Worker
Claim a batch (UPDATE TOP ... SET Processing = 1 OUTPUT inserted.* WHERE Processed = 0 AND Processing = 0) to avoid concurrent publishers.
Publish to broker with dedup key (OutboxId).
On success set
Processed = 1, ProcessedAt = SYSUTCDATETIME().On failure increment Attempt and backoff.
Guarantees
Atomic write of state + outbox → no lost intent.
Delivery to broker is at-least-once unless broker provides stronger semantics; consumers must be idempotent.
Pattern 2 — Event Sourcing (Event Store as Source of Truth)
Event Sourcing stores every state change as an event. SQL projections are generated from the event stream.
Pros
Full audit trail and deterministic state via replay.
Easy to rebuild projections.
Cons
Higher complexity on domain modeling and migrations.
Requires strong discipline for versioning.
If you adopt event sourcing, the event store is primary; projections are derived artifacts.
Pattern 3 — CDC (Change Data Capture)
CDC tools (Debezium, SQL Server CDC) stream DB changes to message brokers. CDC reduces code changes but can be noisy: database-level changes lack domain intent and may produce multiple events per logical operation. Use CDC when migrating legacy systems, but prefer outbox for domain semantics.
Idempotency And Deduplication
Always assume at-least-once delivery. Design consumers and projections with idempotency:
Store processed event id as unique key per projection.
Use
MERGE/UPSERToperations with a version number.Use an event version or sequence to apply ordering checks.
Example projection upsert (SQL Server)

Join the conversation! Your thoughts help the community grow.