Facebook

Users, friendships, posts, reactions, comments, groups, pages. Friendship state machine plus Observer for notifications.

System Advanced
17 min read
ood case-study facebook observer-pattern state-pattern

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, plus Accepted → 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 lineNewsFeedService.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 feedNewsFeedService.feedFor(user) returns ordered FeedItem results. 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. NewsFeedService is the seam; the implementation lives in the HLD round.
  • Search (users, posts, groups, pages). SearchService is 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 calls publish(...) and does not know who is listening. Notifications are async; a slow listener cannot block the write path.
  • State on FriendRequest.statusREQUESTED → ACCEPTED | DECLINED | CANCELLED. ACCEPTED is the transition that materialises a Friendship. 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: Public returns true; Friends checks FriendshipRegistry.areFriends(viewer, author); FriendsOfFriends does one extra hop; Private returns viewer.id == author.id. The reader does not switch on privacy kind.

What is not in the diagram and that is deliberate:

  • No NewsFeed aggregate. The feed is a view, computed by NewsFeedService from posts + friendships + groups + followed pages. Treating the feed as state is the start of the HLD slide. This is the same discipline as linkedin-design — generation strategy is HLD; the LLD round names the seam and moves on.
  • No Like aggregate. Reactions are a Map<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.
  • Friendship is one record per pair, not two. (user1, user2) is canonicalised so min < max; a single row represents the symmetric relationship. Two-row schemas leak the asymmetry of the original request into the symmetric relationship.
  • FriendRequest is separate from Friendship. The request has an initiator and a target; the friendship does not. Conflating them confuses the state machine and the audit trail.
  • SearchService is 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:

  • ACCEPTED is the only transition that materialises a Friendship. DECLINED and CANCELLED are terminal for that request; a fresh request starts a new FriendRequest.
  • REMOVED is symmetric — either party may unfriend; both lose the friendship. The history is preserved (since and removedAt) 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:

  • FriendshipKey canonicalises the pair. One entry per friendship; not two. The asymmetry of “who initiated” is preserved on the FriendRequest, not on the Friendship.
  • NotificationService isolates listeners. Each is wrapped in safe(...); 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.
  • PrivacyStrategy runs 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; the Executor makes it async without changing the call sites.
  • Post.reactions is a Map<UserId, ReactionKind>, not a list. The map enforces the “one active reaction per user” invariant cheaply; a list would invite duplicates and an if (existing) remove dance.

Trade-offs and extensions#

DecisionWhyCost if requirements change
Friendship canonicalised (one row per pair)The relationship is symmetric; one row is the honest modelIf “follower” semantics enter (asymmetric), introduce a separate Follow aggregate; don’t fold into Friendship.
FriendRequest separate from FriendshipState machines are distinct; auditing the request is separate from auditing the relationshipNone — the separation is correct.
PrivacyStrategy at read timeFriend sets change; privacy must reflect current stateCaching becomes harder; per-viewer feed precomputation must invalidate on friendship changes — HLD concern.
Observer + Executor for notificationsListener isolation + async + open-closedOrdering across listeners is not guaranteed; an explicit Sequencer listener can recover order if needed.
NewsFeedService as a seamGeneration strategy is HLDExplicitly out of scope; defend the line.
Reaction as Map<UserId, ReactionKind> on PostCheap uniqueness; aggregate-localPer-reaction analytics require an additional event stream — already published.
In-process FriendshipRegistryLLD scopePersistence: FriendshipRepository per aggregate root; the in-memory registry is the cache.
Page follows are not friendshipsAsymmetric, no acceptance stepNone — modelled separately on purpose.

Likely follow-up extensions and the shape of the answer:

  • Custom audience lists (“close friends” / “restricted”). PrivacyStrategy becomes AudienceStrategy; the Post.audience field references an AudienceList. The reader still goes through the strategy.
  • Mentions and tags. Comment.body is parsed at write time; mention events are emitted to NotificationService. The MentionedUserListener is already in the design.
  • Replies one-level-nested becoming N-level. Promote Comment.replies to a Composite; comments and replies share the same shape. Composite pattern fits.
  • Page admin roles (admin / editor / analyst). Add a PageRole enum; the gate on Page.post(...) consults the role. Same shape as Group.role.
  • Edit history on posts. A PostRevision per Post; 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 that FeedItem can 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 return false from FriendsPrivacy.canSee. There is no consistency to repair; the projection is recomputed per read.
  • “Two users react to the same post simultaneously.”Post.reactions is a ConcurrentHashMap; put is atomic. The notification fan-out is async per reaction; observer listeners are isolated.
  • “How are notifications delivered cross-device?”NotificationService.publish fans 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.canSee does 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.blocked is consulted by every read path before privacy. Block is asymmetric (one-direction); it short-circuits privacy. The Strategy chain becomes Block → Privacy.
  • LinkedIn — siblings on the LLD/HLD seam; both define FeedService and SearchService as 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 PatternNotificationService is the textbook implementation with listener isolation.
  • State PatternFriendRequest.status is a clean finite-state machine.
  • Strategy PatternPrivacyStrategy is one read-time strategy switch.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.