Tinder Swipe Match (LLD)#
Problem statement (interviewer prompt)
Design the class model for Tinder swipe matching. Users browse profiles, swipe LIKE / NOPE / SUPERLIKE, and a mutual LIKE creates a Match. Add a match feed with pagination, a block list, daily quotas, and a pluggable recommendation strategy.
The Tinder swipe LLD design hinges on one core question: when two users like each other concurrently, exactly one Match row must be created. A unique constraint or CAS on the ordered pair (userA, userB) is the cleanest answer.
classDiagram
class User { +id, name, age }
class Profile {
+user : User
+bio, photos, interests
+visibility : Visibility
}
class Swipe {
+swiper : User
+swipee : User
+action : SwipeAction
+at : Instant
}
class SwipeAction {
<<enumeration>>
LIKE
NOPE
SUPERLIKE
}
class Match {
+id, userA, userB : User
+createdAt : Instant
+lastActiveAt : Instant
}
class BlockList { +blocker : User, +blocked : Set~User~ }
class DailyQuota { +user : User, +remaining : int }
class RecommendationStrategy { <<interface>> +next(user, n) List~Profile~ }
User *-- Profile
Swipe --> SwipeAction
Match --> User
A swipe is immutable; a Match is a separate aggregate created only on a mutual LIKE / SUPERLIKE. The block list short-circuits both recommendations and swipe acceptance. Daily quotas live on a per-user counter that resets at the user's local midnight.
A focused OO interview problem: tight domain, one tricky concurrency race, and clear extension points for recommendations and feed.
Problem statement (interviewer prompt)
Design the class model for Tinder's swipe and match flow. Users see a stream of profiles and tap LIKE / NOPE / SUPERLIKE. A mutual LIKE creates a Match. Add pagination over the match feed, a block list, a daily-swipe quota, and a recommendation strategy interface. Discuss what happens when both users swipe LIKE at the same instant.
Requirements#
Functional
- Browse a feed of candidate profiles.
- Swipe LIKE, NOPE, or SUPERLIKE on each profile.
- A mutual LIKE / SUPERLIKE produces a single
Matchbetween the two users. - Match feed: list current matches, paginated, sorted by recent activity.
- Block another user: stops them from appearing as a candidate and prevents new matches.
- Daily quota on LIKE and SUPERLIKE; resets at user-local midnight.
Non-functional
- Double-swipe race must never produce two
Matchrows. - Recommendation strategy must be swappable (geo, ML-ranked, popularity).
- Swipe writes are write-heavy; reads of "have I swiped on X" must be O(1).
Class design#
classDiagram
direction TB
class User {
+id : String
+name : String
+dob : LocalDate
+location : LatLng
}
class Visibility {
<<enumeration>>
PUBLIC
HIDDEN
PAUSED
}
class Profile {
+user : User
+bio : String
+photos : List~String~
+interests : Set~String~
+visibility : Visibility
}
class SwipeAction {
<<enumeration>>
LIKE
NOPE
SUPERLIKE
}
class Swipe {
+id : String
+swiper : User
+swipee : User
+action : SwipeAction
+at : Instant
}
class Match {
+id : String
+userA : User
+userB : User
+createdAt : Instant
+lastActiveAt : Instant
+pairKey() String
}
class BlockList {
+blocker : User
+blocked : Set~User~
+block(u)
+unblock(u)
+isBlocked(u) boolean
}
class DailyQuota {
+user : User
+date : LocalDate
+likesRemaining : int
+superLikesRemaining : int
+consume(action) boolean
}
class RecommendationStrategy {
<<interface>>
+next(user : User, n : int) List~Profile~
}
class GeoRecommender
class MLRecommender
class PopularityRecommender
class SwipeService {
-swipes : SwipeRepo
-matches : MatchRepo
-blocks : BlockList
-quotas : QuotaService
-events : EventBus
+swipe(swiper, swipee, action) Optional~Match~
}
class FeedService {
-matches : MatchRepo
+feed(user, cursor, limit) Page~Match~
}
class SwipeRepo {
<<interface>>
+save(s : Swipe)
+find(swiper, swipee) Optional~Swipe~
}
class MatchRepo {
<<interface>>
+tryCreate(m : Match) Optional~Match~
+findByUser(u, cursor, limit) Page~Match~
}
User *-- Profile
Profile --> Visibility
Swipe --> SwipeAction
Swipe --> User
Match --> User
SwipeService ..> SwipeRepo
SwipeService ..> MatchRepo
SwipeService ..> BlockList
SwipeService ..> RecommendationStrategy
FeedService ..> MatchRepo
RecommendationStrategy <|.. GeoRecommender
RecommendationStrategy <|.. MLRecommender
RecommendationStrategy <|.. PopularityRecommender
Match.pairKey() returns the ordered tuple min(userA.id, userB.id) + ":" + max(userA.id, userB.id). The database has a UNIQUE index on pair_key. That single constraint is what kills the double-swipe race.
Swipe to match flow#
sequenceDiagram
actor A as Alice
actor B as Bob
participant S as SwipeService
participant SR as SwipeRepo
participant MR as MatchRepo
participant E as EventBus
A->>S: swipe(Alice, Bob, LIKE)
S->>S: blocks.isBlocked(Bob, Alice)?
S->>S: quota.consume(LIKE)
S->>SR: save(Swipe(A, B, LIKE))
S->>SR: find(B, A)
alt Bob has already liked Alice
SR-->>S: Swipe(B, A, LIKE)
S->>MR: tryCreate(Match(A, B))
MR-->>S: Match (or existing on conflict)
S->>E: publish(MatchCreated)
S-->>A: matched
else not yet
SR-->>S: none
S-->>A: ok, no match yet
end
The race condition#
Alice taps LIKE on Bob at the same instant Bob taps LIKE on Alice. Without care, both threads observe the other side's swipe and both try to insert a Match row. Two Match rows for the same pair is a data bug.
Three resolutions, pick one:
- UNIQUE constraint on
pair_key: both threads attempt insert, the database guarantees exactly one succeeds; the loser catches the duplicate-key exception and returns the winner's row. Simplest and recommended. - CAS on a synthetic
match_keyrow: insert into amatch_keystable first; whoever inserts wins. Same semantics as UNIQUE, more moving parts. - Pessimistic lock on the pair:
SELECT ... FOR UPDATEonpair_key. Higher latency, holds row locks.
sequenceDiagram
participant TA as Thread A (Alice likes Bob)
participant TB as Thread B (Bob likes Alice)
participant DB as MatchRepo
TA->>DB: insert pair_key=alice:bob
TB->>DB: insert pair_key=alice:bob
DB-->>TA: ok (row created)
DB-->>TB: DuplicateKeyException
Note over TB: select existing match by pair_key, return it
Code (Python)#
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Optional, List
class SwipeAction(Enum):
LIKE = "like"
NOPE = "nope"
SUPERLIKE = "superlike"
@dataclass(frozen=True)
class Swipe:
swiper_id: str
swipee_id: str
action: SwipeAction
at: datetime = field(default_factory=datetime.utcnow)
@dataclass
class Match:
user_a: str
user_b: str
created_at: datetime = field(default_factory=datetime.utcnow)
@property
def pair_key(self) -> str:
a, b = sorted([self.user_a, self.user_b])
return f"{a}:{b}"
class DuplicateMatchError(Exception):
pass
class SwipeService:
def __init__(self, swipes, matches, blocks, quotas, events):
self.swipes = swipes
self.matches = matches
self.blocks = blocks
self.quotas = quotas
self.events = events
def swipe(self, swiper_id: str, swipee_id: str,
action: SwipeAction) -> Optional[Match]:
if swiper_id == swipee_id:
raise ValueError("cannot swipe on self")
if self.blocks.is_blocked(swiper_id, swipee_id):
return None
if action in (SwipeAction.LIKE, SwipeAction.SUPERLIKE):
if not self.quotas.consume(swiper_id, action):
raise QuotaExceeded(action)
self.swipes.save(Swipe(swiper_id, swipee_id, action))
if action == SwipeAction.NOPE:
return None
reciprocal = self.swipes.find(swipee_id, swiper_id)
if reciprocal and reciprocal.action in (
SwipeAction.LIKE, SwipeAction.SUPERLIKE):
return self._create_match(swiper_id, swipee_id)
return None
def _create_match(self, a: str, b: str) -> Match:
m = Match(user_a=a, user_b=b)
try:
created = self.matches.try_create(m)
self.events.publish("MatchCreated", created)
return created
except DuplicateMatchError:
# the other thread won the race, return its row
return self.matches.find_by_pair_key(m.pair_key)
class RecommendationStrategy:
def next(self, user_id: str, n: int) -> List[str]:
raise NotImplementedError
class GeoRecommender(RecommendationStrategy):
def __init__(self, profiles, blocks, swipes, radius_km=50):
self.profiles = profiles
self.blocks = blocks
self.swipes = swipes
self.radius_km = radius_km
def next(self, user_id: str, n: int) -> List[str]:
candidates = self.profiles.nearby(user_id, self.radius_km)
seen = self.swipes.swiped_set(user_id)
return [
p.user_id for p in candidates
if p.user_id not in seen
and not self.blocks.is_blocked(user_id, p.user_id)
][:n]
Match.pair_key is the deduplication weapon. The repository raises DuplicateMatchError when the UNIQUE constraint fires; the service catches it and returns the existing row.
Concurrency#
- Match creation: UNIQUE on
pair_key. Simple, correct, no locks. - Reading "have I swiped on X?": cache the user's swiped set in Redis (
SETdata type). O(1) check, asynchronously rebuilt from the swipe log. - Quota counters: per-user counter in Redis with INCR / GET, reset by a scheduled job at user-local midnight. INCR is atomic.
- Block writes: a block must invalidate any pending swipes from the blocked user against the blocker; do this on the read side instead of cascade-deleting (cheaper).
Edge cases#
- Self swipe: reject in the service.
- Swipe on a hidden / paused profile: drop silently (do not leak visibility state).
- Block after match: existing match stays in the feed but is marked hidden; chat is disabled. (Tinder unmatches; either is defensible.)
- Duplicate swipes from the same user: idempotent. Repository
saveis an UPSERT on (swiper, swipee). - Time zone for quota reset: store the user's IANA zone; the reset job filters by user offset.
Feed pagination#
flowchart LR
C[Client] --> F[FeedService.feed user, cursor, limit]
F --> Q[MatchRepo.findByUser]
Q --> R[(matches table)]
R --> Q
Q --> F
F --> C
classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
class C client;
class F service;
class Q service;
class R datastore;
Cursor is (last_active_at, match_id) to give stable ordering even when two matches share a timestamp. Index: (user_id, last_active_at DESC, id).
Extensions#
- Rewind / undo last swipe: track the last swipe per user separately; rewind deletes it and undoes any side-effects (only valid if no Match was produced).
- Boost: temporarily inflate the user's recommendation rank for N minutes.
- Read receipts in chat: separate Chat aggregate keyed by
match_id. - ML re-ranking:
MLRecommendercalls a feature store and a ranker, falls back toGeoRecommenderon timeout.
Where Tinder Match LLD fits#
flowchart TB
TM((Tinder Match<br/>LLD))
SM[State machines<br/>swipe and match lifecycle]
BP[Behavioral patterns<br/>Strategy recommender]
RP[Repository pattern<br/>SwipeRepo and MatchRepo]
SM --> TM
BP --> TM
RP --> TM
classDef client fill:#dbeafe,stroke:#1e40af,stroke-width:1px,color:#0f172a;
classDef edge fill:#cffafe,stroke:#0e7490,stroke-width:1px,color:#0f172a;
classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
classDef cache fill:#fed7aa,stroke:#9a3412,stroke-width:1px,color:#0f172a;
classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
classDef storage fill:#e5e7eb,stroke:#374151,stroke-width:1px,color:#0f172a;
classDef external fill:#fce7f3,stroke:#9d174d,stroke-width:1px,color:#0f172a;
classDef obs fill:#f3e8ff,stroke:#6b21a8,stroke-width:1px,color:#0f172a;
class TM service;
class SM,BP,RP datastore;
Glossary & fundamentals#
| Tag | Concept | What it is | Page |
|---|---|---|---|
LLD |
State machines | swipe / match lifecycle | state-machines |
LLD |
Repository pattern | Swipe and Match storage abstraction | repository-pattern |
LLD |
Behavioral patterns | Strategy for recommendations | behavioral-patterns |
Quick reference#
Classes#
- User, Profile (visibility: PUBLIC / HIDDEN / PAUSED)
- Swipe (swiper, swipee, action, at) immutable
- SwipeAction: LIKE, NOPE, SUPERLIKE
- Match (userA, userB, createdAt, lastActiveAt, pairKey)
- BlockList (per blocker, set of blocked)
- DailyQuota (per user, per date)
- RecommendationStrategy: GeoRecommender, MLRecommender, PopularityRecommender
- SwipeRepo, MatchRepo (repository pattern)
- SwipeService, FeedService
- EventBus (MatchCreated)
Match rule#
Mutual LIKE or SUPERLIKE creates exactly one Match. pair_key = min(idA, idB) + ":" + max(idA, idB). UNIQUE index on pair_key.
The race#
Both users LIKE at the same instant. Both threads see the reciprocal swipe, both insert Match. The UNIQUE constraint guarantees one winner; the loser catches DuplicateKeyException and returns the existing row.
Concurrency cheats#
- UNIQUE on
pair_keyfor match creation (preferred over locking). - Redis SET for "have I swiped on X" lookups; O(1).
- INCR / GET for quota counters; atomic.
- Per-user quota reset at user-local midnight via scheduled job.
Blocks#
- Read-side filter: drop blocked users from the recommender and short-circuit swipes.
- No cascading deletes; existing match flagged hidden (or unmatched, both defensible).
Feed pagination#
Cursor = (last_active_at, match_id). Index (user_id, last_active_at DESC, id).
Edge cases#
- Self swipe: reject.
- Hidden/paused profile target: silent drop.
- Duplicate swipe: UPSERT, idempotent.
- Rewind: only if no match was produced.
Interview tricks#
- Drop the pair_key + UNIQUE trick early; interviewer will probe concurrency.
- Call out Strategy for recommender.
- Call out Repository to keep storage swappable.
- Use immutable Swipe records; the Match is the only mutable aggregate.
Complexity#
- Swipe write: O(1) plus reciprocal lookup O(1) via cache.
- Match create: O(1) insert plus index check.
- Feed fetch: O(limit) with cursor; index-driven.
Refs#
- LeetCode-style writeups on Tinder LLD
- Tinder engineering blog: recommendation system
- Designing Data-Intensive Applications (Kleppmann): uniqueness and consensus
FAQ#
How do you detect a mutual like?#
On every LIKE, check if the reciprocal LIKE already exists. If yes, atomically create a Match row keyed by the ordered pair (min_user, max_user). A unique constraint prevents duplicates.
How is the match uniqueness enforced under concurrency?#
Store the pair as (userA, userB) where userA is the smaller id. A unique constraint plus INSERT IGNORE or ON CONFLICT DO NOTHING ensures exactly one Match even if both users swipe at the same instant.
How is the match feed paginated?#
Cursor-based pagination on Match.created_at descending, with a tie-breaker on match id. Cursors avoid the offset drift that page-numbered pagination suffers when new matches arrive.
How does a block list work?#
BlockList is a directed edge (blocker, blocked). The recommendation engine filters out blocked pairs both ways before serving cards, and existing matches are hidden but not deleted.
What is the role of the recommendation strategy?#
A pluggable Recommender picks candidate profiles per user based on distance, age, gender preferences, and behaviour signals. Strategy pattern lets you A/B test different ranking algorithms.
Related Topics#
- State Machines: swipe and match transitions modeled explicitly
- Repository Pattern: SwipeRepo and MatchRepo isolate storage from domain logic
- Behavioral Patterns: the Strategy interface that swaps recommenders