LinkedIn

Members, connections, feeds, messaging. Where OOD touches the system-design boundary and you must hold the line.

System Advanced
16 min read
ood case-study social-network observer-pattern composite-pattern

Context#

LinkedIn is a social network for professionals: members create profiles, request and accept connections, post updates and articles, read a personalised feed, and message each other. As an OOD prompt it sits on the seam between LLD and HLD. The honest answer to “how does the feed work” is “fan-out on write versus fan-out on read versus a hybrid, sharded by user and bounded by recency” — and that is a system-design conversation. The LLD discipline in this round is to draw the seam, name the implementation as out-of-scope, and design what’s actually inside the LLD scope.

The trap is that an unprepared candidate spends 20 minutes on feed-generation strategy and emerges with no class diagram. A prepared candidate puts FeedService on the whiteboard inside the first ten minutes, says “the implementation strategy is the HLD round,” and goes back to the things OOD scores: Member/Connection/Post aggregates, the bidirectional connection state machine, observer-driven notifications, and the messaging aggregate.

The interviewer’s hidden objectives:

  • Can you hold the LLD line when the prompt drifts toward HLD? Name what’s out-of-scope and don’t relitigate.
  • Can you model a bidirectional Connection correctly — single canonical record, two-sided state, idempotent operations?
  • Can you use the Composite pattern to keep the feed’s Post / Reshare / SponsoredItem / SystemEvent polymorphic?
  • Can you wire Observer for notifications without coupling Post to NotificationService?
  • Can you reach the messaging aggregate before the round ends?

Requirements (functional and non-functional)#

Scoping decisively is more important here than in any other case study.

Functional — in scope.

  • A member has a profile (name, headline, current position, work history, education, skills).
  • A member can request a connection with another member; the recipient can accept or decline. Either side can remove an accepted connection.
  • A member can post an update (text + optional media). Other members can like, comment on, and reshare a post.
  • A member has a feed — a chronologically-ordered stream of items relevant to them. The feed includes posts from connections, reshares, and aggregated activity (“X and 4 others liked Y”).
  • Members can send messages in 1:1 conversations. (Group chats out of scope.)
  • A member receives notifications for connection requests, reactions, mentions, and messages.

Functional — out of scope (called out explicitly and held to).

  • How the feed is generated at scale. Fan-out strategy, sharding, cache warming, ranking — FeedService is the seam, the implementation is HLD.
  • Search (people, jobs, posts). SearchService is the seam.
  • Recommendations (“People You May Know”, “Jobs You May Like”). Seam only.
  • Job postings, applications, recruiter tools. A whole second product.
  • Premium / billing, ads, sponsored content. Cut. (A SponsoredItem type appears in the feed’s class hierarchy because removing it would distort the composite; the placement / auction is cut.)
  • Group chat, video calls, events. Cut.
  • Endorsements and recommendations on profiles. Mention but don’t design.
  • Privacy controls beyond connection-gated visibility. A Visibility enum exists; the policy engine is cut.

Non-functional.

  • 10⁹ members in principle; in-process design assumes thin repositories at the aggregate boundaries.
  • Connection state changes propagate to notification observers immediately.
  • The Post aggregate must handle high-fanout reactions without per-reaction round-trips through the post object (think: ~10⁵ likes on a viral post). Counter aggregates carry the load; the post stays small.
  • Messaging order within a conversation is preserved; cross-conversation order is not a guarantee.

Use case diagram#

┌────────────────┐
│ Member │
└────────┬───────┘
┌─────────────┬───────┼───────┬────────────┬─────────────┐
▼ ▼ ▼ ▼ ▼ ▼
[edit profile][request connect][post update][read feed][message][react/comment]
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ LinkedIn │
└────────────────┬───────────────┬───────────────┬────────────┘
▲ ▲ ▲
│ │ │
┌───────────┐ ┌──────────────┐ ┌─────────────┐
│ Recruiter │ │ Page Admin │ │ Admin Ops │
├───────────┤ ├──────────────┤ ├─────────────┤
│ (cut) │ │ (cut) │ │ moderation │
└───────────┘ └──────────────┘ └─────────────┘

One actor on the critical path (Member). Three other actors named and cut.

Class diagram#

