Members, connections, feeds, messaging. Where OOD touches the system-design boundary and you must hold the line.
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 / SystemEventpolymorphic? - Can you wire Observer for notifications without coupling
PosttoNotificationService? - 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 —
FeedServiceis the seam, the implementation is HLD. - Search (people, jobs, posts).
SearchServiceis 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
SponsoredItemtype 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
Visibilityenum 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 implementFeedItemso the feed iterator is uniform. The feed’s generation is HLD; the feed’s shape is OOD. - Observer on
Postevents.Post.like(by)does not callNotificationServicedirectly; it emits an event that listeners (notifications, analytics, anti-abuse) subscribe to. - State pattern on
Connection.status—Pending → Accepted → Removed, plusDeclinedreachable fromPending.
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
Feedaggregate. The feed is a view, computed byFeedServicefrom posts + connections. Treating the feed as state is the start of the HLD slide. - No
Likeaggregate. 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 → Acceptedrequires the actor to bebifarequested (and vice-versa). The connection enforces this — it knowsrequestedBy.Removedis terminal for this record. A subsequent reconnection creates a newConnectionwith a newrequestedBy.accept()anddecline()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)witha < b— one record, not two. The methods enforce party membership. requestedBylives on the Connection. The requester cannot accept their own request; the aggregate enforces this.- Observer wiring is
sealedevents + a bus. Pattern-matchingswitchin Java keeps the listener concise;Postdoesn’t know who’s subscribed. FeedServiceis 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 serialiselike()through a monitor. Conversationissynchronizedon send. Within-conversation message order is the only guarantee.
Trade-offs and extensions#
Decisions explicitly made and what they cost:
| Decision | Why | Cost / extension shape |
|---|---|---|
Connection canonicalised on (min, max) | One record; both-sided lookups | None — this is the right shape. |
FeedService as a seam, not an aggregate | Generation is HLD | Explicitly out of scope; defend it. |
Counters via AtomicLong | Viral posts | Atomic-counter limits at scale; promote to a separate EngagementCounter aggregate persisted as a row-per-bucket. |
| Observer bus per process | Decouples Post from listeners | Cross-process: replace PostEventBus with a message-broker adapter; listener interface stays the same. |
Single Profile aggregate | One profile per member | Privacy-tiered profile views become a ProfileView projection. |
| 1:1 conversations only | Group chat is its own complexity | Promote participants from String[] of length 2 to a list; revisit send() invariants. |
Message.status is SENT/DELIVERED/READ | Three-state read receipts | Per-recipient status in group chats is a different shape. |
No privacy beyond Visibility enum | Policy engine cut | Introduce a VisibilityPolicy strategy and consult on read paths. |
Likely follow-up extensions:
- Mentions in posts. Already wired —
PostEvent.Mentionedexists andNotificationServicehandles it. - Reactions beyond Like. Promote the
likescounter to aMap<Reaction, AtomicLong>. The observer event carries the reaction. - Following (asymmetric) in addition to Connecting (symmetric). A new
Followaggregate; one-way, no acceptance.FeedServiceconsults both sets. - Read receipts in group chat. Per-recipient
MessageStatusmap. - Pages and Companies as posters.
authorIdbecomesauthorRef— a sum type ofMemberorPage.Postdoesn’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 thatFeedItemis a composite soPost / Reshare / SponsoredItem / AggregatedActivityflow through the same renderer. - “What’s the state machine on Connection?” —
Pending → Accepted → Removed, withDeclinedreachable fromPending. Re-requesting afterRemovedorDeclinedcreates a newConnection. Show the activity diagram. - “Where would observers fit?” — They are already wired:
PostEventBusdecouplesPostfromNotificationService,FeedService, andAnalytics. Adding an anti-abuse listener is a new class. - “What if Alice mentions Bob in a comment?” —
Post.commentextracts mentions and publishesPostEvent.Mentioned.NotificationServicenotifies Bob. Zero coupling between the comment path and the notification path. - “How do you handle a viral post with 100k likes?” —
likesis anAtomicLong; per-like contention is sub-microsecond. The observer chain is async and isolated per listener — a slowAnalyticslistener does not block notifications. If counters become a hot row at scale, promote to aEngagementCounteraggregate with sharded buckets — that is an HLD conversation. - “Group chats?” — Out of scope.
Conversationis 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.PUBLICis open;CONNECTIONSchecks the canonicalised(a, b)row;PRIVATEis owner-only. AVisibilityPolicystrategy hides the branching from callers.
Related#
- Amazon Online Shopping System — the sibling Advanced prompt; same discipline of cutting decisively.
- Observer Pattern —
PostEventBusis the textbook application. - Composite Pattern —
FeedItemkeeps the feed renderer uniform across heterogeneous types. - State Pattern —
ConnectionandMessage.statusare clean state machines. - Approaching the OOD Interview — the meta-script that produced this writeup’s structure.