What is it? #
A job portal connects two sides: candidates looking for roles and employers looking for candidates. Both need search, and both need a clear view of what is happening with each application.
The technical centre is search. Full-text matching combined with filters on location, salary, experience and job type, ranked sensibly and returned quickly.
The second challenge is the application pipeline, which is a state machine with two parties acting on it and notifications at every transition.
Around both sits the usual marketplace concern: keeping listings fresh, preventing spam, and handling the cold-start problem for new users.
Think of it like this #
A noticeboard where employers pin cards and candidates browse. It works until there are fifty thousand cards.
At that point the value is entirely in how well people can filter and sort, and in whether expired cards are removed. The board itself is trivial; the organisation is the product.
Simple example #
500,000 active jobs, 5 million candidates, 20,000 searches per minute at peak, with employers expecting new listings to be findable within a minute.
Code #
1. Data model
jobs id, employer_id, title, description, location (geo), salary_min,
salary_max, employment_type, experience_level, skills[],
status (draft/active/closed), posted_at, expires_at
applications id, job_id, candidate_id, status, resume_key, cover_letter,
applied_at, updated_at — unique (job_id, candidate_id)
profiles candidate_id, headline, skills[], experience_years, location,
desired_salary, resume_key, visibility
Relational database is the source of truth. A search index is derived from it.
# 2. Search: the index is derived, not authoritative
def search_jobs(query: str, filters: dict, page_cursor=None, size=20):
body = {
"query": {
"bool": {
"must": [{
"multi_match": {
"query": query,
"fields": ["title^3", "skills^2", "description"],
"fuzziness": "AUTO",
}
}] if query else [{"match_all": {}}],
"filter": build_filters(filters), # exact, not scored
}
},
"aggs": { # facet counts
"by_location": {"terms": {"field": "location.city", "size": 20}},
"by_type": {"terms": {"field": "employment_type"}},
"salary_ranges": {"range": {"field": "salary_min",
"ranges": [{"to": 500000}, {"from": 500000}]}},
},
"sort": [{"_score": "desc"}, {"posted_at": "desc"}],
"size": size,
}
return search_client.query("jobs", body, cursor=page_cursor)
def build_filters(filters: dict) -> list:
clauses = [{"term": {"status": "active"}},
{"range": {"expires_at": {"gte": "now"}}}] # never show expired
if filters.get("city"):
clauses.append({"term": {"location.city": filters["city"]}})
if filters.get("min_salary"):
clauses.append({"range": {"salary_max": {"gte": filters["min_salary"]}}})
if filters.get("remote"):
clauses.append({"term": {"employment_type": "remote"}})
return clauses
# 3. Keeping the index fresh without dual-write bugs
def publish_job(employer, data):
with db.transaction():
job = jobs.insert(**data, employer_id=employer.id, status="active")
outbox.insert(event="job.published", payload={"job_id": job.id})
return job
# An indexer consumes the outbox and updates the search index.
# Writing to the database and the index directly would leave them
# inconsistent whenever one of the two writes fails.
4. Application pipeline
applied ──▶ viewed ──▶ shortlisted ──▶ interviewing ──▶ offered ──▶ hired
│ │ │ │
└──────────┴────────────┴───────────────┴──▶ rejected / withdrawn
Every transition notifies the other party. The unique constraint on
(job_id, candidate_id) prevents duplicate applications, which is both a
data rule and a user expectation.
5. Freshness and quality
expiry jobs auto-close after 30 days unless renewed
reindex a nightly full reindex catches anything the outbox missed
spam control rate limit postings per employer, verify domains
cold start new candidates see popular and recent jobs until there
is enough signal for recommendations
How it works #
The relational database holds the truth, and the search index is a derived view. That separation matters: if the index is lost, it can be rebuilt; if the database is lost, nothing can.
The outbox pattern keeps them consistent. Writing the job and the event in one transaction, then indexing from the outbox, means the index eventually reflects every change even if the indexer was briefly down.
In the search query, scoring and filtering do different jobs. The text match scores relevance with weighted fields — a match in the title matters more than one in the description. Filters are exact and unscored, which makes them cacheable and fast.
Aggregations produce the facet counts users expect next to each filter: "Remote (1,204)". Computing those separately would mean several queries.
Excluding expired jobs in the filter rather than relying on a cleanup job means a job that expires mid-day disappears immediately.
The unique constraint on applications enforces at the database level what the interface suggests, so a double-clicked apply button cannot create two applications.
The nightly full reindex is a pragmatic safety net. Event-driven indexing is efficient but can drift; a periodic rebuild corrects it.
Real-world use #
Search quality is the product. Ranking by relevance alone surfaces old listings, so real portals blend relevance with recency, employer quality signals and personalisation.
Recommendations work in both directions: jobs for a candidate, and candidates for a job. Both start with the same skill and location matching before any machine learning is involved.
Resume parsing is a common feature and a hard one, since formats vary wildly. Most systems combine parsing with a structured profile the candidate can correct.
Notifications drive engagement: job alerts, application status changes, and employer messages. The notification design lesson applies directly, including frequency caps.
The marketplace dynamics matter as much as the technology. Stale listings and unanswered applications destroy trust on both sides, which is why expiry rules and response-rate tracking are product features, not just housekeeping.
Common mistakes #
- Treating the search index as the source of truth instead of a derived view.
- Dual-writing to the database and index without an outbox, leaving them inconsistent.
- Scoring filters instead of applying them as exact clauses, making queries slow.
- Showing expired jobs because expiry is only handled by a cleanup job.
- No unique constraint on applications, allowing duplicates from a double click.
Practice #
Add saved searches with email alerts to this design. Specify how a saved search is stored, how new matching jobs are detected, how often alerts are sent, and how you avoid notifying the same job twice. Then describe how you would rank results for a candidate with no history.