System Design
1. System Prerequisites

Abstraction

⏱ 0s active on this topic

In simple words

Abstraction means showing what something does and hiding how it does it. A car gives you a steering wheel, an accelerator and a brake — you never touch the fuel-injection system or the braking electronics behind them. In code, you give other code a small, clean set of methods to call, and keep all the messy inner work (retries, checks, which vendor is used) out of sight behind those methods. That way the inside can change freely without anyone who uses it having to change.


Abstraction vs Encapsulation — the confusion interviewers test

They're related but not the same.

Hides…How
Encapsulationdata / stateprivate fields + methods that guard them
Abstractionimplementation / logicinterfaces & abstract classes
Encapsulation = hide the data
Abstraction   = hide the complexity
  • BankAccount keeps its balance field private → encapsulation
  • A PaymentGateway interface hides whether it calls Razorpay or Stripe → abstraction

Two ways to do abstraction in code

1. Interface — 100% abstraction

Says only what methods exist. Says nothing about how.

interface NotificationService {
    void send(String userId, String message);
}

Different implementations, all hidden from the caller:

class EmailNotificationService implements NotificationService {
    public void send(String userId, String message) {
        // connect to SMTP, format the email, handle retries — all hidden
    }
}

class SmsNotificationService implements NotificationService {
    public void send(String userId, String message) {
        // call Twilio, handle delivery receipts — all hidden
    }
}

The code that uses it depends on the interface, not any implementation:

class OrderService {
    private final NotificationService notifier;   // interface, not a concrete class

    OrderService(NotificationService notifier) { this.notifier = notifier; }

    void placeOrder(Order order) {
        // ...order logic...
        notifier.send(order.getUserId(), "Your order is confirmed");
        // OrderService has NO idea if this went by email, SMS, or push
    }
}

Result: you can swap email → SMS → push without touching OrderService at all.

2. Abstract class — partial abstraction

Defines the structure plus some shared code. Subclasses fill in the gaps.

abstract class DataExporter {

    // shared logic, written once, here
    public final void export(List<Record> records) {
        List<Record> clean = validate(records);   // shared
        String out = format(clean);               // subclass decides
        write(out);                               // subclass decides
    }

    private List<Record> validate(List<Record> records) {
        return records.stream().filter(r -> r != null).toList();
    }

    protected abstract String format(List<Record> records);  // forced on subclass
    protected abstract void write(String data);              // forced on subclass
}

class CsvExporter extends DataExporter {
    protected String format(List<Record> r) { /* CSV */ return ""; }
    protected void write(String data)       { /* write to a file */ }
}

Interface vs abstract class — when to use which

SituationUse
Completely different implementations, no shared codeInterface
Real shared logic + some parts varyAbstract class
A class must fulfil several unrelated contractsInterface (a class can implement many)
You need shared state ("is-a" relationship)Abstract class

Default to interfaces. Reach for an abstract class only when you genuinely have shared code to reuse.


Without abstraction vs with abstraction

Without — a growing if / else

class ReportService {
    void generate(String type) {
        if (type.equals("PDF"))   { /* 50 lines of PDF logic  */ }
        else if (type.equals("XLSX")) { /* 50 lines of Excel logic */ }
        else if (type.equals("CSV"))  { /* 50 lines of CSV logic  */ }
        // adding a new format = editing this class = risk of breaking the others
    }
}

With — one interface, many implementations

interface ReportGenerator { byte[] generate(ReportData data); }

class PdfReportGenerator  implements ReportGenerator { /* ... */ }
class XlsxReportGenerator implements ReportGenerator { /* ... */ }
class CsvReportGenerator  implements ReportGenerator { /* ... */ }

class ReportService {
    private final ReportGenerator generator;
    ReportService(ReportGenerator generator) { this.generator = generator; }

    byte[] generate(ReportData data) {
        return generator.generate(data);   // no if/else, no knowledge of the format
    }
}
// adding a new format = a new class only. Zero changes to ReportService.

At system-design scale

Abstraction is not just a class-level idea — it's how large systems stay changeable.

Payment gateway

your checkout code
      │  calls  gateway.charge(amount, customerId)
      ▼
PaymentGateway  (interface: charge, refund, getStatus)
      ├── RazorpayGateway   → calls Razorpay API
      ├── StripeGateway     → calls Stripe API
      └── PaytmGateway      → calls Paytm API

Checkout has zero knowledge of which provider is live. Switching providers is a config change, not a code change.

Storage

interface FileStorage {
    String upload(String name, byte[] data);
    byte[] download(String key);
}
class S3Storage    implements FileStorage { /* prod */ }
class LocalStorage implements FileStorage { /* dev + tests */ }

Repository (data access)

interface UserRepository { User findById(String id); void save(User u); }
class PostgresUserRepository implements UserRepository { /* ... */ }
class MongoUserRepository    implements UserRepository { /* ... */ }

Business logic never names Postgres or Mongo. A migration touches only the repository class.

Microservices — a whole service is an abstraction. Callers know its API contract; they never see its database, cache, retries, or internal validation.


Who picks the implementation? (the wiring layer)

An interface only helps if something supplies the concrete implementation. That choice is made in one place, once, when the application starts — the composition root. OrderService never chooses which implementation it receives, and neither does the web controller — the controller is itself handed a fully built OrderService.

Plain Java — the main method (or a small assembler class)

