LLD Series: OOP and SOLID (Payments Flavored)
Overview
- 1. The four OOP pillars
- 2. SOLID
- 3. Composition vs inheritance
- 4. DRY, KISS, YAGNI
- 5. Law of Demeter (principle of least knowledge)
- 6. Cohesion and coupling
- 7. Quick smells -> fixes
Part of the Interview Prep series. Dotted-underline words are glossary terms: hover for the meaning or open the Glossary. Track your progress on the Interview Prep tracker. Series index: Start here.
All snippets are Java 11, compiled and run. "Bad" versions compile too; they are bad design, not bad syntax.
1. The four OOP pillars
| Pillar | One-liner | Java tools | Payments example |
|---|---|---|---|
| Encapsulation | Hide state, expose behavior; protect invariants | private, immutable classes, no raw setters | Money can't be negative; Wallet.balance changed only via debit/credit |
| Abstraction | Expose what, hide how | interfaces, abstract classes | PaymentGateway.charge() hides Stripe vs Razorpay |
| Inheritance | is-a reuse of state/behavior | extends | CreditCard extends Card (use sparingly) |
| Polymorphism | Same call, different behavior | overriding, interfaces (subtype); overloading, generics (compile-time/parametric) | method.pay() works for UPI/Card/Wallet |
// File: OopPillars.java
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
// Encapsulation: immutable value object, invariant enforced in constructor
final class Money {
private final BigDecimal amount; private final String currency;
Money(BigDecimal amount, String currency) {
if (amount.signum() < 0) throw new IllegalArgumentException("negative");
this.amount = amount; this.currency = Objects.requireNonNull(currency);
}
Money plus(Money o) {
if (!currency.equals(o.currency)) throw new IllegalArgumentException("currency mismatch");
return new Money(amount.add(o.amount), currency);
}
@Override public boolean equals(Object o) {
if (!(o instanceof Money)) return false;
Money m = (Money) o; return amount.compareTo(m.amount) == 0 && currency.equals(m.currency);
}
@Override public int hashCode() { return Objects.hash(amount.stripTrailingZeros(), currency); }
@Override public String toString() { return amount + " " + currency; }
}
// Abstraction + Inheritance + Polymorphism
abstract class PaymentMethod {
protected final String id;
PaymentMethod(String id) { this.id = id; }
abstract String pay(Money m); // abstraction: contract
String describe() { return getClass().getSimpleName() + "#" + id; } // inherited behavior
}
class UpiMethod extends PaymentMethod {
UpiMethod(String id) { super(id); }
String pay(Money m) { return "UPI " + m; }
}
class CardMethod extends PaymentMethod {
CardMethod(String id) { super(id); }
String pay(Money m) { return "CARD " + m; }
@Override String describe() { return super.describe() + "(****)"; } // override + extend
}
public class OopPillars {
public static void main(String[] args) {
Money total = new Money(new BigDecimal("10.50"), "INR").plus(new Money(new BigDecimal("4.50"), "INR"));
List<PaymentMethod> ms = Arrays.asList(new UpiMethod("u1"), new CardMethod("c1"));
for (PaymentMethod p : ms) System.out.println(p.describe() + " -> " + p.pay(total)); // dynamic dispatch
System.out.println(new Money(new BigDecimal("1.0"), "INR").equals(new Money(new BigDecimal("1.00"), "INR")));
}
}
Abstract class vs interface: interface = capability/contract (multiple, default methods for evolution); abstract class = shared state/partial implementation (single). Default: start with an interface.
2. SOLID
S: Single Responsibility Principle
A class should have one reason to change (one actor/stakeholder).
// File: SrpDemo.java
// BAD: validation rules, charging, persistence, and email in one class -> 4 reasons to change
class BadPaymentService {
void process(String card, long paise) {
if (paise <= 0) throw new IllegalArgumentException();
System.out.println("calling gateway");
System.out.println("INSERT INTO payments ...");
System.out.println("sending email");
}
}
// GOOD: each collaborator owns one concern; orchestrator only coordinates
interface Validator { void validate(long paise); }
interface Gateway { String charge(String card, long paise); }
interface PaymentStore { void save(String ref, long paise); }
interface Notifier { void notifyUser(String msg); }
class PaymentService {
private final Validator v; private final Gateway g; private final PaymentStore s; private final Notifier n;
PaymentService(Validator v, Gateway g, PaymentStore s, Notifier n) { this.v = v; this.g = g; this.s = s; this.n = n; }
void process(String card, long paise) {
v.validate(paise);
String ref = g.charge(card, paise);
s.save(ref, paise);
n.notifyUser("paid " + ref);
}
}
public class SrpDemo {
public static void main(String[] args) {
new BadPaymentService().process("4111", 100);
PaymentService svc = new PaymentService(
p -> { if (p <= 0) throw new IllegalArgumentException(); },
(c, p) -> "ref-" + p,
(r, p) -> System.out.println("saved " + r),
System.out::println);
svc.process("4111", 100);
}
}
O: Open/Closed Principle
Open for extension, closed for modification: add behavior by adding code, not editing tested code.
// File: OcpDemo.java
import java.util.ArrayList;
import java.util.List;
// BAD: every new method forces an edit here (and a re-test of everything)
class BadFeeCalculator {
long fee(String type, long amt) {
if (type.equals("CARD")) return amt * 2 / 100;
else if (type.equals("UPI")) return 0;
else if (type.equals("NETBANKING")) return 10;
throw new IllegalArgumentException(type);
}
}
// GOOD: new payment type = new class
interface FeePolicy { boolean supports(String type); long fee(long amt); }
class CardFee implements FeePolicy { public boolean supports(String t) { return t.equals("CARD"); } public long fee(long a) { return a * 2 / 100; } }
class UpiFee implements FeePolicy { public boolean supports(String t) { return t.equals("UPI"); } public long fee(long a) { return 0; } }
class WalletFee implements FeePolicy { public boolean supports(String t) { return t.equals("WALLET"); } public long fee(long a) { return 1; } } // added later, nothing else touched
class FeeCalculator {
private final List<FeePolicy> policies = new ArrayList<>();
FeeCalculator register(FeePolicy p) { policies.add(p); return this; }
long fee(String type, long amt) {
return policies.stream().filter(p -> p.supports(type)).findFirst()
.orElseThrow(() -> new IllegalArgumentException(type)).fee(amt);
}
}
public class OcpDemo {
public static void main(String[] args) {
System.out.println(new BadFeeCalculator().fee("CARD", 1000));
FeeCalculator c = new FeeCalculator().register(new CardFee()).register(new UpiFee()).register(new WalletFee());
System.out.println(c.fee("CARD", 1000) + " " + c.fee("UPI", 1000) + " " + c.fee("WALLET", 1000));
}
}
L: Liskov Substitution Principle
Subtypes must be usable wherever the base type is expected without surprising callers: don't strengthen preconditions, weaken postconditions, or throw unexpected exceptions. Smell: throw new UnsupportedOperationException() in an override; instanceof checks in client code.
// File: LspDemo.java
import java.util.Arrays;
import java.util.List;
// BAD: FixedDeposit "is-a" Account but can't withdraw -> breaks callers of withdraw()
class BadAccount { long bal = 100; void withdraw(long a) { bal -= a; } }
class BadFixedDeposit extends BadAccount {
@Override void withdraw(long a) { throw new UnsupportedOperationException("locked until maturity"); }
}
// GOOD: model the capability split
interface Account { long balance(); }
interface Withdrawable extends Account { void withdraw(long amt); }
class Savings implements Withdrawable {
private long bal = 100;
public long balance() { return bal; }
public void withdraw(long amt) { if (amt > bal) throw new IllegalStateException("insufficient"); bal -= amt; }
}
class FixedDeposit implements Account {
public long balance() { return 500; }
}
public class LspDemo {
static void sweep(List<? extends Withdrawable> accts) { for (Withdrawable w : accts) w.withdraw(10); } // can't be handed an FD
public static void main(String[] args) {
try { new BadFixedDeposit().withdraw(10); } catch (UnsupportedOperationException e) { System.out.println("BAD: " + e.getMessage()); }
Savings s = new Savings();
sweep(Arrays.asList(s));
System.out.println(s.balance() + " " + new FixedDeposit().balance());
}
}
I: Interface Segregation Principle
Clients should not depend on methods they don't use. Prefer several small role interfaces.
// File: IspDemo.java
// BAD: fat interface; UPI cannot do these, forced to stub
interface BadPaymentMethod {
void pay(long amt); void refund(long amt); void autoDebit(long amt); String tokenize();
}
class BadUpi implements BadPaymentMethod {
public void pay(long a) { System.out.println("upi pay"); }
public void refund(long a) { System.out.println("upi refund"); }
public void autoDebit(long a) { throw new UnsupportedOperationException(); }
public String tokenize() { throw new UnsupportedOperationException(); }
}
// GOOD: role interfaces; implement what you support
interface Payable { void pay(long amt); }
interface Refundable { void refund(long amt); }
interface Recurring { void autoDebit(long amt); }
interface Tokenizable { String tokenize(); }
class Upi implements Payable, Refundable {
public void pay(long a) { System.out.println("upi pay " + a); }
public void refund(long a) { System.out.println("upi refund " + a); }
}
class CreditCard implements Payable, Refundable, Recurring, Tokenizable {
public void pay(long a) { System.out.println("card pay " + a); }
public void refund(long a) { System.out.println("card refund " + a); }
public void autoDebit(long a) { System.out.println("card autodebit " + a); }
public String tokenize() { return "tok_123"; }
}
public class IspDemo {
static void subscribe(Recurring r) { r.autoDebit(99); } // compile-time guarantee UPI can't be passed
public static void main(String[] args) {
new Upi().pay(10);
CreditCard c = new CreditCard(); c.pay(20); subscribe(c); System.out.println(c.tokenize());
}
}
D: Dependency Inversion Principle
High-level policy must not depend on low-level details; both depend on abstractions. Inject dependencies (constructor injection); makes testing with fakes trivial. DI (the technique) is not the same as DIP (the principle).
// File: DipDemo.java
// BAD: hard-wired concrete dependency, impossible to unit test without a real SMTP server
class SmtpMailer { void send(String to, String body) { System.out.println("SMTP -> " + to + ": " + body); } }
class BadOrderService {
private final SmtpMailer mailer = new SmtpMailer();
void place() { mailer.send("a@x.com", "order placed"); }
}
// GOOD
interface MessageSender { void send(String to, String body); }
class EmailSender implements MessageSender { public void send(String to, String b) { System.out.println("EMAIL -> " + to + ": " + b); } }
class SmsSender implements MessageSender { public void send(String to, String b) { System.out.println("SMS -> " + to + ": " + b); } }
class OrderService {
private final MessageSender sender; // abstraction
OrderService(MessageSender sender) { this.sender = sender; } // injected
void place() { sender.send("user-1", "order placed"); }
}
public class DipDemo {
public static void main(String[] args) {
new BadOrderService().place();
new OrderService(new EmailSender()).place();
new OrderService(new SmsSender()).place();
StringBuilder spy = new StringBuilder();
new OrderService((to, b) -> spy.append(to).append('|').append(b)).place(); // test double
System.out.println(spy);
}
}
SOLID one-liners to quote: S one reason to change; O extend by adding, not editing; L subtypes keep promises; I small role interfaces; D depend on abstractions, inject them.
3. Composition vs inheritance
"Favor composition over inheritance" (Effective Java #18). Inheritance breaks encapsulation (subclass depends on superclass internals), is fixed at compile time, and creates deep hierarchies. Composition = has-a, swap parts at runtime, easier to test.
Use inheritance only for a true is-a that honors LSP, and when the superclass is designed for extension. Otherwise delegate.
// File: CompositionDemo.java
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
// BAD: classic InstrumentedHashSet bug. addAll() internally calls add() -> counted twice
class BadCountingSet<E> extends HashSet<E> {
int added = 0;
@Override public boolean add(E e) { added++; return super.add(e); }
@Override public boolean addAll(Collection<? extends E> c) { added += c.size(); return super.addAll(c); }
}
// GOOD: wrapper (composition + delegation); behavior independent of HashSet internals
class CountingSet<E> {
private final HashSet<E> inner = new HashSet<>();
private int added = 0;
boolean add(E e) { added++; return inner.add(e); }
boolean addAll(Collection<? extends E> c) { boolean ch = false; for (E e : c) ch |= add(e); return ch; }
int added() { return added; }
}
// Payments flavor: behaviors plugged in, not inherited (avoids CardWithFraudAndRetryAndLogging subclasses)
interface Retry { String run(java.util.function.Supplier<String> op); }
class Processor {
private final Retry retry;
Processor(Retry r) { this.retry = r; }
String pay() { return retry.run(() -> "paid"); }
}
public class CompositionDemo {
public static void main(String[] args) {
BadCountingSet<String> b = new BadCountingSet<>();
b.addAll(java.util.Arrays.asList("a", "b", "c"));
System.out.println("bad count (expected 3): " + b.added); // 6
CountingSet<String> g = new CountingSet<>();
g.addAll(java.util.Arrays.asList("a", "b", "c"));
System.out.println("good count: " + g.added()); // 3
System.out.println(new Processor(op -> op.get()).pay());
}
}
4. DRY, KISS, YAGNI
- DRY: one authoritative place per piece of knowledge (fee rule, validation). Duplicated code that represents different concepts may legitimately differ; don't over-abstract ("wrong abstraction costs more than duplication").
- KISS: simplest design that meets requirements. In 45 minutes an
enum+Mapbeats a 6-layer framework. - YAGNI: don't build for requirements nobody stated. Instead leave seams (interfaces at volatile points) and mention extensions verbally.
- Tension: SOLID/patterns can overengineer. Justify each abstraction with a real variation in the requirements.
5. Law of Demeter (principle of least knowledge)
A method should only talk to: itself, its fields, its parameters, objects it creates. Avoid "train wrecks".
Bad : order.getCustomer().getWallet().getBalance().subtract(x)
Good: order.canAfford(x) // Order asks its collaborators; callers stay decoupled
Benefits: fewer dependencies, refactorable internals. Fluent builders/streams are not violations (they return the same type). Tell, Don't Ask: tell objects what to do instead of pulling their data to decide externally (wallet.debit(x) not if (wallet.getBalance() >= x) wallet.setBalance(...), which is also a race).
6. Cohesion and coupling
- High cohesion: a class's members all serve one purpose (
Wallethandles balance rules; not email). Low cohesion = "God class",Utils,Manager. - Low coupling: few, narrow, abstract dependencies; change in one class does not ripple. Reduce via interfaces, DI, events, passing data not objects, hiding internals.
- Target: high cohesion, low coupling. Measures: number of reasons to change, number of imports/collaborators, how hard to unit-test in isolation.
- Coupling types (worst to best): content (touching internals) > common/global state > control (flag parameters) > stamp (passing whole object for 1 field) > data (primitives/value objects) > message (events).
7. Quick smells -> fixes
| Smell | Fix |
|---|---|
switch/if on type to pick behavior | Strategy / polymorphism |
switch on state | State pattern |
| Constructor with 8 params | Builder |
instanceof chains | Polymorphism / Visitor |
God class PaymentManager | Split by responsibility (SRP) |
static mutable global | Inject dependency; or thread-safe singleton with justification |
Primitive obsession (double amount, String currency) | Money value object |
Boolean flag params process(true,false) | Separate methods / enums / strategies |
| Anemic domain (getters/setters only) | Move behavior into entities |