Loading content...
There's a powerful temptation in software development: see a problem, open an IDE, start typing. It feels productive. It feels like progress. But experienced engineers know a secret—the keyboard is where problems are implemented, not where they're solved.
The hardest bugs to fix, the costliest refactors, and the most frustrating technical debt all share a common origin: diving into implementation before fully understanding how components should interact. A class that works perfectly in isolation fails when integrated. Services that pass unit tests deadlock in production. APIs that seemed clear become ambiguous under edge cases.
This page teaches you to think in interactions—to simulate your design mentally, trace message flows, anticipate failures, and validate your architecture before committing a single line of code to version control.
By the end of this page, you will understand why interaction design precedes implementation, learn techniques for modeling and visualizing interactions, recognize common interaction pitfalls, and develop the discipline to 'debug' designs before they exist as code.
Why does starting with implementation feel right but often go wrong? Consider the development timeline:
The Curve of Change Cost:
| Phase | Cost to Fix Mistake | Example |
|---|---|---|
| Design (thinking) | 1x | Erase whiteboard, redraw |
| Implementation (coding) | 10x | Rewrite classes, update tests |
| Integration (connecting) | 100x | Refactor interfaces, migrate data |
| Production (running) | 1000x | Emergency patches, downtime, customer impact |
Every phase multiplies the cost of mistakes by approximately 10x. A 5-minute conversation about interaction design can save weeks of rework later.
Real-World Scenario: The Integration Nightmare
Consider three teams building an e-commerce platform:
Each team works independently for 6 weeks. All unit tests pass. All services meet their specs. Integration day arrives.
Chaos ensues:
Three months of work requires three more months to integrate. Had the teams spent one day designing interactions together, they'd have shipped on time.
Systems rarely fail in the components—they fail in the connections. A service that works perfectly alone may deadlock, timeout, corrupt data, or silently fail when connected to other services. Interaction design prevents these failures before they can occur.
What You're Actually Doing When Designing Interactions:
Before any code, you're answering critical questions:
Answering these upfront—in diagrams, documents, or discussions—is dramatically cheaper than discovering them in production.
Not all interactions are created equal. Understanding the different types helps you choose the right pattern for each situation.
Synchronous vs. Asynchronous:
| Aspect | Synchronous | Asynchronous |
|---|---|---|
| Caller Behavior | Blocks until response | Continues immediately |
| Coupling | Temporal coupling (both must be available) | Decoupled in time |
| Latency | Accumulated (A→B→C = A + B + C latency) | Parallelized |
| Error Handling | Immediate exceptions | Deferred, complex |
| Debugging | Easier stack traces | Harder correlation |
| Use When | Need immediate result | Fire-and-forget, long-running |
Request-Response vs. Event-Driven:
Request-Response: Caller sends request, waits for specific response.
orderService.createOrder(data) → OrderEvent-Driven: Component emits events, interested parties subscribe.
OrderCreated event → multiple subscribers handle independently1234567891011121314151617181920212223242526272829303132333435363738
// REQUEST-RESPONSE: Direct, coupled interactionclass OrderController { async createOrder(request: CreateOrderRequest): Promise<Order> { // Synchronous chain - each step waits for completion const inventory = await this.inventoryService.reserve(request.items); const payment = await this.paymentService.charge(request.total); const order = await this.orderService.create(request, inventory, payment); await this.notificationService.sendConfirmation(order); return order; // Caller waits for entire chain }} // EVENT-DRIVEN: Decoupled, asynchronous interactionclass OrderController { async createOrder(request: CreateOrderRequest): Promise<OrderId> { // Create order, emit event, return immediately const order = await this.orderService.create(request); await this.eventBus.publish(new OrderCreatedEvent(order)); return order.id; // Caller doesn't wait for subscribers }} // Subscribers handle their concerns independentlyclass InventorySubscriber { @Subscribe(OrderCreatedEvent) async handleOrderCreated(event: OrderCreatedEvent) { await this.inventoryService.reserve(event.order.items); }} class NotificationSubscriber { @Subscribe(OrderCreatedEvent) async handleOrderCreated(event: OrderCreatedEvent) { await this.emailService.sendConfirmation(event.order); }}Choreography vs. Orchestration:
Choreography: Each service knows its role and reacts to events. No central coordinator. Like a dance where each dancer follows the music.
Orchestration: A central coordinator directs services, telling each what to do. Like an orchestra conductor.
| Aspect | Choreography | Orchestration |
|---|---|---|
| Coordination | Decentralized | Centralized |
| Knowledge | Each service knows next step | Orchestrator knows workflow |
| Coupling | Services loosely coupled | Services coupled to orchestrator |
| Visibility | Workflow is implicit | Workflow is explicit |
| Failure Handling | Distributed, complex | Centralized, clearer |
| Best For | Simple, linear flows | Complex, conditional workflows |
Choose synchronous request-response when you need the result immediately and can tolerate coupling. Choose event-driven when operations can happen independently. Choose orchestration when workflows are complex with branching and error handling. Most systems use a mix—request-response at the API layer, events for background processing.
Sequence diagrams are the primary tool for visualizing interactions. They show:
Reading a Sequence Diagram:
User OrderService InventoryService PaymentService
| | | |
| createOrder | | |
|-------------->| | |
| | reserve() | |
| |------------------>| |
| | Reservation | |
| |<------------------| |
| | charge() |
| |---------------------------------------->|
| | Receipt |
| |<----------------------------------------|
| Order | | |
|<--------------| | |
Key Elements to Include:
alt fragments)loop fragments)par fragments)Example: Complete Checkout Sequence
User API Cart Inventory Payment Order
| | | | | |
| checkout() | | | | |
|----------->| | | | |
| | getCart() | | | |
| |----------->| | | |
| | Cart | | | |
| |<-----------| | | |
| | reserve(items) | | |
| |-------------------------->| | |
| | ReservationId | | |
| |<--------------------------| | |
| | charge(amount) | |
| |---------------------------------------->| |
| | | |
| | [alt: payment success] | |
| | PaymentConfirmation | |
| |<----------------------------------------| |
| | createOrder() |
| |---------------------------------------------------->|
| | Order |
| |<----------------------------------------------------|
| | [alt: payment failure] | |
| | PaymentError | |
| |<----------------------------------------| |
| | release(reservationId) | |
| |-------------------------->| | |
| | | | |
| Order/Error| | | | |
|<-----------| | | | |
Sequence diagrams aren't just documentation—they're thinking tools. Drawing a sequence diagram forces you to answer: 'What happens first? What needs what? What could go wrong?' Teams that skip this step often discover these questions during debugging sessions at 2 AM.
The 'happy path' is easy. Every junior developer can design a system that works when everything succeeds. Seasoned engineers design for failure.
In distributed systems and complex components, failure isn't exceptional—it's inevitable. Networks partition. Services crash. Databases timeout. Disks fill. The question isn't if failures happen but how your design handles them.
Failure Modes to Consider:
Error Handling Strategies:
| Strategy | When to Use | Example |
|---|---|---|
| Retry | Transient failures (network blip) | Retry with exponential backoff |
| Fallback | Non-critical functionality | Show cached data if live fetch fails |
| Circuit Breaker | Prevent cascade | Stop calling failing service temporarily |
| Compensation | Partial failure cleanup | Cancel reserved inventory if payment fails |
| Dead Letter Queue | Unprocessable messages | Move poison messages aside for manual review |
| Timeout + Cancel | Long-running operations | Ensure cleanup even if caller gave up |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// DESIGN: Checkout with comprehensive failure handlingclass CheckoutOrchestrator { async checkout(cartId: string): Promise<CheckoutResult> { // Step 1: Reserve inventory (can fail) const reservation = await this.withRetry( () => this.inventory.reserve(cartId), { maxRetries: 3, backoff: 'exponential' } ); try { // Step 2: Charge payment (can fail) const payment = await this.withCircuitBreaker( 'payment-service', () => this.payment.charge(reservation.total), { timeout: 5000 } ); // Step 3: Create order (must succeed at this point) const order = await this.orders.create(reservation, payment); // Step 4: Confirm inventory (should not fail, but log if it does) await this.inventory.commit(reservation.id) .catch(e => this.logger.error('Inventory commit failed', e)); // Step 5: Send notification (non-critical, fire-and-forget) this.notifications.sendConfirmation(order).catch(() => {}); return { success: true, orderId: order.id }; } catch (error) { // COMPENSATION: Payment failed or errored, release inventory await this.inventory.release(reservation.id) .catch(e => this.logger.critical('Failed to release reservation', { reservationId: reservation.id, error: e })); if (error instanceof PaymentDeclinedError) { return { success: false, reason: 'payment_declined' }; } throw error; // Unknown error, propagate } } // Idempotency: Same checkout attempt returns same result async checkoutIdempotent(cartId: string, idempotencyKey: string): Promise<CheckoutResult> { const existing = await this.checkoutRecords.find(idempotencyKey); if (existing) { return existing.result; // Return cached result } const result = await this.checkout(cartId); await this.checkoutRecords.save(idempotencyKey, result); return result; }}For each interaction in your sequence diagram, ask: 'What if this fails?' Draw the failure path. If you can't clearly describe what happens on failure, you don't understand your design well enough. Failure handling should be designed, not discovered.
Some dependencies are obvious—ServiceA calls ServiceB. Others are hidden, implicit, or temporal. These hidden dependencies cause the nastiest bugs because they're not visible in code or diagrams.
Types of Hidden Dependencies:
1. Temporal Dependencies (Order Requirements)
Operation B assumes Operation A has already happened. This isn't enforced in code.
Example: sendShippingNotification() assumes createOrder() already ran. If called before order creation, it fails mysteriously.
Solution: Make the dependency explicit—pass the Order object to the notification function, or check for order existence.
2. Data Format Dependencies
Component A produces data in a format that Component B expects. Neither enforces the contract.
Example: ServiceA stores dates as strings '2025-01-15'. ServiceB parses dates expecting ISO 8601 with time '2025-01-15T00:00:00Z'. Both work until they integrate.
Solution: Define explicit DTOs or schemas. Use serialization libraries that enforce formats.
3. Shared State Dependencies
Components share mutable state (database row, cache entry, file). Updates by one affect others.
Example: OrderService and InventoryService both read/write the stock column. Race conditions cause overselling.
Solution: Define clear ownership. Only one service writes to each data element. Others request changes through that owner.
4. Configuration Dependencies
Components depend on shared configuration values. Change one, break another.
Example: Multiple services assume MAX_ORDER_ITEMS=100. One service is updated to support 200. Others reject orders with 150 items.
Solution: Centralize configuration. Make dependencies on config values explicit and versioned.
12345678910111213141516171819202122232425262728293031
// HIDDEN DEPENDENCY: Temporal assumptionclass ShippingNotificationService { // BAD: Assumes order exists, caller must 'know' to create order first async sendShippingNotification(orderId: string): Promise<void> { const order = await this.orderRepo.find(orderId); // Throws cryptic error if order doesn't exist await this.email.send(order.customerEmail, this.buildShippingEmail(order)); }} // EXPLICIT DEPENDENCY: Accept the order, or validate existenceclass ShippingNotificationService { // GOOD: Dependency is explicit - caller must provide the order async sendShippingNotification(order: Order): Promise<void> { if (order.status !== OrderStatus.Shipped) { throw new InvalidStateError('Cannot send shipping notification for unshipped order'); } await this.email.send(order.customerEmail, this.buildShippingEmail(order)); } // ALTERNATIVE: Validate internally but make expectation clear async sendShippingNotificationById(orderId: string): Promise<void> { const order = await this.orderRepo.findRequired(orderId); // Explicit 'required' if (order.status !== OrderStatus.Shipped) { throw new InvalidStateError(`Order ${orderId} is ${order.status}, expected Shipped`); } await this.email.send(order.customerEmail, this.buildShippingEmail(order)); }}Every time you write code that assumes something about state, format, or sequence, ask: 'Is this assumption enforced or documented?' If not, you've created a hidden dependency. Make it visible through types, validation, or contracts.
Before implementation, walk through your design with specific scenarios. This is mental debugging—finding bugs in designs that don't exist as code yet.
Scenario Categories to Consider:
| Category | Question to Ask | Example Scenario |
|---|---|---|
| Happy Path | Does the normal case work end-to-end? | User checks out with valid card, items in stock |
| Empty/Null | What happens with missing data? | User checks out with empty cart |
| Boundaries | What happens at limits? | Order with 1000 items, price of $0.01 |
| Errors | What happens when each step fails? | Payment declined after inventory reserved |
| Concurrency | What if operations overlap? | Two users buy last item simultaneously |
| Timing | What if things are slow or out-of-order? | Payment confirmation arrives after timeout |
| Recovery | What happens after restart? | Server crashes mid-checkout, user retries |
| Evolution | What happens when we change things? | New payment provider with different fields |
Trace Through Example: Concurrent Last-Item Purchase
Scenario: Two users attempt to buy the last item in stock at the same time.
Trace through the interaction:
reserve(item) → Success, stock = 0reserve(item) → ??? (This is the question)Possible Outcomes:
Design Decision Needed: You must explicitly decide: Does reservation lock inventory? For how long? What happens if the holder doesn't complete checkout?
This scenario reveals that 'reserve inventory' isn't simple—it has concurrency implications that must be designed.
Running scenarios often reveals requirements nobody stated. 'What if both users want the same item?' sounds obvious, but it implies decisions about locking, timeouts, and user experience. Capture these discoveries as you walk through scenarios.
Practical Exercise: The 'What If' Game
For each interaction in your design, systematically ask:
Answering these questions exposes gaps in your interaction design. Fill those gaps before coding.
Once you've designed interactions, document them. Not prose descriptions but executable contracts that can be verified and enforced.
Elements of an Interaction Contract:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
/** * INTERACTION CONTRACT: Reserve Inventory * * Operation: reserve * Owner: InventoryService * * PRECONDITIONS: * - items must be non-empty array of valid SKUs * - caller must be authenticated with INVENTORY_WRITE permission * - each item quantity must be > 0 * * POSTCONDITIONS (on success): * - stock for each item is decremented by requested quantity * - reservation record is created with expiration time * - returned reservation ID can be used for commit() or release() * * ERROR CONDITIONS: * - INSUFFICIENT_STOCK: One or more items doesn't have enough stock * - None of the items are reserved (all-or-nothing) * - Response includes which items failed * - INVALID_SKU: One or more SKUs don't exist * - EXPIRED_SESSION: Caller's session has expired * * IDEMPOTENCY: * - NOT idempotent. Each call creates new reservation. * - For idempotent reserve, use reserveIdempotent(clientId, items) * * SIDE EFFECTS: * - Creates reservation record in database * - Decrements available stock (but not committed stock) * - Schedules automatic release after TTL if not committed * * TIMEOUT: 5 seconds * RETRY: Safe to retry on network errors (but creates duplicate reservations) */interface InventoryService { reserve(items: ReserveItem[]): Promise<Reservation>;} interface ReserveItem { sku: string; // Required: Product SKU quantity: number; // Required: > 0} interface Reservation { id: string; // Unique reservation identifier items: ReservedItem[]; // Items successfully reserved expiresAt: Date; // When reservation auto-releases createdAt: Date;} interface ReservedItem { sku: string; quantity: number; unitPrice: Money; // Price at time of reservation} // Error typesclass InsufficientStockError extends Error { constructor(public readonly failures: StockFailure[]) { super('Insufficient stock for one or more items'); }} interface StockFailure { sku: string; requested: number; available: number;}When contracts live in code (as TypeScript interfaces with JSDoc, OpenAPI specs, or Protocol Buffers), they stay in sync with implementations. Prose documentation rots; code contracts are verified by compilers and tests.
We've covered the critical discipline of thinking about interactions before writing code. Let's consolidate the key lessons:
What's Next:
You can now break systems into pieces, assign responsibilities, and design interactions. But software isn't static—it evolves. The final piece of the LLD mindset is learning to balance flexibility with simplicity—designing systems that can change without becoming unnecessarily complex.
You now understand why interaction design precedes implementation, and have tools to model, validate, and document component interactions. This discipline transforms you from a coder who discovers problems in production to an engineer who prevents them by design. Next, we'll explore balancing flexibility with simplicity—the final pillar of the LLD mindset.