┌────────────────────────┐ ┌────────────────────────┐
│ Member │ │ Profile │
├────────────────────────┤ ├────────────────────────┤
│ id, email, headline │◇───▶│ workHistory : List │
│ profile : Profile │ │ education : List │
│ connections : Set<Id> │ │ skills : List │
└──────────┬─────────────┘ │ visibility : enum │
│ 0..* └────────────────────────┘
┌────────────────────────┐
│ Connection │ ┌────────────────────────┐
├────────────────────────┤ │ FeedService │
│ a, b (a < b lexicogr.)│ ├────────────────────────┤
│ status : ConnectionState│ │ feedFor(member) │ // seam — HLD
│ requestedBy, at │ │ // implementation OOS │
│ accept(by), decline(by)│ └──────────┬─────────────┘
│ remove(by) │ │
└────────────────────────┘ ▼
┌────────────────────────┐
┌────────────────────────┐ │ FeedItem │◁── Post,
│ Post │ ├────────────────────────┤ Reshare,
├────────────────────────┤ │ id, at, author │ SponsoredItem,
│ author, body, media │ │ render() │ SystemEvent
│ likes : Counter │◀────│ │
│ comments : List │ └────────────────────────┘
│ reshares : Counter │
│ like(by) / comment(by) │
└──────────┬─────────────┘
│ emits events
┌────────────────────────┐ ┌────────────────────────┐
│ PostEventListener │ │ NotificationService │
├────────────────────────┤ ├────────────────────────┤
│ onLiked(post, by) │────▶│ notify(member, item) │
│ onCommented(post, by) │ └────────────────────────┘
│ onMentioned(post, who) │
└────────────────────────┘
┌────────────────────────┐
┌────────────────────────┐ │ Conversation │
│ Message │ ├────────────────────────┤
├────────────────────────┤ │ id, participants (2) │
│ from, to, body, at │◀────│ messages : List │
│ status : MsgStatus │ │ send(from, body) │
│ read() │ └────────────────────────┘
└────────────────────────┘

Three patterns are doing the load-bearing work:

  • Composite on FeedItem. Post, Reshare, SponsoredItem, SystemEvent (“X and 4 others liked Y”) all implement FeedItem so the feed iterator is uniform. The feed’s generation is HLD; the feed’s shape is OOD.
  • Observer on Post events. Post.like(by) does not call NotificationService directly; it emits an event that listeners (notifications, analytics, anti-abuse) subscribe to.
  • State pattern on Connection.statusPending → Accepted → Removed, plus Declined reachable from Pending.

The bidirectional Connection deserves a callout. A connection between members A and B is one record, not two. The canonical key is (min(A, B), max(A, B)) so lookups from either side find the same row. The requestedBy field records who initiated; the status is shared.

What is deliberately not in the diagram:

  • No Feed aggregate. The feed is a view, computed by FeedService from posts + connections. Treating the feed as state is the start of the HLD slide.
  • No Like aggregate. Likes are a counter on Post plus an event for the observer chain. Storing per-like records is correct at HLD scale, scope-creep at LLD.
  • No Newsfeed.recommendedPeople. Recommendations are a seam.

Sequence diagram (key flows)#

Connection request, accept, and the observer chain to notifications:

Alice LinkedIn Connection Bob NotificationSvc
│ connect(Bob) │ │ │ │
│─────────────►│ │ │ │
│ │ new Connection(PENDING, requestedBy=Alice)
│ │────────────►│ │ │
│ │ emit ConnectionRequested │ │
│ │─────────────────────────────────────► │
│ │ │ │ notify(Bob, "Alice wants to connect")
│ │ │ │ │
│ ack │ │ │ │
│◄─────────────│ │ │ │
│ │ │ │ │
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ later ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
│ │ Bob.accept(connectionId)│ │
│ │◄────────────────────────│ │
│ │ accept(by=Bob) │ │
│ │────────────►│ │ │
│ │ emit ConnectionAccepted │
│ │─────────────────────────────────────► │
│ │ │ │ notify(Alice, "Bob accepted")

Post a viral update — the observer pattern keeps Post simple:

