Designing Zero-Downtime Migration Pipelines: Moving Production Databases Without Data Loss
How enterprise engineering teams migrate multi-terabyte transactional PostgreSQL databases with zero downtime: log-based Change Data Capture (CDC), asynchronous shadow validation, and atomic connection pool pausing.

#!/usr/bin/env python3 import os
content = """# Designing Zero-Downtime Migration Pipelines: Moving Production Databases Without Data Loss
For an enterprise engineering team, few operational tasks carry higher career stakes than moving a multi-terabyte production database. Whether you are upgrading major engine versions, migrating from self-hosted hardware to a managed cloud VPC (essential for private VPC data isolation), or restructuring monolithic schemas, the traditional "maintenance window" is no longer acceptable.
In a globally distributed system, taking a core transactional database offline for six hours on a Sunday night means dropping hundreds of thousands of dollars in checkout revenue, breaking customer webhooks, and risking disaster if the migration fails at 4:00 AM with the cutover clock ticking down.
The naive strategy—halting application writes, executing a bulk pg_dump, restoring onto a new cluster, and pointing DNS records to the new host—invariably collapses as datasets exceed a few hundred gigabytes. Network transfer speeds, index rebuild times, and foreign key validations quickly blow past any maintenance window. Worse, if unpredicted schema incompatibilities appear on the new host, rollback means throwing away all writes accumulated on the target or attempting an emergency restore under catastrophic pressure.
True zero-downtime database migration is not an act of heroism during an all-night deployment call. It is an engineered, multi-phase continuous synchronization pipeline.
In this comprehensive architecture blueprint, we walk through the end-to-end mechanics of moving active, high-throughput PostgreSQL databases without dropped transactions, degraded read latencies, or data drift. We break down log-based Change Data Capture (CDC), asynchronous shadow validation, and atomic connection flipping using connection poolers.
The Four Fundamental Migration Strategies: Trade-Off Analysis
Before diving into pipeline topology, every engineering lead must evaluate which synchronization paradigm matches their throughput profile and data integrity requirements.
┌────────────────────────────────────────────────────────────────────────┐
│ DATABASE MIGRATION STRATEGY TRADE-OFF MATRIX │
├────────────────────────────┬─────────────┬──────────────┬──────────────┤
│ Migration Method │ Write Pause │ Data Drift │ Operational │
│ │ (Downtime) │ Risk │ Complexity │
├────────────────────────────┼─────────────┼──────────────┼──────────────┤
│ 1. Cold Maintenance Window │ 2 - 8 Hours │ Zero │ Very Low │
│ 2. Dual-Write Application │ Zero │ High │ High │
│ 3. Physical Streaming Rep │ 5 - 15 Mins │ Low │ Low - Medium │
│ 4. Log-Based CDC Pipeline │ < 500ms │ Zero (Audited)│ High │
└────────────────────────────┴─────────────┴──────────────┴──────────────┘
1. The Fallacy of Application-Level Dual-Writes
When tasked with zero downtime, developers frequently propose dual-writing from the application layer: whenever a service writes to Database A, it simultaneously writes the same payload to Database B.While conceptually straightforward, application-level dual-writes introduce severe distributed consensus bugs:
- Partial Failure Modes: What happens if the write to Database A succeeds, but the network request to Database B times out? Your application must either roll back Database A (penalizing production availability) or swallow the error (silently introducing permanent data drift).
- Out-of-Order Concurrency: Under high concurrency, two parallel web workers updating the same customer record can arrive in opposite order at Database A and Database B, causing permanent state divergence.
- Latency Amplification: Every write path incurs double the I/O latency and network round-trips before sending a 200 OK response to the client.
As Martin Fowler highlighted in his analysis of Parallel Run architectures, application-level shadow writing without transaction log determinism creates immense operational drag. For production databases handling mission-critical transactions, synchronization must occur at the database write-ahead log (WAL) layer, decoupled from the application runtime.
Pipeline Architecture: The 4-Stage Continuous Sync Blueprint
A robust, enterprise-grade zero-downtime migration pipeline operates in four distinct, deterministic phases:
[Stage 1: Schema & Baseline] ──> [Stage 2: Continuous CDC Sync] ──> [Stage 3: Shadow Audit] ──> [Stage 4: Atomic Cutover]
[Visual Asset: Pipeline Topology - Dual-Write Shadow Replication vs. Change Data Capture (CDC)]
graph LR
subgraph "Production Source (Active)"
APP[Application Servers / Workers] -->|Active RW Transactions| SRC[(Source PostgreSQL 14)]
SRC -->|Write-Ahead Log| WAL[WAL Engine / pgoutput]
end subgraph "Replication & CDC Infrastructure"
WAL -->|Logical Slot Stream| DEB[CDC Engine / Debezium / PgLogical]
DEB -->|Stream Delta Batches| QUEUE[Buffer Queue / Kafka / Memory Buffer]
QUEUE -->|Low-Latency Apply| TGT[(Target PostgreSQL 16)]
end
subgraph "Shadow Verification Layer"
SRC -.->|Sampled Reads| VERIFY{Async Checksum Verifier}
TGT -.->|Shadow Reads| VERIFY
VERIFY -->|Alert on Hash Drift| MON[Prometheus / Grafana Alerting]
end
subgraph "Cutover Layer"
POOL[PgBouncer Connection Router] -.->|Cutover Flip <300ms| TGT
TGT -->|Reverse Replication| REV_WAL[Target WAL Slot]
REV_WAL -.->|Reverse Sync Engine| SRC
end
+----------------------------------------------------------------------------------------------------+
| ZERO-DOWNTIME DATABASE MIGRATION TOPOLOGY |
+----------------------------------------------------------------------------------------------------+
| |
| [Incoming API Traffic] |
| | |
| v |
| +-----------------------------------+ |
| | PgBouncer / Connection Pool Router | |
| +-----------------------------------+ |
| | |
| | (Active Production Route) |
| v |
| +-------------------------------+ +----------------------------+ |
| | SOURCE DATABASE (Postgres 14) | | TARGET CLUSTER (Postgres 16)| |
| | - wal_level = logical | | - New Cloud VPC / Hardware | |
| | - Replication Slot (Active) | | - Optimized NVMe Storage | |
| +-------------------------------+ +----------------------------+ |
| | ^ |
| | (Logical Decoded Stream: WAL) | |
| v | |
| +-------------------------------------------------------------------------------+ |
| | CDC Pipeline Engine (Debezium / Native PostgreSQL Subscription / pg_recvlogical) |
| | Continuous Delta Replay: Latency < 15ms | In-Memory Conflict Detection |
| +-------------------------------------------------------------------------------- |
| | | |
| v v |
| +-------------------------------------------------------------------------------+ |
| | SHADOW VERIFICATION ENGINE (Asynchronous Hash Comparator) |
| | Dual-Read Sampling | Row Count Parity | SHA-256 Record Checksums | Data Parity: 100.00% |
| +-------------------------------------------------------------------------------+ |
| |
| CUTOVER MECHANISM: Pause PgBouncer (200ms) --> Drain WAL Stream --> Resume Traffic on Target |
| SAFETY FALLBACK: Target Streams Reverse CDC Back to Source (Instant Zero-Loss Rollback) |
+----------------------------------------------------------------------------------------------------+
Stage 1: Schema Pre-Provisioning and Baseline Snapshot
A common failure mode is attempting to migrate the schema, tables, indexes, and constraints simultaneously over a logical stream. In PostgreSQL, replicating row changes while building multi-gigabyte indexes creates write-amplification and disk I/O bottlenecks that can saturate production storage.
The Correct Sequence:
- Pre-create DDL without foreign keys or secondary indexes on the target:
Extract the schema DDL using pg_dump --schema-only. Strip out foreign keys, triggers, and secondary indexes before applying it to the target cluster, or employ online triggerless schema alteration tools like GitHub gh-ost for massive table restructuring. Secondary indexes should only be created after the initial bulk data is ingested, or built concurrently to avoid lock contention.
- Configure Logical Replication on the Source:
Ensure PostgreSQL is configured to retain logical write-ahead logs. The database configuration parameters require:
# postgresql.conf on Source Cluster
wal_level = logical
max_replication_slots = 10
max_wal_senders = 10
max_worker_processes = 16
- Establish Publication and Subscription:
Using native PostgreSQL Logical Replication, create a publication for all production tables:
-- Executed on SOURCE Database
CREATE PUBLICATION migration_pub FOR ALL TABLES; -- Create dedicated replication user with minimal required privileges
CREATE USER replicator WITH REPLICATION ENCRYPTED PASSWORD 'StrongReplicationKey2026!';
GRANT SELECT ON ALL TABLES IN SCHEMA public TO replicator;
GRANT USAGE ON SCHEMA public TO replicator;
On the Target Database, establish the subscription with initial data copying enabled:
-- Executed on TARGET Database
CREATE SUBSCRIPTION migration_sub
CONNECTION 'host=source-db.internal port=5432 user=replicator password=StrongReplicationKey2026! dbname=production_db'
PUBLICATION migration_pub
WITH (
copy_data = true, -- Copies baseline snapshot automatically
create_slot = true, -- Creates logical replication slot on source
slot_name = 'migration_sub_slot',
synchronous_commit = 'off' -- Maximizes ingestion throughput during initial sync
);
The WAL Retention Safety Mechanism
When a logical replication slot is created on the source database, PostgreSQL will not purge WAL segments until the subscriber acknowledges reading them.If the target database experiences network failure or crashes during the initial load, the source database's WAL disk will rapidly fill up. If disk capacity hits 100%, PostgreSQL automatically shuts down to prevent data corruption.
To prevent disk saturation from crashing your production database, always configure a hard cap on WAL retention:
-- Set maximum WAL retention for replication slots on SOURCE
ALTER SYSTEM SET max_slot_wal_keep_size = '250GB';
SELECT pg_reload_conf();
If the replication lag exceeds 250 GB, PostgreSQL automatically drops the slot, protecting production uptime at the cost of requiring a fresh snapshot sync.
Stage 2: Concurrent Index Creation and Handling Sequences
Once the baseline data copy completes and the logical replication engine enters continuous streaming mode (replaying deltas as they occur), you must prepare the target database for query execution.
Concurrent Indexing Without Locking Application Reads
Never build indexes synchronously on a target database that is actively applying replication streams. Synchronous index builds acquire aSHARE lock on tables, blocking replication workers and causing replication lag to balloon.Always construct secondary indexes concurrently:
-- Executed on TARGET Cluster while continuous replication is streaming
CREATE INDEX CONCURRENTLY idx_users_email ON public.users (email);
CREATE INDEX CONCURRENTLY idx_orders_customer_id ON public.orders (customer_id);
As detailed in our benchmark on PostgreSQL pgvector indexing and tuning, configuring maintenance_work_mem = '4GB' on the target host accelerates concurrent B-tree and HNSW index builds by up to 8x without spilling temporary sort files to disk.
The Sequence Trap: Why SERIAL and BIGSERIAL Break
Logical replication replicates table row data (INSERT, UPDATE, DELETE), but it does not replicate sequence values (nextval()). If your source database has issued order ID 1,450,200, the sequence on the target database remains initialized at 1. If you cut traffic over to the target without resynchronizing sequences, the first INSERT will attempt to use ID 1, immediately triggering fatal primary key unique constraint violations: duplicate key value violates unique constraint "orders_pkey".
Prior to cutover, execute a sequence synchronization script across all auto-incrementing columns:
-- Synchronize all sequences on TARGET to match SOURCE + Safety Headroom
DO $$
DECLARE
rec RECORD;
BEGIN
FOR rec IN
SELECT
s.relname AS seq_name,
t.relname AS table_name,
a.attname AS column_name
FROM pg_class s
JOIN pg_depend d ON d.objid = s.oid
JOIN pg_class t ON t.oid = d.refobjid
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid
WHERE s.relkind = 'S'
LOOP
EXECUTE format(
'SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I), 1) + 5000);',
rec.seq_name, rec.column_name, rec.table_name
);
END LOOP;
END $$;
Adding a buffer of + 5000 guarantees that even if a burst of writes occurs during the final cutover second, ID collisions are impossible.
Stage 3: Asynchronous Shadow Validation and Parity Checks
How do you prove that 500 million rows across 80 tables are bit-for-bit identical before switching production traffic?
Relying on simple SELECT count() queries is wholly inadequate: count queries miss modified timestamps, floating-point rounding errors, and out-of-order string updates. At the same time, running SELECT md5(array_agg(t.)::text) on a multi-terabyte production table locks tables and consumes massive memory.
Production Solution: Segmented Bucket Hashing
Instead of hashing entire tables, implement segmented bucket hashing. The verifier splits the primary key space into deterministic ranges of 100,000 IDs and computes a rolling SHA-256 hash for each bucket across both source and target in the background: -- Computes deterministic hash for a bounded slice of rows
SELECT
min(id) as start_id,
max(id) as end_id,
count() as row_count,
md5(string_agg(
id::text || '|' ||
email || '|' ||
status || '|' ||
extract(epoch from updated_at)::text,
',' ORDER BY id
)) as bucket_hash
FROM public.users
WHERE id >= '00000000-0000-0000-0000-000000000000'
AND id < '02000000-0000-0000-0000-000000000000';
If a bucket hash matches between source and target, all 100,000 rows in that slice are guaranteed to be identical. If a hash mismatch is detected, the verifier isolates the mismatch down to individual row primary keys, alerting your migration team to investigate schema transformation bugs or character encoding discrepancies.
// src/Infrastructure/Migration/DataParityVerifier.ts
import { Pool } from "pg"; export class DataParityVerifier {
constructor(private sourcePool: Pool, private targetPool: Pool) {}
public async auditTableChunk(table: string, minId: number, maxId: number): Promise<boolean> {
const query =
SELECT count() as count,
md5(string_agg(id::text || updated_at::text, ',' ORDER BY id)) as hash
FROM ${table} WHERE id BETWEEN $1 AND $2;
;
const [srcResult, tgtResult] = await Promise.all([
this.sourcePool.query(query, [minId, maxId]),
this.targetPool.query(query, [minId, maxId])
]);
const isMatch = srcResult.rows[0].hash === tgtResult.rows[0].hash;
if (!isMatch) {
console.warn([Parity Error] Mismatch in ${table} range ${minId}-${maxId});
console.warn(Source: ${srcResult.rows[0].hash} (Rows: ${srcResult.rows[0].count}));
console.warn(Target: ${tgtResult.rows[0].hash} (Rows: ${tgtResult.rows[0].count}));
}
return isMatch;
}
}
During this validation phase, as we highlighted in our analysis of modular monolith bounded context schemas, maintaining clean domain schema isolation ensures that verification jobs can run independently per module without cross-table contention.
Visualizing the Cutover: Latency, Replication Lag, and Error Budget
To understand what happens during a live migration, observe how replication lag, transaction queues, and error budgets evolve across the five execution phases:
[Visual Asset: Cutover Phase Spectrum - Replication Lag, Error Budgets, and Fallback Windows]
xychart-beta
title "Replication Lag (ms) and System Traffic Pause Across Migration Phases"
x-axis ["Phase 1: Baseline Copy", "Phase 2: Catch-Up Sync", "Phase 3: Steady-State CDC", "Phase 4: Traffic Pause Cutover", "Phase 5: Post-Cutover Reverse"]
y-axis "Replication Lag (Milliseconds)" 0 --> 5000
bar [4200, 850, 12, 180, 8]
+---------------------------------------------------------------------------------------------------------+
| MIGRATION PHASE BENCHMARK & DRIFT TOLERANCE SPECTRUM |
+------------------------------+--------------------+---------------------+-------------------------------+
| Migration Execution Phase | Replication Lag | Client Traffic State| Rollback Action |
+------------------------------+--------------------+---------------------+-------------------------------+
| Phase 1: Baseline Snapshot | 3,000ms - 8,000ms | Full Production RW | Drop subscription; zero impact|
| Phase 2: Delta Replay Catchup| 200ms - 1,500ms | Full Production RW | Drop subscription; zero impact|
| Phase 3: Steady-State CDC | < 15ms (Near Real) | Full Production RW | Verify bucket hash parity |
| Phase 4: Traffic Cutover | 0ms (Drained) | Paused < 250ms | Abort pause; resume on Source |
| Phase 5: Reverse Replication | < 10ms | Full Traffic on TGT | Fail back to Source instantly |
+------------------------------+--------------------+---------------------+-------------------------------+
Stage 4: Executing the Sub-Second Atomic Cutover
When continuous replication lag is consistently below 15ms and data parity verification reads 100%, the engineering team is ready for cutover.
The goal is to flip production writes from Source to Target without dropping incoming HTTP requests and without writing split-brain data.
We accomplish this using connection pool pausing via PgBouncer or Envoy:
[1. PAUSE Inbound PgBouncer] ──> [2. Await Replication Catch-Up] ──> [3. Switch Connection String] ──> [4. RESUME]
The Step-by-Step Cutover Script:
# Step 1: Instruct PgBouncer to buffer incoming client queries (sub-250ms queue)
# Queries are NOT rejected; they wait in kernel socket buffers
psql -h pgbouncer.internal -p 6432 -U admin pgbouncer -c "PAUSE production_db;" # Step 2: Query source database to capture latest Write-Ahead Log position
CURRENT_LSN=$(psql -h source-db.internal -U postgres -t -c "SELECT pg_current_wal_lsn();")
echo "Source Current WAL LSN: $CURRENT_LSN"
# Step 3: Wait for target subscriber to replay exactly up to that LSN
until psql -h target-db.internal -U postgres -t -c \
"SELECT pg_wal_lsn_diff(pg_last_wal_replay_lsn(), '$CURRENT_LSN') >= 0;" | grep -q 't'; do
echo "Waiting for target replication to catch up..."
sleep 0.05
done
# Step 4: Promote target to primary (disable subscription)
psql -h target-db.internal -U postgres -c "ALTER SUBSCRIPTION migration_sub DISABLE;"
psql -h target-db.internal -U postgres -c "ALTER SUBSCRIPTION migration_sub SET (slot_name = NONE);"
# Step 5: Update PgBouncer configuration to route to TARGET host
sed -i 's/host=source-db.internal/host=target-db.internal/g' /etc/pgbouncer/pgbouncer.ini
psql -h pgbouncer.internal -p 6432 -U admin pgbouncer -c "RELOAD;"
# Step 6: Resume PgBouncer traffic. All buffered queries execute on the NEW primary
psql -h pgbouncer.internal -p 6432 -U admin pgbouncer -c "RESUME production_db;"
echo "Cutover complete! Total traffic pause: 185ms."
What Happens to the End Users During the Pause?
Because PgBouncer pauses at the connection multiplexing layer, clients (web applications, mobile APIs) do not receive connection reset errors (ECONNREFUSED or Connection terminated). The client's TCP socket remains open. The client experiences an imperceptible 185ms increase in latency on in-flight requests—well within standard p99 web response thresholds. When RESUME is issued, PgBouncer immediately routes the buffered queries to the newly designated target cluster.
If your system handles extreme burst traffic during the cutover window, pairing this workflow with high-throughput Redis queue buffering absorbs incoming write surges entirely in memory before draining them to PostgreSQL.
Safety First: Setting Up Reverse Replication for Zero-Loss Rollback
The single most common operational blunder in database migrations is burning the bridge behind you: cutting over to the new database, shutting down the old database, and discovering an unhandled edge-case bug two hours later.
If you must roll back after two hours of production activity on the new database, how do you restore the old database without losing those two hours of new customer transactions?
The answer is immediate reverse logical replication:
[Source (Old Primary)] <──── [Continuous Reverse CDC Stream] ──── [Target (Active Primary)]
Immediately following a successful cutover:
- Create a publication on the Target cluster:
CREATE PUBLICATION reverse_pub FOR ALL TABLES;
- Create a subscription on the Source cluster pointing to the Target:
CREATE SUBSCRIPTION reverse_sub
CONNECTION 'host=target-db.internal user=replicator password=...'
PUBLICATION reverse_pub
WITH (copy_data = false, slot_name = 'reverse_sub_slot');
Because copy_data = false, the source does not re-copy historical tables; it simply consumes the new delta transactions generated on the target.
If an irrecoverable application failure or corrupted analytical view emerges, you can execute the exact same PgBouncer pause-and-switch procedure back to the original source database with zero lost customer data.
Frequently Asked Questions
1. How do you handle sequence generation (SERIAL / BIGSERIAL) during logical replication without ID collisions?
PostgreSQL logical replication replicates row data but does not synchronize sequence metadata (nextval()). To prevent duplicate key constraint violations after cutover, you must script sequence synchronization prior to traffic switching. Query pg_depend and pg_class to identify all sequences, calculate the MAX(id) for each table on the target, and call setval('sequence_name', MAX(id) + 5000). The + 5000 headroom guarantees that any in-flight transactions finishing during the cutover pause will never collide with primary keys generated by the new primary. For new applications, utilizing UUIDv7 or Snowflake IDs eliminates sequence synchronization entirely.
2. What happens if a high-volume batch update causes replication lag to spike right before scheduled cutover?
Never initiate a cutover if logical replication lag exceeds your team's error budget (typically < 50ms). If a scheduled background cron or batch data import triggers millions of row updates, the WAL sender will fall behind.In this scenario, postpone the cutover window, inspect pg_stat_replication to identify the lag in bytes (pg_wal_lsn_diff), and wait for the subscriber worker to drain the queue. To prevent this, place all non-critical background jobs (e.g., invoice generation, analytical rollups) into a paused state two hours prior to the cutover window. For heavy analytical workloads, offload those queries permanently to a columnar engine like ClickHouse OLAP to ensure transactional replication streams never compete with reporting batch queries.
3. Why do we avoid application-level dual-writes in favor of log-based Change Data Capture (CDC)?
Application-level dual-writes suffer from the dual-write problem: because standard HTTP web applications cannot execute a single atomic transaction across two distinct database network endpoints, a network failure or process crash between write #1 and write #2 results in silent, permanent data corruption.Furthermore, out-of-order execution across concurrent threads results in state divergence. Log-based CDC (utilizing tools like Debezium or native PostgreSQL logical decoding) reads directly from the engine's write-ahead log (WAL). Because the WAL is the single source of committed truth, replication is deterministic, strictly ordered, and incurs zero additional latency overhead on the application write path.
4. How does PgBouncer pause inbound traffic without throwing connection errors to end users?
When you issue thePAUSE <database> command in PgBouncer's administrative console, PgBouncer stops reading from client sockets and stops forwarding queries to the database server. However, it does not close the client sockets. Inbound queries from web workers accumulate inside the operating system's TCP socket buffer. As long as the cutover completes within the client application's database timeout threshold (typically 3 to 5 seconds), the application perceives nothing more than a momentary latency spike. When you issue RESUME <database>, PgBouncer immediately flushes the queued queries to the newly designated backend host.
5. What is the exact protocol for establishing reverse replication to enable zero-downtime rollback?
Reverse replication must be prepared before cutover and activated immediately upon promotion. When the target database is promoted, configure a publication on the target (CREATE PUBLICATION reverse_pub FOR ALL TABLES;) and create a subscription on the source database with copy_data = false. Because copy_data = false, the original source database does not attempt to clone historical data it already possesses; it simply begins consuming the real-time WAL stream generated by new writes landing on the target. If an unrecoverable failure occurs two hours later, you can pause PgBouncer and switch back to the original source without losing a single write.
Zero-Downtime Data Architecture & Production Execution
Migrating enterprise data infrastructure should never be a high-stress gamble. Whether you are splitting monolithic schemas into bounded contexts, executing major engine upgrades across cloud providers, or building resilient CDC pipelines, our principal systems architects ensure complete transactional integrity and zero downtime.
Explore our custom software development services to review our core engineering practices, study our client engineering case studies, or schedule a technical architecture consultation to audit your database topology and design a zero-loss migration plan.
Frequently Asked Questions
Key questions answered regarding this architectural implementation.
Danisur Rahman
Lead Systems Architect
Leading distributed systems, edge caching, and hardware integration pipelines. Focusing on high-reliability architectures for growing technology ventures.
More From The Engineering Blog
View All Articles→Headless Web Architecture: Unifying Modern Frontends with Legacy Enterprise Backends
Ripping and replacing a legacy enterprise core is a recipe for budget blowouts and operational downtime. Here is how to unify high-performance Next.js frontends with legacy ERPs, CRMs, and SOAP/REST backends using the Strangler Fig pattern, Backend-for-Frontend (BFF) layers, and resilient Edge caching.
Multi-Tenant SaaS Portals on Next.js: Managing Auth, Routing, and Dynamic Subdomains
Building B2B multi-tenant applications on Next.js requires solving tenant boundary isolation, dynamic wildcard subdomain routing at the edge, and isolated database contexts without deploying separate infrastructure per customer. Here is the production architecture blueprint.
Enjoyed this technical breakdown?
Subscribe to receive new architectural guides and systems post-mortems directly in your inbox.