For Senior / Staff Backend Engineers
The Core Problem#
Most candidates fail LLD rounds not because they lack knowledge, but because they cannot apply knowledge under time pressure. The fix is not to study more patterns — it is to have a repeatable execution framework that works on any problem.
Root Cause: You know SOLID. You know patterns. But in the room, you start coding immediately, skip the thinking phase, and end up with a ball of mud. The solution is a structured thinking ritual you run before writing a single class.
The Two Failure Modes#
| Failure Mode | What it looks like |
|---|---|
| Over-engineering | 5 minutes in, you are debating Event Sourcing. No classes written yet. |
| Under-engineering | You write working code but a flat structure — no layers, no interfaces, God classes everywhere. |
Both are solved by the same thing: a time-boxed, sequential ritual.
The 5-Minute Thinking Ritual#
Run this before writing any class. Every time. Without exception.
Step 1 — Identify the Entities (60 seconds)#
Ask: "What are the nouns in this system?" These become your models.
- Write them down: URL, User, Click, Game, Player, Move, Order, Product...
- Do not worry about fields yet. Just get the nouns on paper.
- Ignore verbs for now. Verbs are use-cases, not models.
Signal: Identifying 4–6 entities in 60 seconds shows the interviewer you can decompose a problem fast.
Step 2 — Identify the Use-Cases (60 seconds)#
Ask: "What does this system DO?" These become your service methods.
- Write verbs: shorten URL, resolve URL, register user, make move, place order...
- Each verb is one service method. If you have 6 verbs, you have 6 service methods.
- Do not think about HOW yet. Just list what the system does.
Step 3 — Identify What Can Go Wrong (30 seconds)#
Ask: "What are the failure cases?" These become your exceptions.
- Not found, already exists, not your turn, expired, invalid input...
- One exception per distinct failure. Name from the caller's perspective.
Step 4 — Draw the Dependency Arrow (30 seconds)#
Always draw this mentally before coding:
main.py
└── Service (depends on Interfaces)
└── Interface (depends on Models)
└── Repository (implements Interface, depends on Models)
└── Model (depends on nothing)
If any arrow points the wrong way — a model importing a service, a repo calling another repo — the design is broken.
Step 5 — State Your Constraints Out Loud (60 seconds)#
Before coding, say one sentence per constraint. This is where staff-level thinking becomes visible.
- "The redirect path is the hot path — click recording must not block it."
- "Short URL generation needs to be collision-free and non-enumerable."
- "Board state changes on every move — I need atomic read-modify-write."
Why this matters: Constraints drive design. An interviewer hearing constraints before code sees an engineer who thinks before building. This is the clearest staff-level signal in an LLD round.
The Universal Layer Map#
Every LLD — regardless of domain — maps to the same six layers. Memorise this once.
| Layer | Question it answers |
|---|---|
model_class.py | What does my data look like? |
exceptions.py | What can go wrong? |
interfaces.py | What operations must every repo support? |
repo_class.py | How is data stored and retrieved? |
service_class.py | What are the use-cases? What does this system DO? |
main.py | How do I wire everything together? |
The One Rule to Never Break#
# Dependencies only point INWARD.
# Outer layers know about inner layers.
# Inner layers NEVER know about outer layers.
main → can import everything
service → imports interfaces + models + exceptions
interfaces → imports models only
repo → imports interfaces + models
model → imports nothing
exceptions → imports nothing
Violation signal: If
model_class.pyever imports fromservice_class.py, or a repo instantiates another repo inside a method, the design has broken the dependency rule.
What Each Layer Must NOT Do#
| Layer | Must NOT |
|---|---|
model | Contain business logic, import from any other layer |
exceptions | Import from any other layer |
interfaces | Contain implementation, know about concrete repos |
repo | Instantiate other repos, contain business logic, check expiry |
service | Write SQL/Redis commands, import concrete repo classes, store state |
main | Contain business logic, domain validation, if/else for domain rules |
SOLID Principles — Practical Application#
Do not recite definitions. Show SOLID through your design choices.
S — Single Responsibility Principle#
One class, one reason to change.
| Violation | Fix |
|---|---|
URLRepository records clicks inside get_url() | Click recording is a side-effect. Move it to URLService. |
GameService contains all move validation logic (500 lines) | Extract MoveValidator. Service orchestrates; validator reasons. |
main() contains business logic | main() is a composition root. Move logic to a service. |
Interview signal: When you extract
MoveValidatorfromGameService, say it out loud: "I'm extracting this because validation is complex enough to have its own test suite and its own reason to change."
The extraction signal: If you find yourself writing a comment like # --- move validation --- inside a class, that block wants to be its own class.
O — Open/Closed Principle#
Open for extension, closed for modification. Add new behaviour without editing existing classes.
# BAD: adding a new piece type requires editing MoveValidator
def _piece_can_reach(self, piece, to_pos):
if piece.type == "rook": ...
if piece.type == "bishop": ...
if piece.type == "archbishop": ... # now you edit this class
# GOOD: each piece knows its own movement rules
class Archbishop(Piece):
def can_reach(self, to_pos): ... # extend, do not modify
When to apply: A long
if/elif/switchon a type field is the signal to replace with polymorphism.
L — Liskov Substitution Principle#
Any concrete implementation must be substitutable for its interface.
# If URLService works with AbstractURLRepository,
# it must work identically with ANY implementation.
service = URLService(url_repo=InMemoryURLRepository()) # works
service = URLService(url_repo=PostgresURLRepository()) # must also work
service = URLService(url_repo=RedisURLRepository()) # must also work
# Violation: PostgresURLRepository.get_url() raises NotImplementedError
# Violation: RedisURLRepository.create_url() has a different return type
Test for LSP: Write your service tests against the interface. If swapping the concrete implementation breaks a test, you have an LSP violation.
I — Interface Segregation Principle#
Do not force classes to implement methods they do not need.
# BAD: one fat interface
class AbstractRepository(ABC):
def create(self): ...
def get(self): ...
def get_analytics(self): ... # URLRepo doesn't need this
def record_click(self): ... # only ClickRepo should have this
# GOOD: separate interfaces by consumer
class AbstractURLRepository(ABC):
def create_url(self): ...
def get_url(self): ...
class AbstractClickRepository(ABC):
def record_click(self): ...
def get_clicks(self): ...
When to split: Split an interface when different consumers only use a subset of its methods. If
URLServicenever callsrecord_click, it should not depend on an interface that has it.
D — Dependency Inversion Principle#
High-level modules depend on abstractions, not concrete classes.
# BAD: service depends on concrete class
class URLService:
def __init__(self):
self._repo = InMemoryURLRepository() # coupled forever, untestable
# GOOD: service depends on interface, concrete class injected from outside
class URLService:
def __init__(self, repo: AbstractURLRepository):
self._repo = repo # injectable, testable, swappable
# main.py is the ONLY place that knows about InMemoryURLRepository
service = URLService(repo=InMemoryURLRepository())
The payoff: DIP is what makes unit testing possible. When
URLServicetakesAbstractURLRepository, you can inject aMockRepositoryin tests and verify service logic in total isolation from storage.
Design Patterns You Will Actually Use#
Do not memorise 23 GoF patterns. Know these 6 deeply and know when to reach for each.
Repository Pattern#
Use when: you need to isolate storage from business logic.
- Abstracts away whether data lives in memory, Postgres, Redis, or a file.
- Service layer never writes SQL, Redis commands, or file I/O.
- The test: "If I replace the DB tomorrow, what do I have to change?" Answer: only the repo.
Strategy Pattern#
Use when: you have multiple algorithms for the same operation and want to swap them.
class AbstractShortCodeStrategy(ABC):
def generate(self, counter: int) -> str: ...
class Base62Strategy(AbstractShortCodeStrategy):
def generate(self, counter): return to_base62(counter)
class HashStrategy(AbstractShortCodeStrategy):
def generate(self, counter): return md5_truncated(counter)
# URLRepository uses whichever is injected — zero changes to repo logic
class URLRepository:
def __init__(self, strategy: AbstractShortCodeStrategy): ...
What to say: "I'd extract the short URL generation into a Strategy so we can swap Base62 for hash-based without changing the repo."
Factory Pattern#
Use when: object creation is complex or the exact type varies at runtime.
class PieceFactory:
@staticmethod
def create(piece_type: PieceType, color: Color, pos: Position) -> Piece:
mapping = {
PieceType.ROOK: Rook,
PieceType.KNIGHT: Knight,
PieceType.QUEEN: Queen,
}
return mapping[piece_type](color, pos)
When to reach for it: constructor logic with if/elif branching on a type is the signal.
Observer Pattern#
Use when: one event should trigger multiple independent reactions.
class EventBus:
def subscribe(self, event_type, handler): ...
def publish(self, event): ...
# URLService publishes ClickRecorded
# AnalyticsService, WebhookService, MLPipelineService all subscribe
# They never directly reference each other
- Click recorded → update analytics, send webhook, trigger ML pipeline.
- Order placed → send email, deduct inventory, notify warehouse.
What to say: "Rather than URLService directly calling AnalyticsService, I'd publish a ClickRecorded event. This decouples analytics from the redirect path and keeps p99 latency low."
Template Method Pattern#
Use when: multiple classes share the same algorithm skeleton but differ in one or two steps.
class BaseNotificationService(ABC):
def send(self, user, message): # template method — fixed skeleton
content = self.format(message) # step 1: varies by subclass
recipient = self.resolve(user) # step 2: varies by subclass
self._deliver(recipient, content) # step 3: varies by subclass
@abstractmethod
def format(self, message): ...
@abstractmethod
def resolve(self, user): ...
@abstractmethod
def _deliver(self, recipient, content): ...
Singleton (use carefully)#
Use when: exactly one shared instance must exist — connection pool, config object.
- Overused: making services singletons when DI already controls instantiation.
- Appropriate: a shared in-memory counter, a config loader, a DB connection pool.
Caution: Singletons make testing harder because they carry global state. In interviews, prefer showing DI over Singleton — it signals more sophistication.
How to Articulate Trade-offs#
This is the single biggest differentiator between mid and staff-level candidates.
The Trade-off Template#
"I chose X over Y because [reason]. The trade-off is [what we gave up]. In production I would [mitigate by doing Z]."
Trade-off Statements by Category#
Storage and data structures:
- "I used a dict for O(1) lookup on the hot path. The trade-off is memory growth — in production I'd cap size with an LRU eviction policy."
- "I'm indexing by both short URL and alias. Two indexes means double the write cost but O(1) on both read paths."
Consistency vs availability:
- "A counter + lock gives strong consistency but becomes a bottleneck under high write throughput. At scale I'd use Redis INCR which is atomic without a Python-level lock."
- "I'm doing last-write-wins here. If we need stronger guarantees, I'd add a version field and use compare-and-swap."
Coupling vs performance:
- "Calling AnalyticsService inline gives consistency but adds latency to the redirect. I'd decouple with a Kafka event — redirect returns in <5ms, analytics processes asynchronously."
- "I'm storing expiry in the application layer. Redis TTL handles it for free — trade-off is TTL is approximate, not exact to the second."
Simplicity vs extensibility:
- "A single AbstractRepository is simpler to start. I'd split it when the interface exceeds 5–6 methods — ISP starts paying off at that size."
- "I could use inheritance here, but composition avoids the fragile base class problem and keeps dependencies explicit."
Short URL generation:
| Approach | Pros | Cons |
|---|---|---|
| Counter + Base62 | No collisions, compact, simple | Sequential = enumerable, distributed counter needs coordination |
| Hash-based (MD5/SHA truncated) | Non-sequential, stateless | Collision probability, needs retry logic |
| UUID | Zero collision risk | Long, not URL-friendly |
Time-Boxing — The 45-Minute Plan#
This is the fix for not being able to apply everything in time. You don't have a time budget — here is one.
| Time | Activity |
|---|---|
| 0:00 – 5:00 | THINKING RITUAL. No code. List entities, use-cases, failure modes, constraints. Say them out loud. |
| 5:00 – 10:00 | Write all models. Get entities + fields on paper. No logic. |
| 10:00 – 12:00 | Write all exceptions. One class per failure mode. Takes 2 minutes. |
| 12:00 – 17:00 | Write interfaces. Method signatures and return types only. |
| 17:00 – 27:00 | Write repo implementations. Fill in storage logic. |
| 27:00 – 40:00 | Write service layer. This is where complexity lives — budget the most time here. |
| 40:00 – 45:00 | Write main(). Wire DI. Demo one happy path + one error path. |
Key rule: If you are behind schedule, cut depth in repos (stub methods with
pass) — never cut interfaces or the service layer. Repos are swappable. Services and interfaces are the architecture.
When You Run Out of Time — What to Say#
Interviewers expect you not to finish. What they evaluate is whether you know what you left out.
- "I've left castling as a stub — I know it needs special pre-condition checks: neither king nor rook has moved, and no squares in between are under attack."
- "The repo is in-memory. In production this would be Redis for board state with Postgres for move history."
- "I haven't handled concurrent moves — optimistic concurrency on a version field on the game record would handle that."
Signal: Saying "I know what I left out and how I would complete it" scores higher than scrambling to write more code in the last 2 minutes.
The Collaborator Extraction Rule#
When a service method grows a complex sub-problem with its own internal logic, extract it into a collaborator class and inject it.
# Signal: you are writing a comment block inside a service method
class GameService:
def make_move(self, ...):
# --- validate move legality --- <-- this block wants to be its own class
if piece.type == PAWN:
... # 40 lines of pawn logic
if piece.type == ROOK:
... # 20 lines of rook logic
The extraction rule: if the block has its own internal state, its own test cases, or more than ~20 lines of logic, it is a collaborator, not inline code.
# After extraction
class GameService:
def __init__(self, ..., validator: MoveValidator):
self._validator = validator # injected, mockable
def make_move(self, ...):
if not self._validator.is_valid_move(game, piece, to_pos):
raise InvalidMoveError(...)
One-Page Cheat Sheet#
The 6 Questions#
| Question | Where the answer lives |
|---|---|
| "What does my data look like?" | model_class.py — pure dataclasses, no logic |
| "What can go wrong?" | exceptions.py — one class per failure mode |
| "What must every storage impl support?" | interfaces.py — abstract repo contracts |
| "How is data actually stored?" | repo_class.py — concrete implementations |
| "What does this system DO?" | service_class.py — use-cases, business logic |
| "How do I wire everything?" | main.py — composition root, DI, no logic |
SOLID in One Line Each#
- S: One class, one reason to change. Extract when a class has two jobs.
- O: Add new behaviour without editing existing code. Replace
if/elifon type with polymorphism. - L: Any concrete repo must be substitutable for its interface without breaking the service.
- I: Do not put click analytics methods on the URL repo interface. Split interfaces by consumer.
- D: Services take interfaces, not concrete classes.
main()is the only place that knows aboutInMemory*.
Patterns in One Line Each#
- Repository: Isolate storage. Service never writes SQL or Redis commands.
- Strategy: Swap algorithms by injecting different implementations.
- Factory: Complex object creation — use when constructor logic branches on type.
- Observer: Decouple side-effects. Event → subscribers process independently.
- Template Method: Shared algorithm skeleton, steps vary by subclass.
Staff Signal Phrases#
- "Before I write any code, let me identify the entities, use-cases, and constraints."
- "I'm depending on the interface here so this is testable and storage-swappable."
- "I chose X over Y — the trade-off is Z. In production I'd mitigate by..."
- "This is a side-effect. I'm keeping it in the service, not the repo, so repos stay pure persistence."
- "I know what I haven't implemented — here is what it would take to complete it."
Structure beats knowledge under pressure. Run the ritual. Every time.