Interface segregation and dependency inversion

Part of the free Low-Level Design course on LogicWiz, in the chapter: SOLID, without the slogans. The class-diagram exercises run in the app.

Week 4, Day 3: Testing checkout without a real SMS

"To test checkout, I send a real SMS to my own phone and charge my own UPI one rupee."

Arjun, explaining to Priya why checkout has no tests


A fat interface forces fake work

SwiftKart started with bike riders. The rider code grew one interface for everything a delivery partner does.

public interface DeliveryPartner {
    void pickUp(Order order);
    void deliver(Order order);
    long collectCash(Order order);
    void uploadDrivingLicence(byte[] scan);
    double rating();
}

Now SwiftKart adds pickup lockers: smart lockers at apartment gates where customers collect their orders. A locker can take an order and hand it over. It cannot collect cash, and it has no driving licence. So PickupLocker throws from three methods. You saw last lesson why that is a Liskov problem.

There is a second cost. The live tracking screen only needs deliver. But it depends on the whole interface, so a change to licence upload touches code that tracking compiles against.

The interface segregation principle (ISP) says no client should be forced to depend on methods it does not use. A client here means the code that calls the interface. So split the interface by what each caller needs.

public interface Deliverer {
    void pickUp(Order order);
    void deliver(Order order);
}

public interface CashCollector {
    long collectCash(Order order);
}

public interface LicensedDriver {
    void uploadDrivingLicence(byte[] scan);
}

// BikeRider implements Deliverer, CashCollector, LicensedDriver
// PickupLocker implements Deliverer only

Arjun asks, "So every interface should have one method?" No. Group the methods that one caller uses together. pickUp and deliver are always used as a pair, so they stay in one interface.

Small interfaces also help the reader. A method that takes a CashCollector tells you exactly what it will do with that object: collect cash, and nothing else.

Who depends on whom

Now the checkout code that cannot be tested.

public class CheckoutService {
    private final RazorpayClient payments = new RazorpayClient(Config.razorpayKey());
    private final MySqlOrderStore orders = new MySqlOrderStore(Config.dbUrl());
    private final TwilioSms sms = new TwilioSms(Config.twilioKey());

    public Order checkout(Cart cart) {
        payments.charge(cart.userId(), cart.totalPaise());
        Order order = orders.insert(cart);
        sms.send(cart.phone(), "Order " + order.id() + " placed");
        return order;
    }
}

Checkout holds the business rules. That makes it a high-level module: the code that says what the business does. The payment SDK, the database driver and the SMS vendor are low-level modules: the code that says how. Here the high-level code points straight at the low-level code. Changing the SMS vendor means editing checkout. Testing checkout means real money and real messages.

The dependency inversion principle (DIP) has two parts:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details should depend on abstractions.

The "inversion" is about who owns the interface. Checkout owns it and names it in its own words: PaymentGateway, OrderRepository, Notifier. The vendor code implements it. The arrows now point from the details up to checkout's interfaces.

Arjun asks, "Is this the same as 'program to an interface'?" Close, but ownership matters. Imagine an interface that copies the Razorpay SDK method by method: createRzpOrder, captureRzpPayment. Checkout depends on an interface, yet it still thinks in Razorpay's terms. Switching to PayU would change the interface and checkout with it. Name the interface for what checkout needs, and keep it in the checkout package, not the vendor's.

classDiagram
  class CheckoutService
  class PaymentGateway {
    <<interface>>
    +charge(userId, amountPaise) Receipt
  }
  class OrderRepository {
    <<interface>>
    +save(order) Order
  }
  class Notifier {
    <<interface>>
    +orderPlaced(order) void
  }
  CheckoutService --> PaymentGateway
  CheckoutService --> OrderRepository
  CheckoutService --> Notifier
  PaymentGateway <|.. RazorpayGateway
  OrderRepository <|.. MySqlOrderRepository
  Notifier <|.. SmsNotifier

Constructor injection: pass the parts in

If checkout must not call new RazorpayClient(), who creates the parts? Someone outside hands them in.

public class CheckoutService {
    private final PaymentGateway payments;
    private final OrderRepository orders;
    private final Notifier notifier;

    public CheckoutService(PaymentGateway payments, OrderRepository orders, Notifier notifier) {
        this.payments = payments;
        this.orders = orders;
        this.notifier = notifier;
    }

    public Order checkout(Cart cart) {
        Receipt receipt = payments.charge(cart.userId(), cart.totalPaise());
        Order order = orders.save(Order.paid(cart, receipt));
        notifier.orderPlaced(order);
        return order;
    }
}

This is dependency injection (DI): an object receives the things it needs from outside instead of creating them. Constructor injection passes them through the constructor. The fields can be final, and the object can never exist half-built. In main, you build the real parts once and pass them in. Setter injection, where the parts arrive through setter methods after construction, also exists. It allows an object to be used before its parts are set, so prefer the constructor.

DIP and DI are different ideas, and interviewers like to check this.

  • DIP is about the direction of dependencies: business code depends on interfaces it owns.
  • DI is about how an object gets its collaborators: they are passed in.

You can inject a concrete MySqlOrderStore through the constructor. That is DI without DIP. Frameworks such as Spring do DI for you. In a machine-coding round you do not need one; wiring by hand in main is clear and graders can read it.

Why this makes code testable

With interfaces in the constructor, a test can pass simple fakes. A fake is a small working stand-in, such as a notifier that records messages instead of sending them.

class FakeNotifier implements Notifier {
    final List<Order> sent = new ArrayList<>();

    public void orderPlaced(Order order) {
        sent.add(order);
    }
}

class CheckoutTest {
    public static void main(String[] args) {
        FakeNotifier notifier = new FakeNotifier();
        CheckoutService checkout = new CheckoutService(
                new AlwaysApprovesGateway(), new InMemoryOrderRepository(), notifier);

        Order order = checkout.checkout(TestCarts.milkAndBread());

        if (!notifier.sent.equals(List.of(order))) {
            throw new AssertionError("checkout did not notify the customer");
        }
    }
}

This runs in milliseconds, with no network and no money. InMemoryOrderRepository keeps orders in a HashMap. It is the same class you will write in a 90-minute machine-coding round, where there is no real database.

Interviewers often ask, "How would you test this?" A strong answer points at the constructor: every outside dependency comes in through it, so a test can replace each one with a fake.

💡 Tip: Time is a dependency too. Inject a java.time.Clock into slot booking code, and a test can check "no slots after 11 pm" without waiting for 11 pm.

Do not add an interface for everything. Value classes such as Money or Address do not need one. Interfaces pay off at the boundaries: code that talks to the outside world (payments, SMS, the database, the clock) and code that varies. Watch for an interface with exactly one implementation, no fake in any test, and no second version planned. It adds a file and teaches nothing. Remove it, and add it back when a second implementation appears.

📌 Summary: ISP: shape interfaces by what each caller uses. DIP: business code owns the interfaces and vendors implement them. DI passes the parts in through the constructor, so tests can pass fakes.

What Arjun learns next

Checkout no longer calls new. But somewhere, code still decides which notifier to build for each customer: SMS, email or push. Next week, Arjun puts that decision in one place with a factory.

Check your understanding

  1. PickupLocker implements DeliveryPartner and throws from collectCash and uploadDrivingLicence. What is the best fix?
  2. Which statement about dependency inversion (DIP) and dependency injection (DI) is accurate?
  3. CheckoutService has the constructor CheckoutService(MySqlOrderStore orders). Which statement is true?
  4. Arjun wants to test the rule "no delivery slots can be booked after 11 pm". What should the slot service take in its constructor?