Alice Post PostEventBus NotificationSvc FeedService Analytics
│ like(Bob) │ │ │ │ │
│──────────►│ │ │ │ │
│ │ likes++ │ │ │ │
│ │ emit Liked│ │ │ │
│ │──────────►│ │ │ │
│ │ │ onLiked(post,Bob)│ │ │
│ │ │─────────────────►│ │ │
│ │ │ │ notify(author, "Bob liked")
│ │ │ onLiked │ │ │
│ │ │────────────────────────────────────►│ │
│ │ │ │ (FeedService updates fan-out — HLD impl)
│ │ │ onLiked │ │ │
│ │ │────────────────────────────────────────────────────►│

Note: Post doesn’t know about NotificationService, FeedService, or Analytics. It publishes; they subscribe. Adding a new listener (e.g., anti-abuse on like-floods) is a new class, not an edit to Post.

Activity diagram (for non-trivial state)#

The bidirectional Connection lifecycle:

┌─────────┐
│ start │
└────┬────┘
┌────────────┐
│ Pending │── recipient accepts ──►┌────────────┐
└─────┬──────┘ │ Accepted │
│ └─────┬──────┘
│ recipient declines │
│ OR requester withdraws │ either side removes
▼ ▼
┌────────────┐ ┌────────────┐
│ Declined │ │ Removed │
└────────────┘ └────────────┘
(terminal) (terminal — re-request
creates a new Pending)

Critical invariants:

  • Pending → Accepted requires the actor to be b if a requested (and vice-versa). The connection enforces this — it knows requestedBy.
  • Removed is terminal for this record. A subsequent reconnection creates a new Connection with a new requestedBy.
  • accept() and decline() are idempotent — calling twice from the same actor in the same state is a no-op. The repository’s job is to enforce uniqueness; the aggregate’s job is to enforce transitions.

Java implementation#

A representative slice. Connection and its state machine, Post with the observer chain, the FeedService seam, and a sketch of Conversation.