public static void main(String[] args) {
    // the one line that names a concrete class
    NotificationService notifier = new SmsNotificationService();

    OrderService    orderService = new OrderService(notifier);         // implementation wired in here
    OrderController controller   = new OrderController(orderService);  // controller just receives the ready object
    controller.start();
}

To switch from SMS to email, change that one line. OrderService and OrderController are untouched.

Spring — the framework is the composition root

The new calls disappear. Each implementation is marked as a bean, and Spring builds the object graph at startup:

@Service
class SmsNotificationService implements NotificationService { /* ... */ }

@Service
class OrderService {
    private final NotificationService notifier;
    OrderService(NotificationService notifier) { this.notifier = notifier; }  // Spring supplies the bean
}

@RestController
class OrderController {
    private final OrderService orderService;
    OrderController(OrderService orderService) { this.orderService = orderService; }  // receives a ready OrderService
}

When more than one implementation exists, a configuration value selects which one loads — so the choice can change without recompiling:

@Service
@ConditionalOnProperty(name = "notification.channel", havingValue = "sms")
class SmsNotificationService implements NotificationService { /* ... */ }

@Service
@ConditionalOnProperty(name = "notification.channel", havingValue = "email")
class EmailNotificationService implements NotificationService { /* ... */ }
# application.properties
notification.channel = sms

This is what "switching providers is a config change, not a code change" means in practice. (Two other ways to choose between beans: @Primary marks a default; @Qualifier("sms") selects one at the injection point.)

In short

The concrete class is chosen once, at startup, in the composition root — main() in plain Java, or the Spring container driven by a configuration value. OrderService itself stays free of new and if/else, which is the point of depending on the interface. The mechanism that supplies the implementation is Dependency Injection, covered next.


The "abstraction boundary" mental model

┌───────────────────────────────────────────────┐
│  Public interface   →  what callers can use    │
│     pay()   refund()   getStatus()             │
└───────────────────────────────────────────────┘
             ↕  the abstraction boundary
┌───────────────────────────────────────────────┐
│  Hidden implementation  →  callers never see   │
│  bank API · fraud checks · retries · encryption│
│  ledger writes · failover                      │
└───────────────────────────────────────────────┘

Everything above the line can be used freely. Everything below can change freely. The boundary is what keeps the two sides independent.


How This Connects

  • Encapsulation (next topic) — the sibling concept. Abstraction hides logic, encapsulation hides data. Both create a boundary; interviewers test that you know the difference.
  • Dependency Injection — abstraction is only useful if callers actually depend on the interface. DI is how you hand them an implementation without them naming it.
  • SOLID Principles — abstraction is the mechanism behind the Dependency Inversion Principle ("depend on abstractions, not concretions") and the Open/Closed Principle (add a new class, don't edit an old one).
  • Design Patterns — Strategy, Factory, Adapter, Repository are all "put an interface here" applied to a specific recurring problem.
  • Module 3 — HLD — every service boundary, every API, every "swap the database / cache / queue" statement in a design is abstraction at system scale.

Trade-offs & common mistakes

Abstraction has a cost. Each interface is one more layer of indirection to read through, and one more thing to keep in your head. Add it when a second implementation is real or clearly coming — not by reflex.

  • Don't create an interface for a class that will only ever have one implementation. UserServiceImpl behind UserService with nothing else implementing it is ceremony, not abstraction.
  • Don't leak the implementation through names: sendViaSmtp() instead of send() defeats the point.
  • Do name the interface after the capability, not the implementation: NotificationService, not EmailSender.
  • Do depend on the interface in constructors, never on the concrete class.

In the Interview

When to say it. Any time you draw a box that talks to another box and say "…and I could swap this out":

  • "the payment service talks to a gateway interface, so we can change providers"
  • "the app depends on a storage interface — S3 in prod, local in tests"
  • "each microservice exposes an API; nobody reads another service's database"

The ~20-second version:

"Abstraction is hiding complexity behind a clean interface — you expose what a component does, not how. A PaymentGateway interface with charge() and refund() lets the checkout code work without knowing whether it's Razorpay or Stripe underneath. It's different from encapsulation, which hides internal state like a private balance field. At system scale a microservice is abstraction in practice — other services call its API and never touch its data store, so teams work independently and internals change without breaking anyone."

Follow-ups to expect:

  1. "Interface or abstract class?" — Interface by default; abstract class only when there's real shared code. A class can implement many interfaces but extend only one class.
  2. "Isn't this over-engineering?" — It is if there's only ever one implementation. Add the interface when the second one is real.
  3. "How does this help testing?" — Callers depend on the interface, so tests pass a fake/mock implementation — no real DB or API needed.

Mistake to avoid: wrapping everything in an interface "to be safe." Unused abstraction is just extra indirection that makes the code harder to follow.


Quick Revision — 6 lines

  1. Abstraction = show WHAT, hide HOW. Expose a small clean interface; hide the internals.
  2. Achieved with interfaces (100% abstract) or abstract classes (structure + shared code).
  3. Different from encapsulation: encapsulation hides data, abstraction hides logic.
  4. It replaces growing if/else chains with "one interface, many implementations" — add a class, don't edit one.
  5. At system scale: microservice APIs, payment gateways, storage layers, repositories are all abstraction boundaries.
  6. Cost = indirection. Add an interface when a second implementation is real, not by reflex. Name it after the capability.

Quick-revision quiz

Question 1 of 18
What does abstraction hide, and what does encapsulation hide?