Loading content...
Imagine constructing a building without blueprints—laying bricks and hoping walls align with windows, that floors meet doorways, that plumbing reaches bathrooms. The result would be chaos.
Yet many developers approach code exactly this way: diving into implementation details before clearly defining what components must do. They write concrete classes first, then extract interfaces after the fact—if at all.
Interface-first development inverts this pattern. You define the contract—what a component promises to do—before writing the code that fulfills that promise. This approach is fundamental to professional software engineering and essential in LLD interviews.
This page explores why interface-first design produces better code, how to practice it effectively, and how to apply it under the time pressure of technical interviews.
By the end of this page, you will understand the concrete benefits of interface-first design, how to identify what should be an interface versus a class, techniques for designing effective interfaces, the relationship between interfaces and testability, and how to apply interface-first principles under interview time constraints.
The interface-first philosophy is rooted in a simple observation: consumers of code care about what it does, not how it does it.
When you call a payment processor, you care that it charges a card and returns a result. You don't care whether it uses HTTP or gRPC, whether it retries three times or five, whether it logs to CloudWatch or Datadog. These are implementation details invisible to the caller.
The Interface is the API:
An interface defines the public contract between components:
Everything else—algorithms, data structures, external dependencies, error handling strategies—is implementation that can vary without affecting callers.
Senior engineers think in interfaces first because they've experienced the pain of not doing so. They've seen how a 'simple' change to a concrete class rippled through 50 files. They've struggled to test code that created its own dependencies. Interface-first isn't academic—it's hard-won wisdom.
Not everything needs to be an interface. Over-abstraction creates unnecessary complexity. The art is recognizing where abstraction adds value.
Strong Candidates for Interfaces:
| Scenario | Why Interface Helps | Example |
|---|---|---|
| Multiple implementations expected | Enables polymorphic substitution | PaymentProcessor → Stripe, PayPal, Square |
| External system integration | Isolates from vendor-specific APIs | EmailService → SendGrid, AWS SES, SMTP |
| Cross-cutting concerns | Consistent handling across contexts | Logger, MetricsCollector, Tracer |
| Strategy pattern candidates | Algorithm can vary at runtime | PricingStrategy, RoutingStrategy |
| Repository/data access | Database technology can change | UserRepository → SQL, MongoDB, InMemory |
| Testability requirements | Need to mock in tests | TimeProvider, RandomGenerator |
Poor Candidates for Interfaces:
| Scenario | Why Interface Adds Noise | Better Approach |
|---|---|---|
| Value objects | Single, obvious implementation | Concrete class (Money, Address, Email) |
| Entity classes | Domain model is what it is | Concrete class (User, Order, Product) |
| Utility classes | Stateless pure functions | Static methods or concrete class |
| One-off classes | No variation expected | Concrete class, extract interface later if needed |
The Litmus Test:
Ask these questions to decide:
If all answers are "no" or "probably not," a concrete class is fine.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
// ================================================// SHOULD BE INTERFACES: Multiple implementations, external deps// ================================================ // ✅ PaymentProcessor - Multiple implementations expectedinterface PaymentProcessor { processPayment(amount: Money, source: PaymentSource): Promise<PaymentResult>; refund(transactionId: string): Promise<RefundResult>;} class StripeProcessor implements PaymentProcessor { /* ... */ }class PayPalProcessor implements PaymentProcessor { /* ... */ }class SquareProcessor implements PaymentProcessor { /* ... */ } // ✅ NotificationService - External systeminterface NotificationService { send(recipient: string, message: string): Promise<void>;} class EmailNotificationService implements NotificationService { /* ... */ }class SMSNotificationService implements NotificationService { /* ... */ }class PushNotificationService implements NotificationService { /* ... */ } // ✅ UserRepository - Data access abstractioninterface UserRepository { findById(id: string): Promise<User | null>; save(user: User): Promise<void>; findByEmail(email: string): Promise<User | null>;} class PostgresUserRepository implements UserRepository { /* ... */ }class MongoUserRepository implements UserRepository { /* ... */ }class InMemoryUserRepository implements UserRepository { /* ... */ } // For tests! // ✅ TimeProvider - Testability requirementinterface TimeProvider { now(): Date; today(): Date;} class SystemTimeProvider implements TimeProvider { now(): Date { return new Date(); } today(): Date { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), now.getDate()); }} class FixedTimeProvider implements TimeProvider { constructor(private fixedTime: Date) {} now(): Date { return this.fixedTime; } today(): Date { return new Date( this.fixedTime.getFullYear(), this.fixedTime.getMonth(), this.fixedTime.getDate() ); }} // ================================================// SHOULD BE CONCRETE CLASSES: Value objects, entities// ================================================ // ✅ Money - Value object, single implementationclass Money { constructor( private readonly amount: number, private readonly currency: string ) { if (amount < 0) throw new Error("Amount cannot be negative"); } add(other: Money): Money { if (this.currency !== other.currency) { throw new Error("Cannot add different currencies"); } return new Money(this.amount + other.amount, this.currency); } getAmount(): number { return this.amount; } getCurrency(): string { return this.currency; }} // ✅ User - Entity, represents domain conceptclass User { constructor( private readonly id: string, private name: string, private email: string ) {} getId(): string { return this.id; } getName(): string { return this.name; } getEmail(): string { return this.email; } updateEmail(newEmail: string): void { // Validation logic this.email = newEmail; }} // ✅ EmailValidator - Utility, single implementationclass EmailValidator { private static readonly EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; static isValid(email: string): boolean { return this.EMAIL_REGEX.test(email); }}YAGNI (You Aren't Gonna Need It) says don't add abstraction until needed. But for the scenarios listed above—external systems, multiple implementations, testability—the need is predictable. These aren't speculative abstractions; they're proven patterns that almost always pay off.
A well-designed interface is a pleasure to use. A poorly designed interface creates friction at every interaction. The difference often comes down to following established principles.
Interface Design Principles:
1. Interface Segregation (ISP)
Clients should not be forced to depend on methods they don't use. Split large interfaces into cohesive smaller ones.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// ❌ BAD: Fat interface forces unnecessary dependenciesinterface UserService { createUser(data: CreateUserData): Promise<User>; updateUser(id: string, data: UpdateUserData): Promise<User>; deleteUser(id: string): Promise<void>; getUserById(id: string): Promise<User | null>; searchUsers(query: string): Promise<User[]>; exportUsers(format: 'csv' | 'json'): Promise<Buffer>; importUsers(data: Buffer): Promise<void>; sendPasswordReset(userId: string): Promise<void>; verifyEmail(userId: string, token: string): Promise<void>;} // A component that only reads users must depend on this entire interface! // ✅ GOOD: Segregated interfacesinterface UserReader { getUserById(id: string): Promise<User | null>; searchUsers(query: string): Promise<User[]>;} interface UserWriter { createUser(data: CreateUserData): Promise<User>; updateUser(id: string, data: UpdateUserData): Promise<User>; deleteUser(id: string): Promise<void>;} interface UserImportExport { exportUsers(format: 'csv' | 'json'): Promise<Buffer>; importUsers(data: Buffer): Promise<void>;} interface UserAuthentication { sendPasswordReset(userId: string): Promise<void>; verifyEmail(userId: string, token: string): Promise<void>;} // Components depend only on what they needclass UserProfileDisplay { constructor(private userReader: UserReader) {} // Only read access} class UserAdmin { constructor( private userReader: UserReader, private userWriter: UserWriter ) {} // Read + Write}2. Abstraction at the Right Level
Interface methods should express what is needed, not how it's done:
12345678910111213141516171819202122232425262728293031323334353637383940
// ❌ BAD: Too low-level, exposes implementation detailsinterface MessageQueue { connectToRabbitMQ(host: string, port: number): Promise<void>; createExchange(name: string, type: 'direct' | 'fanout'): Promise<void>; bindQueueToExchange(queue: string, exchange: string, routingKey: string): Promise<void>; publishToExchange(exchange: string, routingKey: string, message: Buffer): Promise<void>; consumeFromQueue(queue: string, callback: (msg: Buffer) => void): Promise<void>;} // This interface is specific to RabbitMQ!// Can't substitute Kafka, SQS, or in-memory queue without changing API. // ✅ GOOD: Right abstraction levelinterface MessagePublisher { publish(topic: string, message: object): Promise<void>;} interface MessageSubscriber { subscribe(topic: string, handler: (message: object) => Promise<void>): void; unsubscribe(topic: string): void;} // Implementation details hidden:class RabbitMQPublisher implements MessagePublisher { async publish(topic: string, message: object): Promise<void> { // RabbitMQ-specific: connect, create exchange, publish }} class KafkaPublisher implements MessagePublisher { async publish(topic: string, message: object): Promise<void> { // Kafka-specific: produce to topic }} class InMemoryPublisher implements MessagePublisher { async publish(topic: string, message: object): Promise<void> { // For testing: store in memory }}3. Cohesive Method Signatures
Methods should have clear, focused purposes:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// ❌ BAD: Boolean flags indicate multiple responsibilitiesinterface OrderProcessor { processOrder( order: Order, sendEmail: boolean, updateInventory: boolean, generateInvoice: boolean ): Promise<ProcessResult>;} // Caller must know about all these side effects// Hard to understand what happens with each flag combination // ✅ GOOD: Single-responsibility methodsinterface OrderProcessor { processOrder(order: Order): Promise<ProcessResult>;} interface NotificationService { sendOrderConfirmation(order: Order): Promise<void>;} interface InventoryService { reserveItems(items: OrderItem[]): Promise<void>;} interface InvoiceService { generateInvoice(order: Order): Promise<Invoice>;} // Orchestrator combines them explicitly:class OrderWorkflow { constructor( private orderProcessor: OrderProcessor, private notifications: NotificationService, private inventory: InventoryService, private invoices: InvoiceService ) {} async completeOrder(order: Order): Promise<void> { const result = await this.orderProcessor.processOrder(order); // Each step is explicit and optional await this.inventory.reserveItems(order.items); await this.notifications.sendOrderConfirmation(order); const invoice = await this.invoices.generateInvoice(order); // ... }}When designing an interface, imagine writing code that uses it BEFORE implementing it. If the API feels awkward or requires the caller to understand implementation details, redesign. An interface should make the consumer's code clean and obvious.
Interface-first isn't just a principle—it's a workflow. Here's the systematic process for applying it:
The Six-Step Workflow:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
// ================================================// STEP 1: Identify Responsibilities// ================================================ // Scenario: We need a component to manage parking tickets// Responsibilities:// - Issue a new ticket when a vehicle parks// - Calculate fees when vehicle leaves// - Process payment for the ticket// - Track active tickets // ================================================// STEP 2: Define the Interface// ================================================ interface ParkingTicketService { issueTicket(vehicle: Vehicle, spot: ParkingSpot): Ticket; calculateFee(ticket: Ticket): Money; completeTicket(ticket: Ticket, payment: Payment): TicketReceipt; getActiveTickets(): Ticket[]; findTicketByVehicle(licensePlate: string): Ticket | null;} // Also define related interfaces for dependenciesinterface PricingStrategy { calculatePrice(duration: Duration, vehicleType: VehicleType): Money;} // ================================================// STEP 3: Write Consumer Code (before implementing!)// ================================================ class ParkingLotController { constructor( private ticketService: ParkingTicketService, private spotFinder: SpotFinder, private paymentProcessor: PaymentProcessor ) {} async parkVehicle(vehicle: Vehicle): Promise<Ticket> { // Using interface before it exists - validates API design const spot = await this.spotFinder.findAvailableSpot(vehicle.getType()); if (!spot) { throw new Error("No available spots"); } return this.ticketService.issueTicket(vehicle, spot); // Does this feel natural? Is the return type right? } async exitVehicle(licensePlate: string): Promise<TicketReceipt> { const ticket = this.ticketService.findTicketByVehicle(licensePlate); if (!ticket) { throw new Error("No active ticket for vehicle"); } const fee = this.ticketService.calculateFee(ticket); // Hmm, we need to collect payment first... const payment = await this.paymentProcessor.collectPayment(fee); return this.ticketService.completeTicket(ticket, payment); // This flow feels right }} // ================================================// STEP 4: Review and Refine// ================================================ // After using the interface, we realize:// 1. We need to mark spots as occupied/available// 2. Ticket should know its spot for spot release // Refined interface:interface ParkingTicketServiceV2 { issueTicket(vehicle: Vehicle, spot: ParkingSpot): Ticket; calculateFee(ticketId: string): Money; // Use ID instead of object completeTicket(ticketId: string, payment: Payment): TicketReceipt; getActiveTickets(): Ticket[]; findActiveTicketByVehicle(licensePlate: string): Ticket | null; cancelTicket(ticketId: string): void; // Added for edge cases} // ================================================// STEP 5: Implement// ================================================ class DefaultParkingTicketService implements ParkingTicketServiceV2 { private tickets: Map<string, Ticket> = new Map(); private activeByVehicle: Map<string, Ticket> = new Map(); constructor(private pricingStrategy: PricingStrategy) {} issueTicket(vehicle: Vehicle, spot: ParkingSpot): Ticket { const ticket = new Ticket( this.generateId(), vehicle, spot, new Date() ); this.tickets.set(ticket.getId(), ticket); this.activeByVehicle.set(vehicle.getLicensePlate(), ticket); spot.occupy(); return ticket; } calculateFee(ticketId: string): Money { const ticket = this.getTicketOrThrow(ticketId); const duration = this.calculateDuration(ticket.getEntryTime(), new Date()); return this.pricingStrategy.calculatePrice( duration, ticket.getVehicle().getType() ); } completeTicket(ticketId: string, payment: Payment): TicketReceipt { const ticket = this.getTicketOrThrow(ticketId); const fee = this.calculateFee(ticketId); if (payment.getAmount().isLessThan(fee)) { throw new Error("Insufficient payment"); } ticket.complete(); ticket.getSpot().vacate(); this.activeByVehicle.delete(ticket.getVehicle().getLicensePlate()); return new TicketReceipt(ticket, payment, fee); } getActiveTickets(): Ticket[] { return Array.from(this.activeByVehicle.values()); } findActiveTicketByVehicle(licensePlate: string): Ticket | null { return this.activeByVehicle.get(licensePlate) || null; } cancelTicket(ticketId: string): void { const ticket = this.getTicketOrThrow(ticketId); ticket.cancel(); ticket.getSpot().vacate(); this.activeByVehicle.delete(ticket.getVehicle().getLicensePlate()); } private getTicketOrThrow(ticketId: string): Ticket { const ticket = this.tickets.get(ticketId); if (!ticket) throw new Error(`Ticket not found: ${ticketId}`); return ticket; } private generateId(): string { return `TKT-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } private calculateDuration(start: Date, end: Date): Duration { const ms = end.getTime() - start.getTime(); return Duration.ofMinutes(Math.ceil(ms / 60000)); }} // ================================================// STEP 6: Test// ================================================ describe('DefaultParkingTicketService', () => { let service: ParkingTicketServiceV2; let mockPricing: PricingStrategy; beforeEach(() => { mockPricing = { calculatePrice: jest.fn().mockReturnValue(new Money(10, 'USD')) }; service = new DefaultParkingTicketService(mockPricing); }); it('should issue ticket and mark spot as occupied', () => { const vehicle = new Car('ABC-123'); const spot = new CompactSpot('A1'); const ticket = service.issueTicket(vehicle, spot); expect(ticket.getVehicle()).toBe(vehicle); expect(ticket.getSpot()).toBe(spot); expect(spot.isOccupied()).toBe(true); }); it('should calculate fee using pricing strategy', () => { const vehicle = new Car('ABC-123'); const spot = new CompactSpot('A1'); const ticket = service.issueTicket(vehicle, spot); const fee = service.calculateFee(ticket.getId()); expect(mockPricing.calculatePrice).toHaveBeenCalled(); expect(fee.getAmount()).toBe(10); }); // More tests...});One of the most powerful benefits of interface-first design is testability. When components depend on interfaces rather than concrete classes, substituting test doubles becomes trivial.
The Testing Advantage:
Without interfaces, testing requires:
With interfaces, testing becomes:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
// ================================================// Without Interfaces: Hard to Test// ================================================ // ❌ Direct dependency on concrete classclass OrderService { private stripeClient = new StripeClient(process.env.STRIPE_KEY!); private emailClient = new SendGridClient(process.env.SENDGRID_KEY!); async placeOrder(order: Order): Promise<OrderResult> { // Charges real credit card! const payment = await this.stripeClient.charge(order.total); // Sends real email! await this.emailClient.send(order.customerEmail, 'Order Confirmation', '...'); return { success: true, paymentId: payment.id }; }} // How do you test this?// - Need real Stripe API key// - Will charge real money// - Will send real emails// - Tests are slow and flaky // ================================================// With Interfaces: Easily Testable// ================================================ // ✅ Depend on interfacesinterface PaymentGateway { charge(amount: Money): Promise<PaymentResult>;} interface EmailSender { send(to: string, subject: string, body: string): Promise<void>;} class OrderServiceV2 { constructor( private paymentGateway: PaymentGateway, private emailSender: EmailSender ) {} async placeOrder(order: Order): Promise<OrderResult> { const payment = await this.paymentGateway.charge(order.total); await this.emailSender.send(order.customerEmail, 'Order Confirmation', '...'); return { success: true, paymentId: payment.id }; }} // ================================================// Test with Mocks/Stubs// ================================================ describe('OrderServiceV2', () => { // Create test doubles const mockPaymentGateway: PaymentGateway = { charge: jest.fn().mockResolvedValue({ success: true, id: 'test_payment_123' }) }; const mockEmailSender: EmailSender = { send: jest.fn().mockResolvedValue(undefined) }; let service: OrderServiceV2; beforeEach(() => { jest.clearAllMocks(); service = new OrderServiceV2(mockPaymentGateway, mockEmailSender); }); it('should charge payment and send confirmation email', async () => { const order = new Order('cust_1', [new OrderItem('SKU-1', 2)]); const result = await service.placeOrder(order); expect(result.success).toBe(true); expect(mockPaymentGateway.charge).toHaveBeenCalledWith(order.total); expect(mockEmailSender.send).toHaveBeenCalledWith( order.customerEmail, 'Order Confirmation', expect.any(String) ); }); it('should handle payment failure', async () => { // Easy to simulate failure (mockPaymentGateway.charge as jest.Mock).mockRejectedValue( new Error('Card declined') ); const order = new Order('cust_1', [new OrderItem('SKU-1', 2)]); await expect(service.placeOrder(order)).rejects.toThrow('Card declined'); // Email should NOT be sent if payment fails expect(mockEmailSender.send).not.toHaveBeenCalled(); }); it('should continue if email fails (non-critical)', async () => { (mockEmailSender.send as jest.Mock).mockRejectedValue( new Error('Email service down') ); const order = new Order('cust_1', [new OrderItem('SKU-1', 2)]); // Order should still succeed const result = await service.placeOrder(order); expect(result.success).toBe(true); });}); // ================================================// Special Test Doubles// ================================================ // In-memory implementation for integration testsclass InMemoryPaymentGateway implements PaymentGateway { private payments: Payment[] = []; async charge(amount: Money): Promise<PaymentResult> { const payment = { id: `test_${Date.now()}`, amount, status: 'succeeded' }; this.payments.push(payment); return { success: true, id: payment.id }; } // Test helper methods getPayments(): Payment[] { return this.payments; } clear(): void { this.payments = []; }} // Time-controllable implementationclass TestableTimeProvider implements TimeProvider { private currentTime: Date; constructor(initialTime: Date = new Date()) { this.currentTime = initialTime; } now(): Date { return new Date(this.currentTime); } // Test helpers advanceMinutes(minutes: number): void { this.currentTime = new Date(this.currentTime.getTime() + minutes * 60000); } advanceHours(hours: number): void { this.advanceMinutes(hours * 60); }}Interface-first design enables dependency injection, which enables testability. These concepts form a virtuous triangle: interfaces define contracts, DI wires them together, and tests substitute mock implementations. Master this triangle and your code becomes dramatically easier to maintain and evolve.
In LLD interviews, interface-first thinking provides significant advantages:
The Interview-Optimized Approach:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
// ================================================// Interview Scenario: Design a Ride-Sharing Service// ================================================ // STEP 1: Define core interfaces (spend 2-3 minutes here)// Say: "Let me first define the key interfaces so the design is clear" interface RideMatchingService { findNearbyDrivers(location: Location, radiusKm: number): Promise<Driver[]>; selectBestDriver(drivers: Driver[], rideRequest: RideRequest): Driver;} interface PricingService { estimatePrice(origin: Location, destination: Location): Money; calculateFinalPrice(ride: CompletedRide): Money;} interface NotificationService { notifyDriver(driverId: string, notification: RideNotification): Promise<void>; notifyRider(riderId: string, notification: RideNotification): Promise<void>;} interface RideRepository { save(ride: Ride): Promise<void>; findById(rideId: string): Promise<Ride | null>; findActiveByRider(riderId: string): Promise<Ride | null>; findActiveByDriver(driverId: string): Promise<Ride | null>;} // STEP 2: Define the main orchestrator interfaceinterface RideService { requestRide(request: RideRequest): Promise<Ride>; acceptRide(rideId: string, driverId: string): Promise<Ride>; startRide(rideId: string): Promise<Ride>; completeRide(rideId: string): Promise<Ride>; cancelRide(rideId: string, reason: string): Promise<Ride>;} // Say: "These interfaces define the contracts. Now I'll implement the core flow." // STEP 3: Implement only what's criticalclass DefaultRideService implements RideService { constructor( private matching: RideMatchingService, private pricing: PricingService, private notifications: NotificationService, private rides: RideRepository ) {} async requestRide(request: RideRequest): Promise<Ride> { // Estimate price const estimatedPrice = this.pricing.estimatePrice( request.pickup, request.destination ); // Find nearby drivers const drivers = await this.matching.findNearbyDrivers( request.pickup, 5 // 5km radius ); if (drivers.length === 0) { throw new Error("No drivers available"); } // Select best driver const driver = this.matching.selectBestDriver(drivers, request); // Create ride const ride = new Ride( this.generateId(), request.riderId, driver.getId(), request.pickup, request.destination, estimatedPrice, RideStatus.REQUESTED ); await this.rides.save(ride); // Notify driver await this.notifications.notifyDriver(driver.getId(), { type: 'RIDE_REQUEST', rideId: ride.getId(), pickup: request.pickup }); return ride; } async acceptRide(rideId: string, driverId: string): Promise<Ride> { const ride = await this.getRideOrThrow(rideId); if (ride.getDriverId() !== driverId) { throw new Error("Unauthorized"); } ride.accept(); await this.rides.save(ride); await this.notifications.notifyRider(ride.getRiderId(), { type: 'RIDE_ACCEPTED', rideId: rideId, driverName: "..." // Would fetch from driver service }); return ride; } // Say: "startRide and completeRide follow similar patterns. // I'll implement them the same way if time permits." async startRide(rideId: string): Promise<Ride> { const ride = await this.getRideOrThrow(rideId); ride.start(); await this.rides.save(ride); return ride; } async completeRide(rideId: string): Promise<Ride> { const ride = await this.getRideOrThrow(rideId); // Calculate final price (might differ from estimate) const finalPrice = this.pricing.calculateFinalPrice({ origin: ride.getPickup(), destination: ride.getDestination(), startTime: ride.getStartTime()!, endTime: new Date() }); ride.complete(finalPrice); await this.rides.save(ride); // Notify both parties await Promise.all([ this.notifications.notifyRider(ride.getRiderId(), { type: 'RIDE_COMPLETED', rideId, finalPrice }), this.notifications.notifyDriver(ride.getDriverId(), { type: 'RIDE_COMPLETED', rideId, earnings: finalPrice // Simplified }) ]); return ride; } async cancelRide(rideId: string, reason: string): Promise<Ride> { // Say: "Cancel follows the same pattern with appropriate state transitions" const ride = await this.getRideOrThrow(rideId); ride.cancel(reason); await this.rides.save(ride); return ride; } private async getRideOrThrow(rideId: string): Promise<Ride> { const ride = await this.rides.findById(rideId); if (!ride) throw new Error(`Ride not found: ${rideId}`); return ride; } private generateId(): string { return `RIDE-${Date.now()}`; }} // Say: "The other services (RideMatchingService, PricingService, etc.)// would be implemented following the same interface-first pattern.// For RideMatchingService, I'd use a spatial index for efficient // nearby-driver queries..."In a 45-minute LLD interview, spend 2-3 minutes defining core interfaces before implementation. This investment pays off by clarifying your design, enabling focused implementation, and demonstrating architectural maturity to the interviewer.
We've explored the interface-first philosophy, its benefits, and practical application. Here are the key principles:
You now understand why interface-first development produces better code and how to apply it in both production and interview settings. The key insight is that interfaces aren't extra work—they're the design itself, made explicit. Next, we'll explore incremental implementation: building systems piece by piece with continuous validation.
What's next:
The final page in this module covers Incremental Implementation—the practice of building working systems piece by piece, validating at each step, and maintaining a shippable product throughout development.