Abstraction in Java
Learn how Java's abstract classes and interfaces hide complexity and let you define contracts that decouple callers from implementations.
Abstraction means focusing on what an object does, not how it does it. You define a clear contract (what operations are available) and hide the implementation details behind that contract. This lets callers work with any implementation interchangeably — swapping a PostgreSQL connector for a MySQL one, or a file logger for a console logger, without changing any of the calling code. Java provides two tools for abstraction: abstract classes and interfaces.
Abstract Classes
An abstract class is a class that cannot be instantiated directly. It is designed to be extended. It can define both abstract methods (no body — subclasses must implement them) and concrete methods (with a body — shared logic available to all subclasses). This is the right choice when related classes share some implementation but each needs to customise specific parts.
public abstract class DatabaseConnector {
protected final String url;
protected final String username;
private boolean connected = false;
public DatabaseConnector(String url, String username) {
this.url = url;
this.username = username;
}
// Abstract methods — each database type implements these differently
public abstract void connect();
public abstract void disconnect();
public abstract List<Map<String, Object>> query(String sql);
// Concrete method — transaction logic is the same for all databases
// It delegates to the abstract methods, which subclasses fill in
public void executeTransaction(List<String> statements) {
connect();
try {
System.out.println("BEGIN TRANSACTION");
for (String sql : statements) {
query(sql);
}
System.out.println("COMMIT");
} catch (Exception e) {
System.out.println("ROLLBACK — " + e.getMessage());
} finally {
disconnect();
}
}
public boolean isConnected() { return connected; }
protected void setConnected(boolean connected) { this.connected = connected; }
}
Concrete implementations — each provides the database-specific details:
public class PostgresConnector extends DatabaseConnector {
public PostgresConnector(String host, String username) {
super("jdbc:postgresql://" + host + "/db", username);
}
@Override
public void connect() {
setConnected(true);
System.out.println("Connected to PostgreSQL at " + url);
}
@Override
public void disconnect() {
setConnected(false);
System.out.println("Disconnected from PostgreSQL.");
}
@Override
public List<Map<String, Object>> query(String sql) {
System.out.println("[PG] Executing: " + sql);
return List.of(); // simulated result
}
}
public class MySQLConnector extends DatabaseConnector {
public MySQLConnector(String host, String username) {
super("jdbc:mysql://" + host + "/db", username);
}
@Override
public void connect() {
setConnected(true);
System.out.println("Connected to MySQL at " + url);
}
@Override
public void disconnect() {
setConnected(false);
System.out.println("Disconnected from MySQL.");
}
@Override
public List<Map<String, Object>> query(String sql) {
System.out.println("[MySQL] Executing: " + sql);
return List.of();
}
}
Using abstraction — the caller works with DatabaseConnector, not a specific database. Swapping databases requires changing exactly one line:
DatabaseConnector db = new PostgresConnector("localhost:5432", "admin");
db.executeTransaction(List.of(
"INSERT INTO orders VALUES (1, 'Widget', 9.99)",
"UPDATE inventory SET stock = stock - 1 WHERE item = 'Widget'"
));
// Swap to MySQL by changing just this one line — the rest of the code is unchanged:
// DatabaseConnector db = new MySQLConnector("localhost:3306", "admin");
Interfaces
An interface is a pure contract — it declares what methods must exist, with no state of its own. Any class that implements the interface must provide those methods. Unlike abstract classes, a class can implement multiple interfaces, which makes interfaces the most flexible abstraction tool in Java.
public interface Serializable {
String serialize();
void deserialize(String data);
}
public interface Auditable {
String getCreatedBy();
LocalDateTime getCreatedAt();
String getLastModifiedBy();
LocalDateTime getLastModifiedAt();
}
// A class can implement multiple interfaces — gains both capabilities
public class Invoice implements Serializable, Auditable {
private final String id;
private double amount;
private final String createdBy;
private final LocalDateTime createdAt;
public Invoice(String id, double amount, String createdBy) {
this.id = id;
this.amount = amount;
this.createdBy = createdBy;
this.createdAt = LocalDateTime.now();
}
@Override
public String serialize() {
return String.format("{\"id\":\"%s\",\"amount\":%.2f}", id, amount);
}
@Override
public void deserialize(String data) {
System.out.println("Deserializing: " + data);
}
@Override public String getCreatedBy() { return createdBy; }
@Override public LocalDateTime getCreatedAt() { return createdAt; }
@Override public String getLastModifiedBy() { return createdBy; }
@Override public LocalDateTime getLastModifiedAt(){ return createdAt; }
}
Default Methods in Interfaces
Default methods (Java 8+) let you add new methods to an interface without breaking existing implementations. They are also useful for building convenience methods on top of a single required method — the implementing class only needs to provide the core behaviour, and gets all the convenience methods for free.
public interface Logger {
void log(String message); // the one method every implementation must provide
// Default convenience methods — built on top of log()
default void logInfo(String message) { log("[INFO] " + message); }
default void logWarn(String message) { log("[WARN] " + message); }
default void logError(String message) { log("[ERROR] " + message); }
}
// Only need to implement the one abstract method — gets three extras for free
public class ConsoleLogger implements Logger {
@Override
public void log(String message) {
System.out.println(LocalDateTime.now() + " " + message);
}
}
Logger log = new ConsoleLogger();
log.logInfo("Server started.");
log.logWarn("High memory usage.");
log.logError("Database connection failed.");
Interface Constants and Static Methods
Interfaces can define constants (implicitly public static final) and static utility methods. This is useful for grouping related constants and helper functions that have no natural home in a class.
public interface MathConstants {
double PI = 3.14159265358979; // public static final — implied
double E = 2.71828182845905;
double GOLDEN = 1.61803398874989;
// Static utility methods — called on the interface itself
static double circleArea(double r) { return PI * r * r; }
static double circlePerimeter(double r) { return 2 * PI * r; }
}
System.out.println(MathConstants.PI);
System.out.println(MathConstants.circleArea(5)); // 78.53...
Abstract Class vs Interface — Decision Guide
The choice between an abstract class and an interface comes down to one question: do the related types share state (fields) or is the relationship purely behavioural?
| Abstract Class | Interface | |
|---|---|---|
| Can have fields | ✅ | ❌ (constants only) |
| Can have constructors | ✅ | ❌ |
| Partial implementation | ✅ | ✅ (via default) |
| Multiple inheritance | ❌ (one parent) | ✅ (many interfaces) |
| Best for | Shared base with state | Capability contracts |
| Keyword | extends | implements |
Functional Interfaces (Java 8+)
An interface with exactly one abstract method is a functional interface — it can be used with lambda expressions. This is what makes Java’s Comparator, Runnable, and Predicate work with lambdas. You can define your own to build expressive, composable APIs.
@FunctionalInterface
public interface Validator<T> {
boolean validate(T value); // the single abstract method
// Default method to compose validators with AND logic
default Validator<T> and(Validator<T> other) {
return value -> this.validate(value) && other.validate(value);
}
}
// Each validator is a single lambda expression
Validator<String> notBlank = s -> s != null && !s.isBlank();
Validator<String> notTooLong = s -> s.length() <= 100;
Validator<String> emailFormat = s -> s.contains("@");
// Compose them into a single validator using the default and() method
Validator<String> emailValidator = notBlank.and(notTooLong).and(emailFormat);
System.out.println(emailValidator.validate("[email protected]")); // true
System.out.println(emailValidator.validate("not-an-email")); // false