public enum ConnectionState { PENDING, ACCEPTED, DECLINED, REMOVED }
public enum MessageStatus { SENT, DELIVERED, READ }
public enum Visibility { PUBLIC, CONNECTIONS, PRIVATE }
public final class Member {
private final String id;
private final String email;
private Profile profile;
public Member(String id, String email, Profile p) { this.id = id; this.email = email; this.profile = p; }
public String id() { return id; }
}
public final class Connection {
private final String a, b; // canonical: a < b lexicographically
private final String requestedBy;
private final Instant requestedAt;
private ConnectionState state = ConnectionState.PENDING;
public static Connection request(Member from, Member to, Instant now) {
String a = from.id().compareTo(to.id()) < 0 ? from.id() : to.id();
String b = from.id().compareTo(to.id()) < 0 ? to.id() : from.id();
return new Connection(a, b, from.id(), now);
}
private Connection(String a, String b, String requestedBy, Instant at) {
this.a = a; this.b = b; this.requestedBy = requestedBy; this.requestedAt = at;
}
public synchronized void accept(String byMemberId) {
require(ConnectionState.PENDING);
if (byMemberId.equals(requestedBy)) {
throw new IllegalArgumentException("Requester cannot accept their own request");
}
if (!byMemberId.equals(a) && !byMemberId.equals(b)) {
throw new IllegalArgumentException(byMemberId + " is not a party to this connection");
}
state = ConnectionState.ACCEPTED;
}
public synchronized void decline(String byMemberId) {
require(ConnectionState.PENDING);
if (byMemberId.equals(requestedBy)) {
// requester withdrawing their own request is also a Decline
}
state = ConnectionState.DECLINED;
}
public synchronized void remove(String byMemberId) {
require(ConnectionState.ACCEPTED);
if (!byMemberId.equals(a) && !byMemberId.equals(b)) {
throw new IllegalArgumentException(byMemberId + " is not a party to this connection");
}
state = ConnectionState.REMOVED;
}
private void require(ConnectionState expected) {
if (state != expected) throw new IllegalStateException("Expected " + expected + ", was " + state);
}
public ConnectionState state() { return state; }
public String a() { return a; }
public String b() { return b; }
}
// ---- Observer wiring for Post events ----
public sealed interface PostEvent
permits PostEvent.Liked, PostEvent.Commented, PostEvent.Reshared, PostEvent.Mentioned {
record Liked(String postId, String byMemberId) implements PostEvent {}
record Commented(String postId, String byMemberId, String text) implements PostEvent {}
record Reshared(String postId, String byMemberId) implements PostEvent {}
record Mentioned(String postId, String mentionedMemberId, String byMemberId) implements PostEvent {}
}
public interface PostEventListener {
void on(PostEvent e);
}
public final class PostEventBus {
private final List<PostEventListener> listeners = new CopyOnWriteArrayList<>();
public void subscribe(PostEventListener l) { listeners.add(l); }
public void publish(PostEvent e) {
for (PostEventListener l : listeners) {
try { l.on(e); } catch (RuntimeException ex) { /* isolate failures */ }
}
}
}
public final class Post {
private final String id;
private final String authorId;
private final String body;
private final List<String> media;
private final Instant at;
private final AtomicLong likes = new AtomicLong();
private final AtomicLong reshares = new AtomicLong();
private final List<Comment> comments = new CopyOnWriteArrayList<>();
private final PostEventBus bus;
public Post(String id, String authorId, String body, List<String> media, Instant at, PostEventBus bus) {
this.id = id; this.authorId = authorId; this.body = body;
this.media = List.copyOf(media); this.at = at; this.bus = bus;
}
public void like(String byMemberId) {
likes.incrementAndGet();
bus.publish(new PostEvent.Liked(id, byMemberId));
}
public void comment(String byMemberId, String text) {
comments.add(new Comment(byMemberId, text, Instant.now()));
bus.publish(new PostEvent.Commented(id, byMemberId, text));
for (String mentioned : extractMentions(text)) {
bus.publish(new PostEvent.Mentioned(id, mentioned, byMemberId));
}
}
public void reshare(String byMemberId) {
reshares.incrementAndGet();
bus.publish(new PostEvent.Reshared(id, byMemberId));
}
private List<String> extractMentions(String text) { /* parse @handles */ return List.of(); }
public record Comment(String byMemberId, String text, Instant at) {}
}
// ---- FeedItem composite ----
public sealed interface FeedItem permits PostItem, ReshareItem, SponsoredItem, AggregatedActivity {
Instant at();
String authorId();
}
public record PostItem(Post post) implements FeedItem {
public Instant at() { return Instant.now(); } public String authorId() { return ""; }
}
public record ReshareItem(Post original, String resharerId, Instant at) implements FeedItem {
public String authorId() { return resharerId; }
}
public record SponsoredItem(String campaignId, String body, Instant at) implements FeedItem {
public String authorId() { return "sponsored"; }
}
public record AggregatedActivity(String summary, List<String> actorIds, Instant at) implements FeedItem {
public String authorId() { return actorIds.get(0); }
}
// ---- The FeedService seam ----
public interface FeedService {
/** Implementation strategy (fan-out on write / read / hybrid) is HLD scope. */
List<FeedItem> feedFor(String memberId, int limit, Instant before);
}
// ---- NotificationService as a listener ----
public final class NotificationService implements PostEventListener {
public void on(PostEvent e) {
switch (e) {
case PostEvent.Liked l -> deliver(l.byMemberId(), "liked your post");
case PostEvent.Commented c -> deliver(c.byMemberId(), "commented on your post");
case PostEvent.Reshared r -> deliver(r.byMemberId(), "reshared your post");
case PostEvent.Mentioned m -> deliver(m.mentionedMemberId(), "mentioned you");
}
}
private void deliver(String memberId, String text) { /* push / email / in-app */ }
}
// ---- Messaging aggregate ----
public final class Conversation {
private final String id;
private final String[] participants; // length 2
private final List<Message> messages = new ArrayList<>();
public Conversation(String id, String memberA, String memberB) {
this.id = id; this.participants = new String[] { memberA, memberB };
}
public synchronized Message send(String fromMemberId, String body) {
if (!isParticipant(fromMemberId)) {
throw new IllegalArgumentException(fromMemberId + " is not in this conversation");
}
String to = participants[0].equals(fromMemberId) ? participants[1] : participants[0];
Message m = new Message(UUID.randomUUID().toString(), fromMemberId, to, body, Instant.now());
messages.add(m);
return m;
}
public synchronized List<Message> since(Instant t) {
return messages.stream().filter(m -> m.at().isAfter(t)).toList();
}
private boolean isParticipant(String id) {
return participants[0].equals(id) || participants[1].equals(id);
}
}
public final class Message {
private final String id;
private final String from, to, body;
private final Instant at;
private MessageStatus status = MessageStatus.SENT;
public Message(String id, String from, String to, String body, Instant at) {
this.id = id; this.from = from; this.to = to; this.body = body; this.at = at;
}
public void delivered() { if (status == MessageStatus.SENT) status = MessageStatus.DELIVERED; }
public void read() { status = MessageStatus.READ; }
public Instant at() { return at; }
}

