Abstraction & Interfaces
When one part of a system depends on another, the question is always: how tightly are they
coupled? If OrderService calls StripeAPI directly, it knows the name of every Stripe method,
every parameter, every exception type. Add PayPal later and you rewrite OrderService. Add a
test mock and you need to patch Stripe. The class has too many reasons to change.
Abstraction solves this by introducing a contract between the two sides. Instead of depending on
StripeAPI, OrderService depends on a PaymentMethod interface — a minimal description of
what a payment provider must be able to do. StripeAPI, PayPalClient, and a test FakePayment
all implement that interface. OrderService never changes when a new provider is added.
The diagram below shows how OrderService depends on the interface, not on any concrete provider.
The payment method example
Consider an order checkout flow that needs to charge a customer. The naive implementation reaches directly into whichever payment library the company started with.
1# Bad: OrderService is hard-wired to Stripe.
2# Adding PayPal means modifying OrderService.
3# Unit-testing OrderService means making real Stripe network calls.
4class StripeAPI:
5 def charge_card(self, card_token: str, amount_cents: int) -> str:
6 # calls stripe.com/v1/charges ...
7 return "ch_stripe_abc123"
8
9class OrderService:
10 def __init__(self) -> None:
11 self._stripe = StripeAPI() # hard dependency
12
13 def checkout(self, order: "Order", card_token: str) -> str:
14 charge_id = self._stripe.charge_card(card_token, order.total_cents())
15 order.mark_paid(charge_id)
16 return charge_id
1from abc import ABC, abstractmethod
2
3# Good: OrderService depends on the PaymentMethod interface, not any concrete provider.
4class PaymentMethod(ABC):
5 @abstractmethod
6 def charge(self, amount_cents: int, reference: str) -> str:
7 """Charge the customer and return a provider transaction id."""
8
9class StripePayment(PaymentMethod):
10 def charge(self, amount_cents: int, reference: str) -> str:
11 # calls stripe.com/v1/charges with amount_cents and reference
12 return f"ch_stripe_{reference}"
13
14class PayPalPayment(PaymentMethod):
15 def charge(self, amount_cents: int, reference: str) -> str:
16 # calls PayPal Orders API
17 return f"paypal_{reference}"
18
19class FakePayment(PaymentMethod):
20 """In-memory stub for unit tests — no network required."""
21 def __init__(self) -> None:
22 self.charged: list[tuple[int, str]] = []
23
24 def charge(self, amount_cents: int, reference: str) -> str:
25 self.charged.append((amount_cents, reference))
26 return f"fake_{reference}"
27
28class OrderService:
29 def __init__(self, payment: PaymentMethod) -> None:
30 self._payment = payment # depends on the interface, not a concrete class
31
32 def checkout(self, order: "Order") -> str:
33 charge_id = self._payment.charge(order.total_cents(), order.id)
34 order.mark_paid(charge_id)
35 return charge_id
Structure of the abstraction
The relationship between OrderService and the payment providers now looks like this:
OrderService points at PaymentMethod, not at any provider. Adding a new provider is a new
file that implements the interface — OrderService is untouched.
What the interface should contain
A good interface contains only what callers actually need. The PaymentMethod interface here
has one method: charge. It does not expose refund, createCustomer, or any other provider
capability — those are either not needed by OrderService or they belong on a different
interface. An interface that grows without bound becomes as tightly coupled as a concrete class.
The right question when defining an interface is: what does the caller need, not what can the
provider do? Starting from the calling side and working backward, OrderService needs to charge
a customer and get a transaction id — that is exactly one method.
Interface vs abstract class
Both interfaces and abstract classes define a contract, but they differ in what they carry. An
interface specifies only method signatures — no fields, no shared implementation. Any class can
implement an interface regardless of where it sits in the class hierarchy, and a single class can
implement many interfaces at once. PaymentMethod in this article is a capability contract:
every class that implements it promises to provide charge. StripePayment, PayPalPayment,
and FakePayment are otherwise unrelated; the only thing they share is the promise.
An abstract class can carry shared state and partial implementation. A base class
AbstractPaymentMethod might hold a shared _logger field, or implement a charge_with_retry
method that calls the abstract charge method three times before giving up. Subclasses inherit
that behavior without re-implementing it. The tradeoff is that most languages allow a class to
extend only one abstract class, so the hierarchy slot is spent.
The decision comes down to whether the relationship is "can do" or "is a kind of." Use an interface when multiple unrelated types all need to provide the same capability — payment providers, notification channels, storage backends. Use an abstract class when subclasses share real code or state, and the inheritance relationship reflects a genuine "is-a" where some behavior is always the same. In practice, a common pattern combines both: the interface defines the public contract, and an optional abstract class provides a partial implementation that concrete subclasses can extend if they share behavior.
In Python the distinction is softer because ABC with @abstractmethod serves as an abstract
class, and a plain Protocol (structural typing) serves as an interface. In Java and TypeScript
the split is explicit: interface vs abstract class.
Interview framing
In an LLD interview, the moment you hear "the system needs to support multiple X" — multiple payment providers, multiple notification channels, multiple storage backends — that is the signal to introduce an interface. Define the abstraction from the caller's perspective, keep it minimal, and inject the concrete implementation rather than creating it inside the class that uses it.
Interviewers also look for whether you can distinguish a good abstraction boundary from a leaky
one. If the PaymentMethod interface exposed a chargeCard(card_token) method, it would be
leaking Stripe's model — PayPal does not use card tokens in the same way. The correct interface
is payment-provider-agnostic: a reference and an amount. That is the kind of judgment the
question is probing.