Interview decision
Choose composition, references, inheritance, and ports from lifecycle, substitution, and caller needs rather than diagram aesthetics.
Mental modelAn interface is a behavioral promise at a useful boundary; a relationship explains ownership, cardinality, and lifecycle.
Use it to
- Translate provider-specific notification results.
- Keep pricing policy replaceable only when rules vary.
- Express caller-shaped repository or clock seams.
Keep out of scope
- Adding an interface to every class.
- Using inheritance only to reuse fields.
See the responsibility boundary
NotificationService depends on a DeliveryChannel contract; EmailChannel adapts a provider; DeliveryResult shields callers from provider exceptions.
interface DeliveryChannel {
send(message: Notification): Promise<DeliveryResult>;
}
type DeliveryResult = { kind: 'delivered'; receipt: string } | { kind: 'retryable' | 'rejected'; reason: string };Choose relationships from lifecycle and substitution
Composition fits when one object owns another’s lifecycle; a reference fits when collaborators live independently. Inheritance earns its place only when callers can safely substitute one subtype for another.
A Notification owns a message payload but references a DeliveryChannel. An EmailChannel and SmsChannel can both satisfy the channel contract.
Interviewer follow-ups
- Who owns this object’s lifecycle?
- Could every subtype honor the same contract?
Do not over-engineer: Do not draw arrows for every incidental method call.
Keep interfaces shaped by callers
An interface should represent the behavior a consumer needs, not every method a concrete implementation happens to expose. This keeps tests and substitutions honest.
A DeliveryChannel exposes deliver(notification); it does not expose provider-specific connection setup to NotificationService.
Interviewer follow-ups
- Which caller depends on this operation?
- What does the caller need to know about failure?
Do not over-engineer: Do not add an interface around a class that has no variation, external boundary, or test need.
Make collaboration traceable
A diagram is useful when it lets you walk the primary flow: caller, coordinating object, policy or boundary, state change, and result. Text sketches are enough when they are precise.
`Scheduler -> RoomCatalog.findAvailable -> Reservation.create -> Calendar.add` communicates a flow and puts conflict ownership in view.
Interviewer follow-ups
- Can we replay the primary use case through these calls?
- Where does an error return or get translated?
Do not over-engineer: Do not require UML notation when plain interfaces or a sequence trace communicate better.
Validate the design, not the diagram
Walk one representative flow
- Resolve a permitted channel from recipient preferences.
- Send through the caller-shaped DeliveryChannel contract.
- Translate the provider response into DeliveryResult.
- Return the stable result without leaking SDK types.
Tests that protect the reasoning
- A disabled channel is rejected before provider invocation.
- A provider timeout becomes retryable rather than delivered.
- A fake channel verifies message content without network access.
Evolve it without losing the boundary
- Follow-up change
- Add push notifications and identify which adapter is new and which public contract remains stable.
- Concurrency, when relevant
- Interface design alone does not solve duplicate sends; if retries are in scope, expose an idempotency key at the orchestration boundary.
- System Design boundary
- Queues, provider failover, global rate limiting, and delivery storage belong in System Design once their contracts affect this component.
What depth looks like by level
- Entry
- Define usable operations and simple composition.
- Mid
- Explain dependency direction and error translation.
- Senior
- Balance stable contracts against channel-specific capability.
- Staff+
- Clarify platform versus product ownership and avoid flattening every provider into a lowest-common-denominator interface.