Mentorship Platform HLD: Matching Mentees to the Right Mentor
How a mentorship platform like Preplaced matches people: hard filters that remove the unqualified, a weighted skill-overlap score that ranks the rest, and the search-match-book architecture.
It's 11pm, your interview is in three weeks, and you finally decide to book a mentor. You open Preplaced (or ADPList, MentorCruise), and into the box you type what's actually in your head: "backend system-design, I can spend maybe ₹2000 a session, and please someone who actually knows their stuff." You hit search — and a good platform answers in under a second with the handful of mentors worth your ₹2000, best first, out of thousands. That single second is the whole system, and it hides a two-stage shape every recommendation engine shares: hard filters sweep out everyone who can't qualify at all (the ₹5000 mentor, the unrated one, the pure-frontend one), then a soft score orders whoever's left by how well they fit. Filter, then rank. Get that order right and "find me a mentor" collapses into a few lines of code; get it backwards and a mentor you can't afford still shows up on top.
This is the inside of a mentorship marketplace. The signature problem is constrained ranking: filter on the non-negotiables, then rank on fit. (The booking half — slots and payment — is the Topmate write-up; this is the discovery half.)
Let's start nowhere near a computer
Imagine hiring a tutor through an agency. You hand the clerk a card: the subjects you need, the most you'll pay, and "no one below four stars." The clerk does two passes. First they remove the impossible — too expensive, too poorly rated, teaches the wrong subject — sweeping those cards off the table entirely. Then, among the cards that survive, they sort: the tutor who covers the most of your subjects goes on top, and ties are settled by who's better rated.
That first sweep is hard filtering (a card either qualifies or it doesn't), and the second is soft scoring (a graded ranking among those that do). Mixing them up — ranking everyone, then hoping the bad ones sink — is the classic mistake; a too-expensive mentor should never appear at all, no matter how brilliant.
Where this exact shape shows up
- Preplaced, ADPList, MentorCruise, Topmate discovery — all match mentees to mentors this way.
- It's the universal filter-then-rank of search and recommendations: job matching, dating, product search.
- The booking that follows a match is the Topmate slot/payment flow.
Step 1 — Functional requirements (sentences first)
- A mentee searches with their goals: desired skills, budget, minimum rating.
- The platform filters out mentors who don't qualify (price, rating, no relevant skill).
- It ranks the qualifying mentors by fit, best first.
- A mentee picks one and books a session.
- Results are deterministic — the same query gives the same order.
The load-bearing requirement is "filter, then rank by fit." It's what makes this a two-stage matcher rather than a flat list.
Step 2 — Non-functional requirements
Features tell you what to build; the non-functional requirements tell you how well — and here they decide the whole shape. Name them with the canonical terms so an interviewer hears you reach for the right vocabulary:
- Low latency. Matching runs in tens of milliseconds even over a large mentor pool — the mentee is waiting on that one search.
- Consistency (mostly eventual, one part strong). The ranked list a mentee browses can be a minute stale — a rating that ticked up, a mentor who just went live — so profiles are eventually consistent and cacheable. But two things must be current at the point of action: the price shown must equal the price charged, and a mentor shown as bookable must actually have an open slot.
- Determinism. A special, strict flavour of consistency for ordering: identical queries return identical order, no random shuffling of equals — because a jumping list looks broken and can't be tested.
- Availability. The match service is read-only, so it should ride out failures by degrading to slightly stale results rather than returning nothing.
- Durability. Profiles, skills and reviews are the source of truth and can never be lost; the ranked-result cache is disposable by design.
- Scalability. Many mentees querying a growing catalog, plus the occasional hot mentor everyone wants at once.
Listing them is the easy half; the design only earns them if it fulfills them. Here's the contract, each requirement mapped to the one mechanism that keeps it and the step that cashes it:
| Requirement | How this design fulfills it |
|---|---|
| Low latency | pre-filter with the skill index, score only candidates in one linear pass — Steps 6, 9 |
| Consistency (eventual) | profiles cached with a TTL; price snapshotted at booking, availability read from a live store — Steps 3, 4 |
| Determinism (ordering) | a fixed overlap×100 + rating score plus an id tie-break — equal scores order identically — Step 6 |
| Availability | the match service is stateless + read-only, so requests fail over to a replica; results degrade, never vanish — Step 10 |
| Durability | mentors, skills, reviews live in SQL; the ranked cache can be rebuilt from it — Step 3 |
| Scalability | an inverted skill → mentors index, score survivors only, shard by skill, cache hot queries — Step 9 |
Every trade-off below is chosen to keep one of these — and the trade-off table in Step 8 names which.
Step 3 — Nouns and the data model
Circle the nouns in the requirements and the entities fall out. Mentor is obvious. The two rookies miss are the ones that are really relationships: a mentor's skills aren't a free-text blob, they're a join against a shared vocabulary, and a mentor's availability is a separate, fast-moving thing entirely.
mentors (id, user_id → users, headline, price_paise,
rating_tenths, sessions_done) -- the ranked entity
skills (id, name, slug) -- a controlled vocabulary
mentor_skills (mentor_id → mentors, skill_id → skills) -- the overlap join (many-to-many)
availability (id, mentor_id → mentors, starts_at,
ends_at, status) -- open slots: fast-changing
reviews (id, mentor_id → mentors, mentee_id,
stars, created_at) -- rating_tenths is their rollupThree details an interviewer rewards. Skills are a controlled vocabulary, never free text. If one mentor types "Node.js", another "node", a third "NodeJS", the overlap count silently reads zero and the whole matcher quietly breaks — so mentor_skills joins to a canonical skills row by skill_id, and the mentee's query is normalised to the same ids before anything is counted. Rating is stored as tenths (47 == 4.7 stars), an integer rollup of the reviews rows, so the ranking arithmetic is exact and reproducible — no float drift deciding who ranks first. And price_paise is money as an integer, snapshotted onto the booking when a session is confirmed, so a mentor raising their rate tomorrow can't change what a mentee already agreed to today.
Which datastore — and why it isn't "a database." The problem picks the store, and saying which is the senior beat:
- Relational SQL (Postgres) is the source of truth for
mentors,skills,mentor_skillsandreviews. The overlap is literally a join, the review→rating rollup wants a transaction, and price must be strongly consistent — exactly what a relational engine hands you. Durability and the price guarantee pick SQL. - A search / inverted index (Elasticsearch or OpenSearch) holds the
skill → mentorsposting lists and the free-text search over headlines and bios. It answers "who has any of these skills?" in a single hop instead of scanning the catalog — this is what makes low latency survive a big pool. - Redis caches the ranked result for hot goal+budget queries (short TTL — profiles drift slowly) and holds the fast-changing has-an-open-slot signal keyed by mentor. Availability can't share the profile's cache TTL, because slots change every time anyone books — mixing the two is how you rank a mentor who's fully booked for a month.
Step 4 — Verbs become APIs
The verbs in the requirements become endpoints. The split that matters is that the ranked search and the availability check are two different calls with two different freshness needs:
GET /mentors?skills=java,system-design&maxBudget=2000&minRating=40
search + filter + rank (paginated, cacheable ~60s)
GET /mentors/{id} one full profile (cacheable — drifts slowly)
GET /mentors/{id}/availability open slots (short TTL — changes constantly)
POST /bookings { mentorId, slotId } confirm + pay (idempotent — the Topmate flow)The ranked list is safe to cache for a minute; folding availability into it is not — so availability is its own endpoint over a live-ish store, never baked into the cached ranking. That one boundary keeps the search fast and keeps "bookable" honest.
Step 5 — Two stages: hard filters, then soft score
A hard filter is a yes/no gate on a non-negotiable: over budget, below the rating bar, or zero overlapping skills → removed entirely. A soft score is a graded number that ranks whoever passes. Keeping them separate is the whole design: filters guarantee every result is acceptable, the score makes the order good. The score weights skill overlap far above rating, so a mentor who covers more of your goals always outranks a higher-rated mentor who covers fewer — fit beats vanity.
Step 6 — The matcher
Here's the engine. Filter on the three hard constraints, then sort survivors by overlap×100 + rating, with mentor id as the final tie-break:
package dev.fiveyear.mentorship;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* The matching core behind a mentorship platform like Preplaced: given a mentee's goals
* (the skills they want, their budget, a minimum rating bar) and a pool of mentors, return
* the mentors worth showing, best first. It works in two stages — HARD FILTERS remove
* anyone who can't qualify at all (over budget, under the rating bar, no overlapping
* skill), then a SOFT SCORE ranks the survivors. The score weights skill overlap far above
* rating so domain fit dominates, with rating breaking ties and mentor id as a final, fully
* deterministic tie-break. Ratings are stored as tenths (47 == 4.7 stars) to keep the
* arithmetic exact and the ranking reproducible.
*/
public class MentorMatcher {
public static final class Mentor {
final String id;
final Set<String> skills;
final int ratingTenths; // 0..50, e.g. 47 == 4.7 stars
final int pricePerSession;
public Mentor(String id, Set<String> skills, int ratingTenths, int pricePerSession) {
this.id = id; this.skills = skills; this.ratingTenths = ratingTenths; this.pricePerSession = pricePerSession;
}
}
public static final class Query {
final Set<String> desiredSkills;
final int maxBudget;
final int minRatingTenths;
public Query(Set<String> desiredSkills, int maxBudget, int minRatingTenths) {
this.desiredSkills = desiredSkills; this.maxBudget = maxBudget; this.minRatingTenths = minRatingTenths;
}
}
/** Mentors that pass the hard filters, ranked best-first by the soft score. */
public List<String> match(List<Mentor> mentors, Query q) {
List<Mentor> eligible = new ArrayList<>();
for (Mentor m : mentors) {
if (m.pricePerSession > q.maxBudget) continue; // hard: affordable
if (m.ratingTenths < q.minRatingTenths) continue; // hard: meets the bar
if (overlap(m, q) == 0) continue; // hard: at least one shared skill
eligible.add(m);
}
eligible.sort((a, b) -> {
int byScore = Integer.compare(score(b, q), score(a, q)); // higher score first
if (byScore != 0) return byScore;
return a.id.compareTo(b.id); // deterministic tie-break
});
List<String> out = new ArrayList<>();
for (Mentor m : eligible) out.add(m.id);
return out;
}
/** Soft score: skill overlap dominates (×100), rating breaks ties. */
public int score(Mentor m, Query q) { return overlap(m, q) * 100 + m.ratingTenths; }
int overlap(Mentor m, Query q) {
int n = 0;
for (String s : q.desiredSkills) if (m.skills.contains(s)) n++;
return n;
}
}The ×100 weight is the design decision in one number: two skills (200) always beats one skill (100) no matter the rating, while rating (0–50) only ever decides within the same overlap tier. The id tie-break is what makes the ranking deterministic — two equally-scored mentors never swap places between requests.
The weight is a budget, and it's the part interviewers push on. ×100 works only because it's strictly larger than the biggest value any lower term can reach: rating maxes out at 50, so one extra skill (+100) can never be outvoted by rating. Break that inequality and the tiers bleed. The rule generalises the moment you add a third signal — say sessions_done for a light recency nudge. You don't sprinkle it in; you re-budget so each tier still dominates everything beneath it: overlap×10000 + rating×100 + recencyBucket, where 10000 clears the largest rating×100 + recency sum and 100 clears the largest recency. It's positional notation — each signal is a digit, and a higher digit must outweigh every lower one combined. Reach for a learned/ML ranker only when hand-budgeting this many digits stops being explainable; here, transparency is the feature, so the arithmetic stays in the open.
The weight budget is a real bug waiting to happen: pick ×10 for overlap and
a 4.9★ single-skill mentor (10 + 49 = 59) leaps above a 4.0★ two-skill
mentor (20 + 40 = 60… still fine — but at ×10 and a 0–99 rating it
flips). The multiplier must exceed the maximum of every lower term summed,
not a typical value. State it as an inequality, not a vibe.
Step 7 — The architecture (boxes last)
Only now, with the model and the read path settled, do the boxes draw themselves.
A mentee's query hits the match service, which reads the mentor profiles (skills, rating, price), runs filter-then-rank, and returns an ordered list. The mentee picks one, and the flow hands off to booking — the slot-lock-pay-confirm engine from the Topmate write-up — and finally the live session (video + shared notes). The match service is read-only over profiles; it never mutates state, which makes it trivial to cache and scale.
Trace one search end to end and the freshness split from Step 4 becomes concrete:
The service tries the ranked-result cache first; a hit for a popular goal+budget combination returns in a single hop. On a miss it asks the skill index for candidate ids, hydrates just those profiles from SQL, then filters, scores and sorts them in one linear pass. Availability is deliberately not on this path — it's fetched per-mentor from its own short-TTL store only when the mentee drills into someone, so the hot search path never pays for constantly-changing slot data.
Step 8 — Trade-offs (each one keeping an NFR)
| Decision | The tempting alternative | Why ours wins | Keeps |
|---|---|---|---|
| filter then rank (two stages) | one combined score with penalties | a disqualifier (over budget) can never leak into results | consistency |
| overlap weighted ×100 over rating | weight rating equally | domain fit dominates; a great mentor for the wrong topic ranks low | relevance |
| deterministic id tie-break | leave equal scores unordered | identical queries return identical order; testable, stable | determinism |
| skills as a controlled vocabulary | free-text skill strings | "Node.js" vs "node" can't silently zero the overlap count | correctness |
| availability on its own short TTL | fold slots into the ranked cache | never rank a mentor who's fully booked; search stays fast | consistency |
| transparent additive score | an opaque ML black box | every rank is explainable from the formula | explainability |
| stateless read-only match service | match coupled to booking | cache and scale matching independently of writes | availability |
The complete implementation
The matcher is the engine. Here's the driver that proves it — each hard filter, the overlap-beats-rating ranking, budget tightening, an impossible bar, and the deterministic tie-break:
package dev.fiveyear.mentorship;
import java.util.List;
import java.util.Set;
import dev.fiveyear.mentorship.MentorMatcher.Mentor;
import dev.fiveyear.mentorship.MentorMatcher.Query;
public class Main {
public static void main(String[] args) {
MentorMatcher matcher = new MentorMatcher();
Mentor amir = new Mentor("amir", Set.of("java", "system-design", "kafka"), 48, 1500); // 2 overlaps
Mentor bina = new Mentor("bina", Set.of("java", "system-design"), 45, 1200); // 2 overlaps, lower rating
Mentor cory = new Mentor("cory", Set.of("java"), 49, 1000); // 1 overlap, top rating
Mentor dina = new Mentor("dina", Set.of("python", "ml"), 50, 800); // 0 overlap -> filtered
Mentor evan = new Mentor("evan", Set.of("java", "system-design"), 47, 5000); // over budget -> filtered
Mentor finn = new Mentor("finn", Set.of("system-design"), 30, 900); // under rating bar -> filtered
List<Mentor> pool = List.of(amir, bina, cory, dina, evan, finn);
// want java + system-design, budget 2000, at least 4.0 stars
Query q = new Query(Set.of("java", "system-design"), 2000, 40);
List<String> ranked = matcher.match(pool, q);
// dina (no overlap), evan (over budget), finn (under rating) are all filtered out
assertTrue(ranked.equals(List.of("amir", "bina", "cory")), "filtered + ranked: " + ranked);
// amir and bina both cover 2 skills; amir's higher rating ranks him first
assertTrue(matcher.score(amir, q) > matcher.score(bina, q), "equal overlap -> rating breaks the tie");
// cory covers only 1 skill, so despite the best rating he ranks below the 2-skill mentors
assertTrue(matcher.score(bina, q) > matcher.score(cory, q), "more skill overlap beats a higher rating");
// tighten the budget: only the cheapest qualifying mentors remain
Query tight = new Query(Set.of("java", "system-design"), 1100, 40);
assertTrue(matcher.match(pool, tight).equals(List.of("cory")), "tighter budget filters amir and bina");
// raise the bar so high nobody qualifies
Query strict = new Query(Set.of("java", "system-design"), 2000, 50);
assertTrue(matcher.match(pool, strict).isEmpty(), "an impossible rating bar returns no matches");
// a deterministic tie: two identical-score mentors break by id
Mentor zeb = new Mentor("zeb", Set.of("java", "system-design"), 45, 1200); // identical score to bina
List<String> tie = matcher.match(List.of(bina, zeb), q);
assertTrue(tie.equals(List.of("bina", "zeb")), "identical scores break by id ascending");
System.out.println("ALL MENTOR MATCH ASSERTIONS PASSED");
}
static void assertTrue(boolean cond, String msg) { if (!cond) throw new AssertionError(msg); }
}Step 9 — Scaling the match, one bottleneck at a time
The junior move is to shard on day one; the senior move is to climb the ladder, adding a rung only when a measured bottleneck forces it. The naïve matcher scores every mentor on every query — fine for a hundred mentors, fatal at a hundred thousand. Each rung below is earned by the bottleneck the rung before it exposes.
-
One SQL store, scan-and-score. Correct and simple. The first thing to hurt is the full scan — you're scoring thousands of mentors to return ten.
-
Pre-filter with an inverted
skill → mentorsindex. Don't scan the catalog; ask the index for the mentors who share at least one wanted skill, and score only those. For a query wanting two skills, you union the two posting lists — that candidate set is a rounding error next to the whole catalog. -
Cache hot queries. The same "backend + ₹2000 + 4★" search runs thousands of times a day. Cache its ranked result with a short TTL; most searches never touch the index or SQL at all. Profiles drift slowly, so a one-minute stale ranking is invisible.
-
Add read replicas, then shard by skill. When even the hydrate reads outgrow one box, fan them across replicas; when the catalog itself outgrows one box, partition profiles by primary skill/domain so a query touches only the relevant shards.
Sharding forces a cross-shard correctness question the interviewer loves. Once profiles are partitioned by skill, a mentee wanting java + system-design fans out to two shards. Each returns its own locally-sorted sublist — but the globally-correct order interleaves them, so the coordinator does a merge by (score desc, id asc), the exact same comparator the single-box matcher used, now applied across shards. Two subtleties: a mentor who lists both skills appears in both shards' results, so the merge must dedup by mentor id (keep one, it carries the full score); and the merge comparator must be byte-for-byte the one used locally, or the same query returns different orders depending on which shard answered first — determinism, gone.
The write hot-key is a separate axis. The read ladder above spreads typical load beautifully, but one celebrity mentor — the ex-FAANG director everyone's heard of — is a single hot row no sharding can split. Two distinct pressures, two distinct answers. The read pressure (everyone loading her profile) is pure caching: her profile is the hottest key, so it lives permanently warm in the cache. The write pressure (everyone racing to book her three open slots this week) isn't a matching problem at all — it's the seat-hold contention from the Topmate booking flow, and it's self-limiting: three slots can only produce three winning bookings no matter how many tap "book," and every loser gets a cheap, instant "already taken, pick another time." Discovery ranks her first for thousands; booking lets exactly three through.
Step 10 — When a piece fails: designing for failure
A design is finished when you can say what happens as each box dies — and notice the pattern the reference build teaches: an optimization degrades to the source of truth, the truth gets a replica and fails fast, a slow external is arranged so its outage expires harmlessly.
- The cache dies (optimization → source of truth). Every search falls through to the index and SQL — slower, but still correct, because the cache only ever held a copy of a ranking the store can recompute. Latency degrades; nothing is wrong.
- The search index dies (optimization → source of truth). Fall back to scanning-and-scoring straight from SQL. It's the slow rung-1 path, but it returns the same ranking — the index was a speed layer over an answer the store already owns.
- A profile is stale (price/rating changed). The ranked cache can lag by its TTL, which is fine for ordering — but the moment a mentee acts, price and availability are re-read live and the price is snapshotted onto the booking, so a rate change mid-browse can never charge the wrong amount.
- The match service instance dies (truth held elsewhere → fail over). It owns no state, so a load balancer simply routes the retry to another replica; because matching is a pure read over profiles, there's nothing to recover.
- No mentor matches at all. Return an honest empty result (as the code does) with a nudge to relax one constraint — never pad the list with off-topic mentors, which quietly breaks the "every result is acceptable" promise the filters exist to keep.
The interview corner
Clarify before you draw: Is availability a hard filter (only show mentors with an open slot in the next N days) or a ranking signal (down-rank the fully-booked)? Are skills a fixed taxonomy or free text the mentee types (it decides whether overlap can even be trusted)? Is booking in scope, or just discovery up to the hand-off? And what's the consistency target — may a ranked list be a minute stale, and must price be exact at the point of booking? Those four answers set half the design.
The follow-up ladder — each rung a new scenario, not a re-run of the thesis:
- "A mentor raises their price while a mentee is mid-search — does the cached ranking show the old price?" The ranked cache may lag by its TTL, so yes, briefly — and that's fine for ordering. But price is re-read live at the booking step and snapshotted onto the booking, so the number shown at checkout is the number charged. Separate the slowly-drifting ranking from the must-be-current action.
- "Add 'sessions completed' as a third ranking signal — how do you weight it without breaking overlap dominance?" Re-budget the score like positional notation:
overlap×10000 + rating×100 + recency, each multiplier chosen to exceed the maximum sum of every lower term. It's an inequality you can state, not a knob you tune — and that's exactly when you don't yet need a learned ranker. - "Sharded by skill, a mentee wants
java + system-design— how do you rank across shards?" Fan out, let each shard return its locally-sorted sublist, then merge by(score desc, id asc)— the identical comparator — and dedup any mentor who appears in both shards. Use a different comparator per shard and determinism silently dies. - "Your #1 result is fully booked for a month — that's a terrible experience. Fix it." Availability is fast-changing and lives outside the profile cache; treat it as a hard filter ("has an open slot in N days") or a heavy down-rank, read from its own short-TTL store. The bug is folding slot data into the slowly-cached ranking — separate freshness needs, separate stores.
- "Everyone wants the one celebrity mentor — does that fall over?" No, because the two pressures split. The read storm (loading her profile) is answered by keeping the hottest key permanently cached; the write storm (booking her three slots) is the Topmate seat-hold race, and it's self-limiting — three slots, three winners, every other tap gets a fast "already taken."
Mistakes that fail the round:
- Ranking everyone, then hoping the unqualified sink. A ₹5000 mentor must never appear for a ₹2000 budget — that's a hard filter before scoring, the same filter-then-rank discipline search and dating decks live by. Rank-then-hope is the classic tell of someone who hasn't built one.
- Free-text skills. "Node.js", "node" and "NodeJS" as raw strings silently zero the overlap count and the matcher returns garbage with total confidence. Skills must be a controlled vocabulary joined by id, with the query normalised to the same ids.
- Folding fast-changing availability into the slowly-cached ranking. Cache the ranked list for a minute and bake "bookable" into it, and you'll proudly surface mentors who filled up an hour ago. Keep availability on its own short-TTL path, never inside the ranked-result cache.
Where to go from here
Pocket version: filter on the non-negotiables, then rank the survivors by overlap×100 + rating with an id tie-break; skills are a controlled-vocabulary join, availability is a fast-changing thing kept out of the ranked cache; pre-filter with a skill → mentors index, score only candidates, shard by skill and merge deterministically; hand booking off to Topmate.
- Swap the additive score for a learned ranker and watch what you gain (personalisation) and lose (the explainability that let you debug a rank by hand) — the trade-off worth naming out loud.
- Add two-sided matching — mentors accept or decline requests — and the pure read problem grows a write path and a notification fan-out.
- The filter-then-rank shape is everywhere: LinkedIn's people search and Tinder's deck are the same two stages; the booking that follows a match is the Topmate engine.
- New to system design? The rookie's guide to HLD walks the method this article follows.