Turn ServiceHub business requirements into measurable service-level objectives, workload classes, capacity assumptions, and architecture decision records before choosing PostgreSQL topology, partitioning, pooling, backup, or extensions.
Define SLOs, Workload Classes, Data Model, Capacity Forecast, and Architecture Decisions
Turn ServiceHub business requirements into measurable service-level objectives, workload classes, capacity assumptions, and architecture decision records before choosing PostgreSQL topology, partitioning, pooling, backup, or extensions.
Learning outcomes
ServiceHub has outgrown its “one database server and some indexes” phase. The application now schedules field-service work for multiple tenants, receives mobile updates, generates operational reports, and must survive operator mistakes and host failure. The engineering mistake at this stage is to choose PostgreSQL knobs or high-availability topology first and write requirements afterward. A defensible production design reverses that order: define measurable service-level objectives (SLOs), classify work, forecast capacity, then derive architecture and record why alternatives were rejected.
Translate availability, latency, durability, recovery point objective (RPO), recovery time objective (RTO), growth, retention, and concurrency into measurable acceptance criteria.
Separate client concurrency from useful database concurrency and classify OLTP writes, operational reads, analytical reads, maintenance, backup, and replication work.
Derive schema, partitioning, backup/PITR, standby, pooling, and extension choices from workload constraints rather than fashion.
Build a transparent capacity forecast whose assumptions can be replaced with measured values.
Write architecture decision records (ADRs) that include rejected alternatives, failure modes, evidence required, and a rollback/review trigger.
The official PostgreSQL project currently lists PostgreSQL 18.4 as the current minor for major 18. PostgreSQL 19 is still a development/beta line, so this capstone uses PostgreSQL 18.4 behavior and requires a fresh version/security check before a real production rollout.
1. Create a disposable capstone cluster, not a production-like accident
The capstone uses one dedicated PostgreSQL
database cluster: one server instance/data
directory containing databases. That is different from an HA
cluster, which is an operational topology of primary/standby
servers. Later lessons add a standby and a restore target, but
all destructive work stays under a dedicated
ch24_lab directory.
export CH24_ROOT="$PWD/ch24_lab"export CH24_PRIMARY="$CH24_ROOT/primary"export CH24_STANDBY="$CH24_ROOT/standby"export CH24_RESTORE="$CH24_ROOT/restore"export CH24_ARCHIVE="$CH24_ROOT/archive"export CH24_BACKUP="$CH24_ROOT/basebackup"mkdir -p "$CH24_ROOT" "$CH24_ARCHIVE"initdb \ --pgdata="$CH24_PRIMARY" \ --username=postgres \ --auth-local=trust \ --auth-host=scram-sha-256 \ --pwpromptpg_ctl -D "$CH24_PRIMARY" \ -l "$CH24_ROOT/primary.log" \ -o "-p 55480 -c listen_addresses=127.0.0.1" startcreatedb -h 127.0.0.1 -p 55480 -U postgres servicehub_capstone
$env:CH24_ROOT = Join-Path $PWD "ch24_lab"$env:CH24_PRIMARY = Join-Path $env:CH24_ROOT "primary"$env:CH24_STANDBY = Join-Path $env:CH24_ROOT "standby"$env:CH24_RESTORE = Join-Path $env:CH24_ROOT "restore"$env:CH24_ARCHIVE = Join-Path $env:CH24_ROOT "archive"$env:CH24_BACKUP = Join-Path $env:CH24_ROOT "basebackup"New-Item -ItemType Directory -Force $env:CH24_ROOT,$env:CH24_ARCHIVE | Out-Nullinitdb.exe ` --pgdata=$env:CH24_PRIMARY ` --username=postgres ` --auth-local=trust ` --auth-host=scram-sha-256 ` --pwpromptpg_ctl.exe -D $env:CH24_PRIMARY ` -l (Join-Path $env:CH24_ROOT "primary.log") ` -o "-p 55480 -c listen_addresses=127.0.0.1" startcreatedb.exe -h 127.0.0.1 -p 55480 -U postgres servicehub_capstone
--pwprompt avoids embedding the PostgreSQL
superuser password in course files. Host connections use
SCRAM-SHA-256 authentication from the start. The local
trust rule exists only to make a disposable
same-host lab recoverable; do not copy it into a production HBA
policy.
SELECT version() AS server_version, current_database(), current_setting('server_version_num') AS server_version_num, current_setting('data_directory') AS data_directory, current_setting('port') AS port;SELECT name,setting,context,source,pending_restartFROM pg_settingsWHERE name IN ( 'wal_level', 'max_connections', 'shared_buffers', 'archive_mode', 'max_wal_senders', 'max_replication_slots')ORDER BY name;
Save this baseline with the capstone evidence. It proves what the running server actually accepted; it does not prove that the psql/libpq/client-driver versions or operating-system packages match, so record those separately.
2. SLOs are acceptance tests, not aspirational prose
An SLO is a measurable target for a service property. Availability asks whether the database-backed service can accept correct work. Latency specifies response-time distributions, not averages. Durability describes how much acknowledged data may be lost. RPO is the maximum acceptable data-loss interval after recovery; RTO is the maximum acceptable elapsed recovery time. These targets can conflict: synchronous replication can tighten RPO while increasing commit latency, and a very small RTO can justify more operational complexity.
| ServiceHub training target | Acceptance criterion | Architecture consequence |
|---|---|---|
| Availability | 99.9% monthly for write API; maintenance counted unless explicitly excluded | One local standby is not enough for every real production failure domain; define failover/routing runbook |
| OLTP latency | p95 ≤ 150 ms and p99 ≤ 300 ms for representative write/read transactions | Bound useful backend concurrency; plan/index evidence required |
| Operational report | p95 ≤ 3 s for tenant-day dashboard | Permit separate reporting workload class and optional standby reads |
| Durability/RPO | ≤ 60 s accepted data loss for site-loss recovery; zero-loss not promised | Continuous WAL archive plus frequent restore tests; async standby acceptable for this scenario |
| RTO | ≤ 15 min for operator-executed failover/restore decision path | Prewritten fencing/routing procedures and practiced restore |
| Retention | Work-order events online 13 months; older history exported/removed by policy | Partition high-volume event history by time; core order table remains unpartitioned |
These numbers are fictional ServiceHub acceptance targets used to make the engineering process testable. Replace them with business-approved targets and measured workload distributions.
3. Classify work before forecasting capacity
“500 concurrent users” is not a database workload. Some users are thinking, some API requests are waiting on networks, some transactions are short writes, and some reports scan many rows. PostgreSQL's process-per-connection architecture means server connections are real backend processes, so client concurrency and simultaneously active database work must be modeled separately.
WITH workload(class, peak_clients, target_active_backends, tx_per_second, notes) AS ( VALUES ('write_api', 450, 24, 180, 'short OLTP transactions'), ('read_api', 700, 16, 260, 'key/tenant lookups'), ('ops_reports', 60, 6, 8, 'seconds-scale aggregates'), ('maintenance', 4, 3, NULL, 'vacuum/analyze/index/backup coordination'))SELECT *FROM workloadORDER BY class;
The backend counts are design hypotheses to validate with load
tests, not settings to paste into max_connections.
If clients greatly outnumber useful database concurrency, a
connection pooler becomes an admission-control option. The
mandatory course path remains core PostgreSQL; an external
pooler such as PgBouncer is optional and must be
versioned/tested separately.
4. Forecast growth with explicit assumptions
A capacity forecast should expose every multiplier so a future
engineer can replace assumptions with observed bytes/row,
events/order, retention, index ratio, WAL amplification, backup
copies, and headroom. PostgreSQL physical size is measured later
with pg_total_relation_size; the first forecast is
deliberately transparent rather than falsely precise.
WITH a AS ( SELECT 120000::numeric AS new_orders_per_day, 5::numeric AS events_per_order, 13::numeric AS online_months, 900::numeric AS estimated_order_bytes, 260::numeric AS estimated_event_bytes, 1.45::numeric AS table_plus_index_factor, 1.35::numeric AS free_space_and_growth_headroom),calc AS ( SELECT *, new_orders_per_day * 30.4 * online_months AS online_orders, new_orders_per_day * events_per_order * 30.4 * online_months AS online_events FROM a)SELECT round(online_orders) AS orders, round(online_events) AS events, pg_size_pretty(( (online_orders * estimated_order_bytes + online_events * estimated_event_bytes) * table_plus_index_factor * free_space_and_growth_headroom )::bigint) AS rough_online_footprintFROM calc;
This calculation does not predict real storage. It excludes WAL archive volume, temporary spill, dead tuples, backups, standby copies, logs, filesystem overhead, TOAST behavior, compression outside PostgreSQL, and actual index definitions. Its purpose is to force those categories into the design conversation.
SELECT current_database(), pg_size_pretty(pg_database_size(current_database())) AS database_size;SELECT n.nspname AS schema_name, pg_size_pretty(sum(pg_total_relation_size(c.oid))) AS total_relation_sizeFROM pg_class AS cJOIN pg_namespace AS n ON n.oid=c.relnamespaceWHERE c.relkind IN ('r','p','m') AND n.nspname IN ('app','auth','ops')GROUP BY n.nspnameORDER BY n.nspname;
After the schema/data exist, replace estimated bytes with these measured relation/database totals and trend them over time. Database size still excludes WAL archive copies, operating-system logs, backup copies, and filesystem free-space requirements.
# Linux/macOS:df -h "$CH24_ROOT"# Windows PowerShell equivalent:# Get-Volume | Select-Object DriveLetter,Size,SizeRemaining
Capacity acceptance must include host/storage free space as well as PostgreSQL relation size. A database that fits today can still breach availability when WAL, temporary files, backups, logs, or retained replication-slot WAL exhaust the volume.
5. Derive the data model from write/read/retention behavior
ServiceHub's current order row is frequently read and updated; keeping it in one ordinary table avoids partition-key constraints on every uniqueness rule. The append-heavy event history grows much faster and is retained by time, so it is the partition candidate. This is a workload/retention choice—not a claim that partitioning automatically accelerates queries.
| Object | Decision | Reason |
|---|---|---|
| tenants/customers/work_orders | ordinary heap tables | strong cross-row keys and OLTP lookups dominate; simpler uniqueness |
| work_order_events | monthly RANGE partition by occurred_at | high append volume, time-bounded retention, time-filtered diagnostics |
| queue lookup | B-tree on tenant/status/scheduled_at | matches dispatcher access path |
| customer active orders | leave as evidence-driven candidate | Lesson 3 proves whether an additional partial/composite index is worth write cost |
6. Derive HA and backup from the RPO/RTO contract
A physical standby replays WAL and can reduce failover time, but an asynchronous standby can lag and is not a backup against accidental DELETE. Point-in-time recovery (PITR) combines a physical base backup with continuous WAL archive so you can restore to a chosen point before an operator mistake. These mechanisms solve different failures, so the capstone uses both.
Clients | +--> routing / application config | +--> PostgreSQL 18.4 primary :55480 | | | +--> WAL stream --> physical standby :55481 | | | +--> WAL archive --> immutable/off-host target in production | +--> PITR restore drill :55482 (normally stopped)Core PostgreSQL provides streaming replication and promotion.Fencing the failed primary and rerouting clients are external operational responsibilities.
7. Write ADRs with rejection criteria
An architecture decision record (ADR) states the context, decision, alternatives, measurable consequences, and review trigger. “Use PostgreSQL because it is robust” is not an ADR. The decision must be falsifiable.
ADR-024-01 — Partition only work_order_eventsContext:- event history dominates growth and has 13-month online retention- work_orders needs simple tenant-scoped uniqueness and frequent point updatesDecision:- keep work_orders unpartitioned- range-partition work_order_events monthly on occurred_atRejected:- partition every table by tenant: complicates operational balance and uniqueness- partition work_orders by month: current orders span months and need stable OLTP keysAcceptance evidence:- EXPLAIN shows pruning for time-bounded event queries- partition creation exists before month rollover- retention detach/drop runbook stays within maintenance SLOReview trigger:- measured work_orders size/churn or tenant skew makes current layout breach SLO
8. Deliberately wrong approach: “HA means zero RPO”
The team writes “RPO = 0 because we have a standby” while configuring asynchronous streaming replication. A primary can acknowledge a commit before the standby has received/flushed/replayed that WAL; site loss at that moment can lose acknowledged transactions.
The repair is not automatically “turn on synchronous replication.” First decide whether zero-loss is actually required, then measure the latency/topology consequence. This capstone's explicit RPO is ≤60 seconds, so asynchronous replication plus tested WAL archive is consistent with the stated requirement. A stricter contract would require a different design and acceptance test.
9. Build the acceptance matrix before implementation
WITH acceptance(area, test, pass_rule) AS ( VALUES ('correctness','cross-tenant RLS test','tenant A cannot read/write tenant B rows'), ('latency','representative OLTP replay','p95 <= 150 ms under declared load profile'), ('vacuum','dead-tuple recovery','autovacuum/manual evidence clears churn without wraparound risk'), ('backup','restore drill','pg_verifybackup passes and target data/business invariants pass'), ('RPO','PITR target','restore reaches named point within <= 60 s loss contract'), ('RTO','failover drill','fence + promote + route + verify <= 15 min'), ('upgrade','supported-version runbook','release notes + pg_upgrade --check + workload comparison'))SELECT * FROM acceptance ORDER BY area;
The database can help produce evidence, but SQL alone cannot prove DNS routing, storage durability, operator response time, certificate validation, or business correctness. The final lesson combines database and operational evidence into one design defense.
Choose complexity only when an SLO requires it. A standby, partitioning, pooler, extension, or synchronous commit policy is not a maturity badge; each is another mechanism to operate, monitor, patch, test, and recover. Lesson 2 turns these decisions into schema and security controls.
Check your understanding
- Why is client concurrency not the same as useful database concurrency?
- What failure does PITR solve that a physical standby alone does not?
- Why is the event table partitioned while work_orders is not?
- What would invalidate an ADR in this capstone?
- Why is asynchronous replication incompatible with a blanket zero-RPO promise?
Review the answers
Client sessions include idle/non-database time while active PostgreSQL backends consume real server resources. A standby normally reproduces accidental writes/deletes; PITR can restore to a point before them. Partitioning follows retention/growth behavior, while the core order table benefits from simpler OLTP keys. An ADR must be revisited when its measurable assumptions/SLOs are no longer true. Async commit can be acknowledged before standby durability/replay, so loss is possible during failover.
Authoritative references
Use current upstream PostgreSQL documentation and release/support pages as the source of truth for version-, security-, topology-, and recovery-sensitive behavior.