Abstraction
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 | |
|---|---|---|
| Encapsulation | data / state | private fields + methods that guard them |
| Abstraction | implementation / logic | interfaces & abstract classes |
Encapsulation = hide the data
Abstraction = hide the complexity
BankAccountkeeps itsbalancefield private → encapsulation- A
PaymentGatewayinterface 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
| Situation | Use |
|---|---|
| Completely different implementations, no shared code | Interface |
| Real shared logic + some parts vary | Abstract class |
| A class must fulfil several unrelated contracts | Interface (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.
UserServiceImplbehindUserServicewith nothing else implementing it is ceremony, not abstraction. - Don't leak the implementation through names:
sendViaSmtp()instead ofsend()defeats the point. - Do name the interface after the capability, not the implementation:
NotificationService, notEmailSender. - 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
PaymentGatewayinterface withcharge()andrefund()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 privatebalancefield. 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:
- "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.
- "Isn't this over-engineering?" — It is if there's only ever one implementation. Add the interface when the second one is real.
- "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
- Abstraction = show WHAT, hide HOW. Expose a small clean interface; hide the internals.
- Achieved with interfaces (100% abstract) or abstract classes (structure + shared code).
- Different from encapsulation: encapsulation hides data, abstraction hides logic.
- It replaces growing
if/elsechains with "one interface, many implementations" — add a class, don't edit one. - At system scale: microservice APIs, payment gateways, storage layers, repositories are all abstraction boundaries.
- Cost = indirection. Add an interface when a second implementation is real, not by reflex. Name it after the capability.