Notes the interviewer will look for:

  • Connection canonicalisation. (a, b) with a < b — one record, not two. The methods enforce party membership.
  • requestedBy lives on the Connection. The requester cannot accept their own request; the aggregate enforces this.
  • Observer wiring is sealed events + a bus. Pattern-matching switch in Java keeps the listener concise; Post doesn’t know who’s subscribed.
  • FeedService is an interface with the implementation strategy named out-of-scope in a comment, not hand-waved. That comment is the most important line in the round.
  • Counters via AtomicLong. A viral post does not serialise like() through a monitor.
  • Conversation is synchronized on send. Within-conversation message order is the only guarantee.

Trade-offs and extensions#

Decisions explicitly made and what they cost:

DecisionWhyCost / extension shape
Connection canonicalised on (min, max)One record; both-sided lookupsNone — this is the right shape.
FeedService as a seam, not an aggregateGeneration is HLDExplicitly out of scope; defend it.
Counters via AtomicLongViral postsAtomic-counter limits at scale; promote to a separate EngagementCounter aggregate persisted as a row-per-bucket.
Observer bus per processDecouples Post from listenersCross-process: replace PostEventBus with a message-broker adapter; listener interface stays the same.
Single Profile aggregateOne profile per memberPrivacy-tiered profile views become a ProfileView projection.
1:1 conversations onlyGroup chat is its own complexityPromote participants from String[] of length 2 to a list; revisit send() invariants.
Message.status is SENT/DELIVERED/READThree-state read receiptsPer-recipient status in group chats is a different shape.
No privacy beyond Visibility enumPolicy engine cutIntroduce a VisibilityPolicy strategy and consult on read paths.

Likely follow-up extensions:

  • Mentions in posts. Already wired — PostEvent.Mentioned exists and NotificationService handles it.
  • Reactions beyond Like. Promote the likes counter to a Map<Reaction, AtomicLong>. The observer event carries the reaction.
  • Following (asymmetric) in addition to Connecting (symmetric). A new Follow aggregate; one-way, no acceptance. FeedService consults both sets.
  • Read receipts in group chat. Per-recipient MessageStatus map.
  • Pages and Companies as posters. authorId becomes authorRef — a sum type of Member or Page. Post doesn’t otherwise change.

Mock interview follow-ups#

  • “How does the feed work?” — That is the HLD round. FeedService.feedFor(member) 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 from connections’ posts), and a hybrid with celebrity-account exceptions. The LLD-relevant decision is that FeedItem is a composite so Post / Reshare / SponsoredItem / AggregatedActivity flow through the same renderer.
  • “What’s the state machine on Connection?”Pending → Accepted → Removed, with Declined reachable from Pending. Re-requesting after Removed or Declined creates a new Connection. Show the activity diagram.
  • “Where would observers fit?” — They are already wired: PostEventBus decouples Post from NotificationService, FeedService, and Analytics. Adding an anti-abuse listener is a new class.
  • “What if Alice mentions Bob in a comment?”Post.comment extracts mentions and publishes PostEvent.Mentioned. NotificationService notifies Bob. Zero coupling between the comment path and the notification path.
  • “How do you handle a viral post with 100k likes?”likes is an AtomicLong; per-like contention is sub-microsecond. The observer chain is async and isolated per listener — a slow Analytics listener does not block notifications. If counters become a hot row at scale, promote to a EngagementCounter aggregate with sharded buckets — that is an HLD conversation.
  • “Group chats?” — Out of scope. Conversation is 1:1 in this design. Group chat requires per-recipient read-status, member-management semantics, and admin roles — that is enough for its own round.
  • “How would you persist this?” — One repository per aggregate root: MemberRepository, ConnectionRepository, PostRepository, ConversationRepository. The seam shapes don’t move. Counter and feed persistence is HLD.
  • “Can a non-connection see Bob’s profile?” — Consult profile.visibility. PUBLIC is open; CONNECTIONS checks the canonicalised (a, b) row; PRIVATE is owner-only. A VisibilityPolicy strategy hides the branching from callers.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.