System Design Lab
A Job Board starts with PostgreSQL full-text retrieval and typed filters. A Search Service becomes a separate component only when candidate scoring, search features, or independent scaling becomes the binding constraint.
Adjust active jobs, search QPS, measured cache hits for reusable queries, keyword candidates, job updates, and freshness. See when PostgreSQL is enough, when popular results or a search session deserve a cache, and when Elasticsearch / OpenSearch should become an independent component.
Guided walkthrough
Reason about it one step at a time
Takeaway
Normal evolution scenarios
Click left to right for the intended demo path. Each card changes the workload inputs.
Recommended shape
Bottlenecks
Why this changes
Decision tradeoffs
Source-backed rules
These are the durable system-design claims behind the model. The exact slider thresholds are deliberately labeled as teaching assumptions.
GIN is the preferred PostgreSQL full-text index type
GIN stores lexeme-to-row inverted relationships for candidate retrieval; dynamic relevance is still computed by ts_rank / ts_rank_cd.
PostgreSQL — Preferred Index Types for Text SearchA partial index stores only rows satisfying its predicate
Active-only GIN, B-tree, and GiST indexes exclude years of closed history, but the query predicate must imply the index predicate.
PostgreSQL — Partial IndexesOpenSearch keyword search uses BM25 by default
A dedicated Search Service treats full-text relevance, non-scoring filters, and Top-K retrieval as the primary workload rather than an add-on to a relational database.
OpenSearch — Keyword searchElasticsearch does not cache full search hits by default
The shard request cache defaults to size=0 totals, aggregations, and suggestions. Popular Top 20 results require an explicit Search API cache such as Redis.
Elasticsearch — Shard request cacheCDC captures committed row-level database changes
CDC is the change-capture mechanism: a connector reads the database transaction log and emits a change-event stream. A Queue is an optional transport and buffer; the Indexer updates SearchDocuments.
Debezium — CDC ArchitectureOutbox commits business state and its event in one transaction
Job API writes the Job row and outbox row together; a CDC connector then captures the outbox change. This avoids a PostgreSQL-success/message-send-failure dual-write split.
Debezium — Outbox Event RouterA derived Search Index is near-real-time, not transactionally visible
Document changes become searchable after refresh, so search freshness and Application API business correctness must remain separate boundaries.
Elasticsearch — Near real-time searchTeaching assumptions
- Candidate, latency, and throughput formulas are conservative teaching models that expose an inflection point—not PostgreSQL, Elasticsearch, or OpenSearch benchmarks.
- Keyword match rate and filter retention should come from the real query distribution; total row count alone cannot decide a search migration.
- PostgreSQL indexes update in the Job transaction; a separate Search Service is modeled with an approximately one-second refresh plus indexing backlog.
- The production path uses Outbox / CDC -> durable Queue -> Indexer. A small system may omit the Queue, but it must not synchronously dual-write PostgreSQL and the Search Service.
- Result cache is off by default. Only measured reuse of normalized popular-query / first-page keys earns a 30-second TTL. A search session stores bounded Top 1,000 Job IDs for a few minutes to stabilize pagination, not accelerate the first query.
- Regardless of which index serves search, the Application API uses current PostgreSQL Job.status for the final correctness decision.