Skip to main content
Java beginner Lesson 17 of 58

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 ClassInterface
Can have fields❌ (constants only)
Can have constructors
Partial implementation✅ (via default)
Multiple inheritance❌ (one parent)✅ (many interfaces)
Best forShared base with stateCapability contracts
Keywordextendsimplements

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

Frequently Asked Questions

When should I use an abstract class vs an interface?
Use an abstract class when subclasses share state (fields) or common method implementations and have an 'is-a' relationship. Use an interface to define a capability contract that any unrelated class can implement.
Can abstract classes have constructors?
Yes. Abstract classes can have constructors, but you cannot instantiate an abstract class directly. The constructor is called via super() from the concrete subclass.
What is the purpose of a default method in an interface?
Default methods (added in Java 8) let you add new methods to an interface without breaking existing implementations. They provide a default behaviour that implementors can optionally override.