Abstraction in Java: Focusing on What Matters
Abstraction is one of the pillars of the Object Oriented Programming paradigm and it is probably one of the most misunderstood concepts. I’ve seen colleagues and myself, let’s be honest, confuse it with encapsulation a lot of times. Many people think it’s just about creating interfaces and abstract classes. But abstraction is something more fundamental, and honestly, understanding it well helps you go from good code to great code.
What Actually is Abstraction?
Abstraction is the art of hiding complexity and exposing only what’s necessary to achieve the functionality. When you drive your car, you don’t need to be a mechanic or know about how the internal combustion engine works. You just grab your keys, start the engine and from there you push the gas pedal to advance, use the brake pedal to stop, and turn the steering wheel to the direction you want to go. That’s abstraction my friend.
When using abstraction you show only the essential features of an object and hiding the unnecessary details.
The goal is to reduce complexity by breaking down a system into smaller, manageable pieces where each piece has a clear, simple, intention revealing interface. When done correctly, and this is not simple, of course, abstraction lets you think about problems at a higher level without getting bogged down in implementation details.
Abstraction vs Encapsulation - Ok yeah, so what’s the difference then?
Great question!
Encapsulation is about controlling access to data to offer protection and avoid inconsistencies. It’s about bundling data with the methods that operate on that data, and restricting direct access to some of the internal components.
Abstraction is about hiding complexity and showing only relevant details. It’s a simplified view of something complex. Think of it as creating a simple universal remote control for a complicated entertainment system.
Both concepts are hiding (or protecting) something: Encapsulation is hiding information and Abstraction is hiding implementation details.
Let us see some code to illustrate the encapsulation concept (the following example is shorter version of the code from another post in my blog that talks about encapsulation: https://abaddon-gtz.blogspot.com/2025/08/encapsulation-in-java-writing-secure.html)
public class BadBankAccount {
// Private fields - hidden from outside access
private String owner;
private double balance;
private List<String> transactions;
private static final double MINIMUM_BALANCE = 0.0;
// Constructor with validation (This can be better implemented as a factory method)
public BadBankAccount(String owner, double initialBalance) {
if (owner == null || owner.trim().isEmpty()) {
throw new IllegalArgumentException("Owner name cannot be null or empty");
}
if (initialBalance < MINIMUM_BALANCE) {
throw new IllegalArgumentException("Initial balance cannot be negative");
}
this.owner = owner;
this.balance = initialBalance;
this.transactions = new ArrayList<>();
addTransaction("Account opened with balance: $" + initialBalance);
}
// Business logic for deposits
public boolean deposit(double amount) {
if (amount <= 0) {
System.out.println("Deposit amount must be positive");
return false;
}
balance += amount;
addTransaction("Deposited: $" + amount + " | New balance: $" + balance);
return true;
}
// more code
}
The focus here is protection. We’re preventing invalid states and controlling access to the internals to avoid inconsistencies.
Now look at abstraction:
// A checkout service that process payments
public class CheckoutService {
private PaymentProcessor processor;
public void checkout(double amount) {
// simple interface, abstracting complexity
boolean success = processor.processPayment(amount);
if (success) {
completeOrder();
}
}
}
// ABSTRACTION!
public interface PaymentProcessor {
boolean processPayment(double amount);
}
// Implementation 1 - Credit Card (complex internal logic)
public class CreditCardProcessor implements PaymentProcessor {
@Override
public boolean processPayment(double amount) {
// Hidden complexity:
// - Validate card number
// - Check CVV
// - Contact payment service
// - Handle security
// - Deal with declined transactions or other errors
// - Manage retry logic
// All this complexity is hidden behind a simple interface
return true;
}
}
// Implementation 2 - PayPal (different complex logic)
public class PayPalProcessor implements PaymentProcessor {
@Override
public boolean processPayment(double amount) {
// Hidden complexity:
// - authentication
// - PayPal API calls
// - Handle different currencies
// - etc, etc, etc
// Again, complexity hidden
return true;
}
}
The focus here is simplification. We’re hiding different complex implementations behind a common, simple interface.
They Work Together
More often than not these concepts are used together and probably because of that, I think, it is why we tend to confuse them a lot.
Again, using the example of driving a car, there is encapsulation and abstraction in action. You don’t have to open the hood of the car to start the engine manually and also to turn the car to the direction you want to go you use the simple interface that is the steering wheel.
Simple quick mental model to remember the concepts
Ask yourself:
- “Am I protecting data and controlling access?” → That’s encapsulation
- “Am I hiding complexity and providing a simpler interface?” → That’s abstraction
Both make your code better, but for different reasons. Encapsulation keeps your data safe and consistent. Abstraction keeps your code manageable and flexible.
Do I really need them?
Without proper abstraction and encapsulation, your code can become a tangled mess where:
- You need to understand too much to do simple things
- Changes in one place break things in unexpected places (we’ve all know this pain!)
- New team members take forever to understand what’s going on in the code
- Testing becomes a nightmare because everything is coupled to everything
So, yeah! you need them!
Imagine you are building a simple tool to migrate data from one database to another and because some business and safety requirements, there are some steps that you need to enforce like:
- you must create a backup before copying the data
- after running the migration you need to validate data is actually copied in the database.
Now imagine you write a class like the following:
/**
* <Some very large and detailed documentation of how to use this tool>
*/
public class DatabaseMigrationTool {
// lot of attributes
public void setSourceDatabase(String host, String db, String user, String password) { ... }
public void setTargetDatabase(String host, String db, String user, String password) { ... }
public void createBackup() {
// Implementation details omitted for brevity
this.backupCreated = true;
// nullpointer exceptions if there is no source and target data defined :D
}
public void migrateData() {
// you cannot transfer the data if the backup wasn't created
if (!backupCreated) throw new IllegalStateException("Hey, did you forget to backup?");
// Implementation details omitted for brevity
this.dataTransferred = true;
}
public void validateMigratedData() {
// you cannot validate copied data if the data wasn't transfered first!
if (!dataTransferred) throw new IllegalStateException("Come on! really?!");
}
}
This is probably not that bad. If a developer that does not like to read documentation tries to use it, he probably will hit his head against the wall a couple of times before learning there is some order of how to call the methods.
DatabaseMigration migration = new DatabaseMigration();
migration.migrateData(); // ❌ Oops! No backup!
migration.setSourceDatabase(...);
migration.validateMigratedData(); // ❌ Oops!
migration.setTargetDatabase(...);
With Abstraction (clean and intention revealing)
Let us use the beautiful “Step Builder Pattern” that combines Fluent interfaces and the Builder pattern.
public interface FirstStep {
SecondStep fromDatabase(String host, String db, String user, String password);
}
public interface SecondStep {
ThirdStep toDatabase(String host, String db, String user, String password);
}
public interface ThirdStep {
FourthStep withBackup();
}
public interface FourthStep {
FifthStep migrateData();
}
public interface FifthStep {
FinalStep validateMigration();
}
public interface FinalStep {
void finish();
}
public class DatabaseMigrationTool {
// you have the same attributes and methods defined before but they are private
// no one can call them if not using the builder
// The builder!
public static FirstStep builder() {
return new Builder();
}
private static class Builder
implements FirstStep, SecondStep, ThirdStep, FourthStep, FifthStep, FinalStep {
private DatabaseMigrationTool tool = new DatabaseMigrationTool();
@Override
public SecondStep fromDatabase(String host, String db, String user, String password) {
// Add validation here
this.tool.setSourceDatabase(host, db, user, password);
return this;
}
@Override
public ThirdStep toDatabase(String host, String db, String user, String password) {
// Add validation here
this.tool.setTargetDatabase(host, db, user, password);
return this;
}
@Override
public FourthStep withBackup() {
this.tool.createBackup();
return this;
}
@Override
public FifthStep migrateData() {
this.tool.migrateData();
return this;
}
@Override
public FinalStep validateMigration() {
this.tool.validateMigratedData();
return this;
}
@Override
public void finish() {
System.out.println("Done!");
}
}
}
Now if some developer wants to use it, the different interfaces that define the flow of the steps enforce the correct usage:
DatabaseMigrationTool.builder()
.fromDatabase(...)
.toDatabase(...)
.withBackup()
.migrateData()
.validateMigration()
.finish();
You could probably say: “I can simply define a ‘execute’ method calling all methods in the correct order”. Yes, of course, but what if later you need to add other steps that can be skipped according to the needs of each individual user of the tool?
public interface SkippableStep {
NextStep executeStep();
NextStep skipStep();
}
That’s the power of abstraction.
Achieving Abstraction with Java
1. Interfaces - The Foundation
Interfaces define contracts without implementation details.
public interface PaymentProcessor {
PaymentResult processPayment(double amount, PaymentMethod method);
boolean refund(String transactionId);
TransactionStatus checkStatus(String transactionId);
}
// Different implementations
public class StripePaymentProcessor implements PaymentProcessor { /* ... */ }
public class PayPalPaymentProcessor implements PaymentProcessor { /* ... */ }
2. Abstract Classes - use them when you need some common stuff
Use abstract classes when you have shared behavior but still need abstraction:
public abstract class ReportGenerator {
// Template method design pattern - defines the overall algorithm structure
public final Report generate(String data) {
Report report = createReport();
report.setHeader(generateHeader());
report.setBody(processData(data));
report.setFooter(generateFooter());
return report;
}
// shared behavior
protected String generateHeader() {
return "Report Generated: " + LocalDateTime.now();
}
// Abstract methods - let subclasses decide how to implement the functionality
protected abstract Report createReport();
protected abstract String processData(String data);
protected abstract String generateFooter();
}
3. Depend on Abstractions, Not Concrete Implementations
Your high-level code should depend on interfaces, not concrete classes. SOLID Dependency Inversion principle!
// Bad
public class OrderService {
private MySQLOrderRepository repository; // Tied to MySQL!
public OrderService() {
this.repository = new MySQLOrderRepository();
}
}
// Good - depends on abstraction
public class OrderService {
private final OrderRepository repository; // Any implementation works!
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
Some good practices I’ve learned
- Start with the interface, not the implementation. When designing a new component, ask yourself: “What operations do I need?” not “How will I implement this?” Design the contract first.
- Keep abstractions focused. An interface should represent one concept. If your interface is doing too much, split it up. SOLID Interface Segregation Principle is your friend here.
- Don’t create abstractions right away.* Wait until you have at least two implementations or a clear reason. Over-abstraction is as bad as under-abstraction. I’ve made this mistake more times than I’d like to admit.
- Name abstractions by what they do, not how they do it. Use
NotificationChannelinstead ofEmailSender. The abstraction shouldn’t leak implementation details in its name. - Test through abstractions. Write tests against interfaces, not concrete classes. This makes your tests more resilient to implementation changes and you can use mocks this way.
Conclusion
Abstraction is about managing complexity by hiding unnecessary details and exposing clean, intuitive interfaces. When you get it right, your code becomes easier to understand, easier to test, and way easier to change. Getting right abstractions does not happen at the first try, it requires practice, design patterns knowledge, and experimentation.
Good abstractions reduce the need for extensive documentation (caution, I’m not saying you don’t need documentation) because the code itself clearly expresses intent. The abstractions become the language you use to think about and discuss your system.
Do you have other examples of abstraction to share?
No comments:
Post a Comment