Users, friendships, posts, reactions, comments, groups, pages. Friendship state machine plus Observer for notifications.
Context#
Facebook is the canonical “social platform” OOD prompt: users send and accept friend requests, post updates, react and comment, join groups, follow pages, and consume a personalised news feed. The prompt is dense enough that an unprepared candidate spends 25 minutes on feed-generation strategy and emerges with no class diagram. The trap is the same one linkedin-design warns about — and the discipline is the same: draw the seam, name feed fan-out as a system-design concern, and design what is actually inside the LLD scope.
The interviewer’s hidden objectives, in roughly the order they will be tested:
- Can you identify the entities — User, FriendRequest, Friendship, Post, Comment, Reaction, Group, Page, NewsFeed, Notification — and name the relationships honestly (Friendship is bidirectional; Page-Follow is directional)?
- Can you model the friendship state machine without confusion:
None → Requested → Accepted | Declined, plusAccepted → Removed, with the request asymmetric (initiator vs target) but the friendship symmetric? - Can you reach for Observer when “comment fires a notification to the post author and prior commenters” comes up, instead of inlining the fan-out?
- Can you model privacy as a per-read concern (public / friends / friends-of-friends / private) without coupling every reader to the rules?
- Can you hold the LLD line —
NewsFeedService.feedFor(user)is the seam, feed fan-out is HLD, search is HLD, ranking is HLD — and still finish the round with a class diagram and a sequence?
Requirements (functional and non-functional)#
Scope below is what a 45-minute Advanced round expects. Out-of-scope items are named so they cannot ambush the round mid-way.
Functional — in scope.
- Users register and have a
Profile. - Users send and respond to friend requests. Friendships are symmetric, bidirectional, and undirected.
- Users post updates (text + optional media). Each post has a privacy setting: public, friends, friends-of-friends, private.
- Users react (six fixed types: like, love, haha, wow, sad, angry) to posts and comments. Only one active reaction per
(user, target)— a new reaction supersedes the previous one. - Users comment on posts; comments may have replies (one level of nesting in the LLD scope).
- Users join Groups and follow Pages. A Group has owners and members; a Page has owners and followers.
- Notifications are produced for friend requests, comments on your post, replies to your comment, reactions to your post, and tags.
- News feed —
NewsFeedService.feedFor(user)returns orderedFeedItemresults. The interface is in-scope; the implementation is not.
Functional — out of scope (called out explicitly and held to).
- How the feed is generated at scale. Fan-out-on-write versus fan-out-on-read versus hybrid; celebrity-account exceptions; ranking.
NewsFeedServiceis the seam; the implementation lives in the HLD round. - Search (users, posts, groups, pages).
SearchServiceis the seam. - Messenger / chat / video calls. Different domain; out of scope for this round.
- Marketplace, Ads, Events, Stories. Out of scope.
- Privacy beyond the four-bucket model. Per-post audience lists (“close friends”, “exclude these people”) are a clean follow-up extension.
Non-functional.
- The platform holds on the order of 10⁹ users and 10¹⁰ posts. For LLD scope, in-process data structures are sufficient; repository seams are named so a database fits later.
- Feed-read latency under 200 ms; post-create latency under 100 ms.
- Notification delivery is best-effort and eventually consistent — duplicates and re-orderings are acceptable.
- Concurrency: the same post may receive simultaneous comments and reactions; counters must not lose updates.
Use case diagram#
┌───────────────────┐ │ User │ └─────────┬─────────┘ │ ┌──────────┬───────────┼───────────┬──────────┬───────────────┐ ▼ ▼ ▼ ▼ ▼ ▼[send friend request] [accept] [post] [react] [comment] [join group] │ │ │ │ │ │ └──────────┴───────────┴────┬──────┴──────────┴───────────────┘ ▼ ┌─────────────────────────────┐ │ Facebook System │ │ │ … notifications (push) │ │ └─────────────────────────────┘ ▲ │ ┌────────┴────────┐ │ Admin │ … moderation, role grants (out of scope) └─────────────────┘Primary actor is User; Admin is out of scope but worth naming.
Class diagram#
┌──────────────────────────┐ │ User │ ├──────────────────────────┤ │ id, name, joinedAt │ │ profile : Profile │ │ blocked : Set<UserId> │ ├──────────────────────────┤ │ canSee(post) │ // delegates to PrivacyStrategy └─────────┬────────────────┘ │ author / member of ┌────────────────────┼──────────────────────────┐ ▼ ▼ ▼ ┌────────────────┐ ┌──────────────────┐ ┌──────────────────────┐ │ Friendship │ │ Post │ │ GroupMembership │ ├────────────────┤ ├──────────────────┤ ├──────────────────────┤ │ user1, user2 │ │ id, author │ │ user, group, role │ │ since │ │ body, media │ └──────────────────────┘ │ since-event │ │ privacy : Privacy│ └────────────────┘ │ comments : List<Comment>│ ┌────────────────┐ │ reactions : Map<UserId, Reaction>│ │ FriendRequest │ └─────────┬────────────┘ ├────────────────┤ │ │ from, to │ ┌─────────┴────────┐ │ status : ReqState│ ▼ ▼ │ REQUESTED | │ ┌────────────┐ ┌──────────────┐ │ ACCEPTED | │ │ Comment │ │ Reaction │ │ DECLINED | │ ├────────────┤ ├──────────────┤ │ CANCELLED │ │ │ id, author │ │ user, kind │ └────────────────┘ │ body │ │ at │ │ replies │ └──────────────┘ │ reactions │ └────────────┘
┌────────────────┐ ┌────────────────┐ ┌────────────────────────────┐ │ Group │ │ Page │ │ NewsFeedService │ // seam — HLD impl ├────────────────┤ ├────────────────┤ ├────────────────────────────┤ │ id, name │ │ id, name │ │ feedFor(user) : List<FeedItem>│ │ owners, members│ │ owners, │ └────────────────────────────┘ │ posts │ │ followers │ └────────────────┘ │ posts │ ┌────────────────────────────┐ └────────────────┘ │ SearchService │ // seam — HLD impl ├────────────────────────────┤ │ search(query) : List<...> │ └────────────────────────────┘
┌──────────────────────────┐ ┌────────────────────────────────┐ │ PrivacyStrategy │◁── Public, │ NotificationService │ ├──────────────────────────┤ Friends, ├────────────────────────────────┤ │ canSee(viewer, post) │ FoF, │ subscribe(topic, listener) │ └──────────────────────────┘ Private │ publish(NotificationEvent) │ └─────────────┬──────────────────┘ │ Observer ▼ PostAuthorListener, MentionedUserListener, FriendRequestTargetListener, …Three patterns are doing the load-bearing work:
- Observer on
NotificationService. Subscribers (PostAuthorListener,MentionedUserListener,FriendRequestTargetListener, prior-commenter listener) attach by event type; the post / comment / friend-request domain code callspublish(...)and does not know who is listening. Notifications are async; a slow listener cannot block the write path. - State on
FriendRequest.status—REQUESTED → ACCEPTED | DECLINED | CANCELLED.ACCEPTEDis the transition that materialises aFriendship. The friendship itself has a tiny state machine (Active → Removed); the removal is symmetric. - Strategy on
PrivacyStrategy. Every read of a post passes through it:Publicreturnstrue;FriendschecksFriendshipRegistry.areFriends(viewer, author);FriendsOfFriendsdoes one extra hop;Privatereturnsviewer.id == author.id. The reader does not switch on privacy kind.
What is not in the diagram and that is deliberate:
- No
NewsFeedaggregate. The feed is a view, computed byNewsFeedServicefrom posts + friendships + groups + followed pages. Treating the feed as state is the start of the HLD slide. This is the same discipline aslinkedin-design— generation strategy is HLD; the LLD round names the seam and moves on. - No
Likeaggregate. Reactions are aMap<UserId, Reaction>on the post. One reaction per user; the map enforces uniqueness. Persisting per-reaction records is correct at HLD scale, scope-creep at LLD. Friendshipis one record per pair, not two.(user1, user2)is canonicalised somin < max; a single row represents the symmetric relationship. Two-row schemas leak the asymmetry of the original request into the symmetric relationship.FriendRequestis separate fromFriendship. The request has an initiator and a target; the friendship does not. Conflating them confuses the state machine and the audit trail.SearchServiceis a seam. Mention; do not design.
Sequence diagram (key flows)#
The friend-request lifecycle:
Alice FriendController Repo FriendRequest FriendshipRegistry NotificationService │ send(bob) │ │ │ │ │ │───────────────►│ │ │ │ │ │ │ new request │ │ │ │ │ │─────────────►│ │ │ │ │ │ │ status:=REQUESTED │ │ │ │ │───────────────►│ │ │ │ │ │ publish(FriendRequestEvent) │ │ │ │ │─────────────────────────────────────────────────────────►│ │ │ │ │ │ │ FriendRequestTargetListener → notify Bob │ ok │ │ │ │ │ │◄───────────────│ │ │ │ │
Bob FriendController Repo FriendRequest FriendshipRegistry NotificationService │ accept(req) │ │ │ │ │ │───────────────►│ │ │ │ │ │ │ load(req) │ │ │ │ │ │─────────────►│ │ │ │ │ │ │ status:=ACCEPTED │ │ │ │ │───────────────►│ │ │ │ │ │ materialise Friendship │ │ │ │ │─────────────────────────────────►│ │ │ │ │ publish(FriendshipFormed) │ │ │ │ │─────────────────────────────────────────────────────────►│ │ │ │ │ notify Alice ("Bob accepted")The post-comment-reaction flow, showing observer fan-out:
Carol PostController Post NotificationService Listeners │ comment(p,t) │ │ │ │ │──────────────►│ │ │ │ │ │ Post.comment(t) │ │ │ │────────────►│ │ │ │ │ │ append Comment │ │ │ │ │ publish(CommentEvent) │ │ │ │───────────────►│ │ │ │ │ │ fan out to all subscribers │ │ │ │──────────────────────►│ PostAuthorListener → push │ │ │ │──────────────────────►│ PriorCommenterListener → push │ │ │ │──────────────────────►│ MentionedUserListener → push │ ok │ │ │ │ │◄──────────────│ │ │ │NotificationService is the single fan-out point. Listeners are independent — a slow one does not delay another. Each listener is itself a Strategy over delivery channel (push, in-app, email).
Activity diagram (for non-trivial state)#
The FriendRequest state machine is the platform’s most subtle:
┌─────────┐ │ start │ └────┬────┘ ▼ ┌──────────────┐ │ REQUESTED │ └──────┬───────┘ │ ┌─────────────────┼────────────────┬──────────────────┐ ▼ ▼ ▼ ▼ accept() decline() cancel() (target blocks) │ │ │ │ ▼ ▼ ▼ ▼ ┌──────────┐ ┌──────────┐ ┌────────────┐ ┌──────────┐ │ ACCEPTED │ │ DECLINED │ │ CANCELLED │ │ DECLINED │ └────┬─────┘ └──────────┘ └────────────┘ └──────────┘ │ ▼ create Friendship(user1, user2) │ ▼ ┌──────────┐ │ ACTIVE │ ───── unfriend() ─────► ┌──────────┐ └──────────┘ │ REMOVED │ └────┬─────┘ │ ▼ (re-request goes back to REQUESTED)Two invariants the state machine carries:
ACCEPTEDis the only transition that materialises aFriendship.DECLINEDandCANCELLEDare terminal for that request; a fresh request starts a newFriendRequest.REMOVEDis symmetric — either party may unfriend; both lose the friendship. The history is preserved (sinceandremovedAt) for “you were friends with X” rendering, but the active relationship is gone.
Java implementation#
A representative slice. Friendship lifecycle, the observer wiring for comments, and the privacy strategy are the three load-bearing pieces.
public enum ReactionKind { LIKE, LOVE, HAHA, WOW, SAD, ANGRY }public enum RequestStatus { REQUESTED, ACCEPTED, DECLINED, CANCELLED }public enum Privacy { PUBLIC, FRIENDS, FRIENDS_OF_FRIENDS, PRIVATE }
public final class User { private final long id; private final String name; public User(long id, String name) { this.id = id; this.name = name; } public long id() { return id; } public String name() { return name; }}
public final class FriendRequest { private final long id; private final long from, to; private RequestStatus status = RequestStatus.REQUESTED; private final Instant createdAt; private Instant resolvedAt;
public FriendRequest(long id, long from, long to, Instant at) { if (from == to) throw new IllegalArgumentException("Cannot friend self"); this.id = id; this.from = from; this.to = to; this.createdAt = at; }
public void accept(long actor, Instant at) { if (actor != to) throw new SecurityException("Only the target can accept"); if (status != RequestStatus.REQUESTED) throw new IllegalStateException("Request is " + status); status = RequestStatus.ACCEPTED; resolvedAt = at; } public void decline(long actor, Instant at) { if (actor != to) throw new SecurityException("Only the target can decline"); if (status != RequestStatus.REQUESTED) throw new IllegalStateException("Request is " + status); status = RequestStatus.DECLINED; resolvedAt = at; } public void cancel(long actor, Instant at) { if (actor != from) throw new SecurityException("Only the initiator can cancel"); if (status != RequestStatus.REQUESTED) throw new IllegalStateException("Request is " + status); status = RequestStatus.CANCELLED; resolvedAt = at; } public long from() { return from; } public long to() { return to; } public RequestStatus status() { return status; }}
/** Canonicalises the symmetric pair so the registry holds one entry per friendship. */public final class FriendshipKey { private final long lo, hi; public FriendshipKey(long a, long b) { this.lo = Math.min(a, b); this.hi = Math.max(a, b); } public boolean equals(Object o) { return o instanceof FriendshipKey k && k.lo == lo && k.hi == hi; } public int hashCode() { return Objects.hash(lo, hi); }}
public final class FriendshipRegistry { private final Map<FriendshipKey, Instant> active = new ConcurrentHashMap<>(); private final Map<Long, Set<Long>> byUser = new ConcurrentHashMap<>();
public void link(long a, long b, Instant at) { active.put(new FriendshipKey(a, b), at); byUser.computeIfAbsent(a, k -> ConcurrentHashMap.newKeySet()).add(b); byUser.computeIfAbsent(b, k -> ConcurrentHashMap.newKeySet()).add(a); } public void unlink(long a, long b) { active.remove(new FriendshipKey(a, b)); Optional.ofNullable(byUser.get(a)).ifPresent(s -> s.remove(b)); Optional.ofNullable(byUser.get(b)).ifPresent(s -> s.remove(a)); } public boolean areFriends(long a, long b) { return active.containsKey(new FriendshipKey(a, b)); } public Set<Long> friendsOf(long u) { return byUser.getOrDefault(u, Set.of()); }}
/** Privacy as Strategy — every read goes through it. */public interface PrivacyStrategy { boolean canSee(User viewer, Post post, FriendshipRegistry friends);}public final class PublicPrivacy implements PrivacyStrategy { public boolean canSee(User v, Post p, FriendshipRegistry f) { return true; }}public final class FriendsPrivacy implements PrivacyStrategy { public boolean canSee(User v, Post p, FriendshipRegistry f) { return v.id() == p.authorId() || f.areFriends(v.id(), p.authorId()); }}public final class FriendsOfFriendsPrivacy implements PrivacyStrategy { public boolean canSee(User v, Post p, FriendshipRegistry f) { if (v.id() == p.authorId() || f.areFriends(v.id(), p.authorId())) return true; for (long mid : f.friendsOf(p.authorId())) if (f.areFriends(v.id(), mid)) return true; return false; }}public final class PrivatePrivacy implements PrivacyStrategy { public boolean canSee(User v, Post p, FriendshipRegistry f) { return v.id() == p.authorId(); }}
/** Observer wiring. */public interface NotificationListener { void onEvent(DomainEvent event);}public final class NotificationService { private final Map<Class<? extends DomainEvent>, List<NotificationListener>> listeners = new ConcurrentHashMap<>(); private final Executor executor; public NotificationService(Executor executor) { this.executor = executor; }
public void subscribe(Class<? extends DomainEvent> kind, NotificationListener l) { listeners.computeIfAbsent(kind, k -> new CopyOnWriteArrayList<>()).add(l); } public void publish(DomainEvent e) { for (NotificationListener l : listeners.getOrDefault(e.getClass(), List.of())) { executor.execute(() -> safe(l, e)); } } private void safe(NotificationListener l, DomainEvent e) { try { l.onEvent(e); } catch (RuntimeException ignored) { /* listener isolation */ } }}
public final class Post { private final long id; private final long authorId; private final String body; private final Privacy privacy; private final Instant createdAt; private final List<Comment> comments = new CopyOnWriteArrayList<>(); private final Map<Long, ReactionKind> reactions = new ConcurrentHashMap<>(); private final NotificationService notifications;
public Post(long id, long authorId, String body, Privacy p, Instant at, NotificationService n) { this.id = id; this.authorId = authorId; this.body = body; this.privacy = p; this.createdAt = at; this.notifications = n; }
public Comment addComment(User author, String text, Instant at) { Comment c = new Comment(IdGen.next(), author.id(), text, at); comments.add(c); notifications.publish(new CommentEvent(id, authorId, c, at)); return c; }
public void react(User user, ReactionKind kind, Instant at) { reactions.put(user.id(), kind); notifications.publish(new ReactionEvent(id, authorId, user.id(), kind, at)); }
public long authorId() { return authorId; } public Privacy privacy() { return privacy; }}
/** The friend-request acceptance flow, end to end. */public final class FriendService { private final FriendRequestRepo requests; private final FriendshipRegistry friendships; private final NotificationService notifications; private final Clock clock;
public FriendService(FriendRequestRepo r, FriendshipRegistry fr, NotificationService n, Clock c) { this.requests = r; this.friendships = fr; this.notifications = n; this.clock = c; }
public void accept(long requestId, long actor) { FriendRequest req = requests.load(requestId); req.accept(actor, clock.instant()); friendships.link(req.from(), req.to(), clock.instant()); notifications.publish(new FriendshipFormed(req.from(), req.to(), clock.instant())); }}Notes the interviewer will look for:
FriendshipKeycanonicalises the pair. One entry per friendship; not two. The asymmetry of “who initiated” is preserved on theFriendRequest, not on theFriendship.NotificationServiceisolates listeners. Each is wrapped insafe(...); a buggy listener cannot break the others or the write path. This is the second-most-common interview question on this round and the slice answers it before being asked.PrivacyStrategyruns on every read. Not at write time — privacy is read-time because friendships change. Putting it on the post field is a frequent slip.- Observers are dispatched on an
Executor. Synchronous notification fan-out at write time is what makes celebrity-post latency blow up; theExecutormakes it async without changing the call sites. Post.reactionsis aMap<UserId, ReactionKind>, not a list. The map enforces the “one active reaction per user” invariant cheaply; a list would invite duplicates and anif (existing) removedance.
Trade-offs and extensions#
| Decision | Why | Cost if requirements change |
|---|---|---|
Friendship canonicalised (one row per pair) | The relationship is symmetric; one row is the honest model | If “follower” semantics enter (asymmetric), introduce a separate Follow aggregate; don’t fold into Friendship. |
FriendRequest separate from Friendship | State machines are distinct; auditing the request is separate from auditing the relationship | None — the separation is correct. |
PrivacyStrategy at read time | Friend sets change; privacy must reflect current state | Caching becomes harder; per-viewer feed precomputation must invalidate on friendship changes — HLD concern. |
Observer + Executor for notifications | Listener isolation + async + open-closed | Ordering across listeners is not guaranteed; an explicit Sequencer listener can recover order if needed. |
NewsFeedService as a seam | Generation strategy is HLD | Explicitly out of scope; defend the line. |
Reaction as Map<UserId, ReactionKind> on Post | Cheap uniqueness; aggregate-local | Per-reaction analytics require an additional event stream — already published. |
In-process FriendshipRegistry | LLD scope | Persistence: FriendshipRepository per aggregate root; the in-memory registry is the cache. |
Page follows are not friendships | Asymmetric, no acceptance step | None — modelled separately on purpose. |
Likely follow-up extensions and the shape of the answer:
- Custom audience lists (“close friends” / “restricted”).
PrivacyStrategybecomesAudienceStrategy; thePost.audiencefield references anAudienceList. The reader still goes through the strategy. - Mentions and tags.
Comment.bodyis parsed at write time; mention events are emitted toNotificationService. TheMentionedUserListeneris already in the design. - Replies one-level-nested becoming N-level. Promote
Comment.repliesto a Composite; comments and replies share the same shape. Composite pattern fits. - Page admin roles (admin / editor / analyst). Add a
PageRoleenum; the gate onPage.post(...)consults the role. Same shape asGroup.role. - Edit history on posts. A
PostRevisionperPost; the post points to the current revision; the edit history is the chain. Same as the Stack Overflow extension.
Mock interview follow-ups#
Questions interviewers reach for and the briefest correct answer:
- “How does the news feed work?” — That is the HLD round.
NewsFeedService.feedFor(user)is the seam; implementation choices include fan-out-on-write (push posts to follower inboxes at post time), fan-out-on-read (compute on read), and a hybrid with celebrity-account exceptions. The LLD-relevant decision is thatFeedItemcan be Composite over Post / Reshare / SponsoredItem / Event — the renderer is uniform. - “What happens when Alice unfriends Bob mid-read?” — Privacy is read-time. Bob’s next read of an
Alice / Friends-scoped post will returnfalsefromFriendsPrivacy.canSee. There is no consistency to repair; the projection is recomputed per read. - “Two users react to the same post simultaneously.” —
Post.reactionsis aConcurrentHashMap;putis atomic. The notification fan-out is async per reaction; observer listeners are isolated. - “How are notifications delivered cross-device?” —
NotificationService.publishfans out to listeners; one listener owns push (per-device tokens), another owns in-app inbox, another owns email digests. Each is a strategy keyed on user preference. Delivery is best-effort; the listener for push retries with backoff on failure. - “How would you persist this?” — One repository per aggregate root:
UserRepository,FriendRequestRepository,FriendshipRepository,PostRepository,GroupRepository,PageRepository. Notifications get an outbox table (NotificationEvent) consumed by the async listeners. The seams don’t move. - “What scales worst here?” — Friend-of-friend privacy and feed fan-out for celebrity accounts. Both are HLD problems. The LLD-relevant decision is that
FriendsOfFriendsPrivacy.canSeedoes an O(friends_of_author) scan; at celebrity scale, that is replaced by a precomputed reachability index — an HLD seam, not an LLD redesign. - “What changes if a user blocks another?” —
User.blockedis consulted by every read path before privacy. Block is asymmetric (one-direction); it short-circuits privacy. The Strategy chain becomesBlock → Privacy.
Related#
- LinkedIn — siblings on the LLD/HLD seam; both define
FeedServiceandSearchServiceas seams and hold the line. - Stack Overflow — the other “user-generated content” Advanced round; shares the privilege-by-role pattern and the projection-over-events trick.
- Observer Pattern —
NotificationServiceis the textbook implementation with listener isolation. - State Pattern —
FriendRequest.statusis a clean finite-state machine. - Strategy Pattern —
PrivacyStrategyis one read-time strategy switch.