Skip to main content
Java · lesson 17 of 66
Open the Java page →
Java beginner Lesson 17 of 66

Abstraction in Java

Learn how Java's abstract classes and interfaces hide complexity and let you define contracts that decouple callers from implementations.

By SkillByExample · Updated

Abstraction means focusing on what an object does, not how it does it. You define a contract (the operations available) and hide the implementation behind it, so callers can swap one implementation for another without changing their own code. Java has two tools for this: abstract classes and interfaces. Every example is one file run with the JDK 21 source launcher (java File.java).

Programming against an abstract class

An abstract class can fix the shared workflow in a concrete method and leave the implementation-specific steps abstract.

import java.util.List;

public class Transactions {
    public static void main(String[] args) {
        DatabaseConnector db = new PostgresConnector("localhost:5432");
        db.executeTransaction(List.of("INSERT INTO orders VALUES (1)", "UPDATE stock SET n = n - 1"));
    }
}

abstract class DatabaseConnector {
    protected final String url;
    DatabaseConnector(String url) { this.url = url; }

    abstract void connect();
    abstract void query(String sql);
    abstract void disconnect();

    void executeTransaction(List<String> statements) {   // same for every database
        connect();
        System.out.println("BEGIN");
        statements.forEach(this::query);
        System.out.println("COMMIT");
        disconnect();
    }
}

class PostgresConnector extends DatabaseConnector {
    PostgresConnector(String host) { super("jdbc:postgresql://" + host + "/db"); }
    void connect()          { System.out.println("Connected to " + url); }
    void query(String sql)  { System.out.println("[PG] " + sql); }
    void disconnect()       { System.out.println("Disconnected from PostgreSQL"); }
}
$ java Transactions.java
Connected to jdbc:postgresql://localhost:5432/db
BEGIN
[PG] INSERT INTO orders VALUES (1)
[PG] UPDATE stock SET n = n - 1
COMMIT
Disconnected from PostgreSQL

main declares db as DatabaseConnector, and executeTransaction never mentions Postgres. Adding a MySQL connector means writing one new subclass and changing only the new expression.

Interfaces: contracts with no state

An interface declares methods without holding instance fields. A class can implement many interfaces, so it can satisfy several unrelated contracts at once.

public class Invoices {
    public static void main(String[] args) {
        Invoice inv = new Invoice("INV-1", 42.5, "alice");
        JsonWritable w = inv;
        Auditable a = inv;
        System.out.println(w.toJson());
        System.out.println("created by " + a.createdBy());
    }
}

interface JsonWritable { String toJson(); }
interface Auditable    { String createdBy(); }

class Invoice implements JsonWritable, Auditable {
    private final String id;
    private final double amount;
    private final String createdBy;

    Invoice(String id, double amount, String createdBy) {
        this.id = id; this.amount = amount; this.createdBy = createdBy;
    }
    public String toJson()    { return String.format("{\"id\":\"%s\",\"amount\":%.2f}", id, amount); }
    public String createdBy() { return createdBy; }
}
$ java Invoices.java
{"id":"INV-1","amount":42.50}
created by alice

The same object is seen through two narrower types. Code holding a JsonWritable can call toJson() and nothing else — it cannot reach createdBy() even though the object has it. Interface methods are implicitly public, which is why Invoice has to declare them public.

Default methods

A default method gives an interface a method body. Implementations only supply the one abstract method and get the convenience methods built on it for free.

import java.time.LocalTime;

public class Logging {
    public static void main(String[] args) {
        Logger log = new ConsoleLogger();
        log.info("Server started");
        log.warn("High memory usage");
    }
}

interface Logger {
    void log(String message);

    default void info(String message) { log("[INFO] " + message); }
    default void warn(String message) { log("[WARN] " + message); }
}

class ConsoleLogger implements Logger {
    public void log(String message) {
        System.out.println(LocalTime.now().withNano(0) + " " + message);
    }
}
$ java Logging.java
15:21:14 [INFO] Server started
15:21:14 [WARN] High memory usage

The times come from the clock and will differ on your run. ConsoleLogger wrote one method, yet info and warn both work, because both defaults route through its log().

When two defaults collide

Defaults bring a rule: if a class inherits the same default method from two interfaces, the compiler refuses to pick one.

public class Clash {
    public static void main(String[] args) {
        System.out.println(new Duck().describe());
    }
}

interface Swimmer { default String describe() { return "swims"; } }
interface Flyer   { default String describe() { return "flies"; } }

class Duck implements Swimmer, Flyer { }
$ java Clash.java
Clash.java:10: error: types Swimmer and Flyer are incompatible;
class Duck implements Swimmer, Flyer { }
^
  class Duck inherits unrelated defaults for describe() from types Swimmer and Flyer
1 error
error: compilation failed

The fix is to override the method in Duck and choose explicitly with Interface.super:

public class ClashFixed {
    public static void main(String[] args) {
        System.out.println(new Duck().describe());
    }
}

interface Swimmer { default String describe() { return "swims"; } }
interface Flyer   { default String describe() { return "flies"; } }

class Duck implements Swimmer, Flyer {
    public String describe() {
        return Swimmer.super.describe() + " and " + Flyer.super.describe();
    }
}
$ java ClashFixed.java
swims and flies

Swimmer.super.describe() calls that interface’s default specifically, so Duck can combine both.

Constants and static methods

Interface fields are implicitly public static final, and interfaces can hold static helper methods called through the interface name.

public class Geometry {
    public static void main(String[] args) {
        System.out.println(Circles.UNIT);
        System.out.println(Circles.area(5));
        Circles.UNIT = "m";
    }
}

interface Circles {
    String UNIT = "cm";
    static double area(double r) { return Math.PI * r * r; }
}
$ java Geometry.java
Geometry.java:5: error: cannot assign a value to static final variable UNIT
        Circles.UNIT = "m";
               ^
1 error
error: compilation failed

UNIT was never declared final, yet the compiler calls it static final: an interface cannot hold per-instance or mutable state. Remove the assignment and it runs:

public class GeometryFixed {
    public static void main(String[] args) {
        System.out.println(Circles.UNIT);
        System.out.println(Circles.area(5));
    }
}

interface Circles {
    String UNIT = "cm";
    static double area(double r) { return Math.PI * r * r; }
}
$ java GeometryFixed.java
cm
78.53981633974483

Abstract class vs interface

Abstract classInterface
Instance fieldsyesno (constants only)
Constructorsyesno
Method bodiesyesyes, via default and static
How many per classone (extends)many (implements)
Best forshared base with statecapability contracts

Functional interfaces

An interface with exactly one abstract method is a functional interface, so a lambda can implement it. Default methods on it can combine lambdas into bigger ones.

public class Validators {
    public static void main(String[] args) {
        Validator<String> notBlank = s -> !s.isBlank();
        Validator<String> hasAt    = s -> s.contains("@");
        Validator<String> email    = notBlank.and(hasAt);

        System.out.println(email.validate("alice@example.com"));
        System.out.println(email.validate("not-an-email"));
        System.out.println(email.validate("   "));
    }
}

@FunctionalInterface
interface Validator<T> {
    boolean validate(T value);

    default Validator<T> and(Validator<T> other) {
        return value -> this.validate(value) && other.validate(value);
    }
}
$ java Validators.java
true
false
false

and() returns a new lambda, so email is a validator built from two others. @FunctionalInterface makes the compiler reject a second abstract method; default methods do not count.

Practice

  1. Add a MySQLConnector to Transactions.java and run the same transaction through it by changing only the line that creates db.
Solution
import java.util.List;

public class SwapDb {
    public static void main(String[] args) {
        DatabaseConnector db = new MySQLConnector("localhost:3306");
        db.executeTransaction(List.of("INSERT INTO orders VALUES (1)"));
    }
}

abstract class DatabaseConnector {
    protected final String url;
    DatabaseConnector(String url) { this.url = url; }
    abstract void connect();
    abstract void query(String sql);
    abstract void disconnect();
    void executeTransaction(List<String> statements) {
        connect();
        System.out.println("BEGIN");
        statements.forEach(this::query);
        System.out.println("COMMIT");
        disconnect();
    }
}

class MySQLConnector extends DatabaseConnector {
    MySQLConnector(String host) { super("jdbc:mysql://" + host + "/db"); }
    void connect()          { System.out.println("Connected to " + url); }
    void query(String sql)  { System.out.println("[MySQL] " + sql); }
    void disconnect()       { System.out.println("Disconnected from MySQL"); }
}
$ java SwapDb.java
Connected to jdbc:mysql://localhost:3306/db
BEGIN
[MySQL] INSERT INTO orders VALUES (1)
COMMIT
Disconnected from MySQL

executeTransaction is unchanged; the BEGIN/COMMIT lines come from the abstract class.

  1. Add a debug default method to Logger and a second implementation, PrefixLogger, that prints a fixed prefix instead of the time.
Solution
public class Logging2 {
    public static void main(String[] args) {
        Logger log = new PrefixLogger("app");
        log.debug("cache miss for user:42");
        log.info("ready");
    }
}

interface Logger {
    void log(String message);
    default void info(String message)  { log("[INFO] " + message); }
    default void debug(String message) { log("[DEBUG] " + message); }
}

class PrefixLogger implements Logger {
    private final String prefix;
    PrefixLogger(String prefix) { this.prefix = prefix; }
    public void log(String message) { System.out.println(prefix + ": " + message); }
}
$ java Logging2.java
app: [DEBUG] cache miss for user:42
app: [INFO] ready

The new default needed no change in any implementation; that is why defaults were added in Java 8.

  1. Write Validator<Integer> lambdas isPositive and isEven, and combine them with and(). Test 4, -4 and 3.
Solution
public class IntValidators {
    public static void main(String[] args) {
        Validator<Integer> isPositive = n -> n > 0;
        Validator<Integer> isEven = n -> n % 2 == 0;
        Validator<Integer> both = isPositive.and(isEven);
        for (int n : new int[] {4, -4, 3}) {
            System.out.println(n + " -> " + both.validate(n));
        }
    }
}

@FunctionalInterface
interface Validator<T> {
    boolean validate(T value);
    default Validator<T> and(Validator<T> other) {
        return value -> this.validate(value) && other.validate(value);
    }
}
$ java IntValidators.java
4 -> true
-4 -> false
3 -> false

and() is generic over T, so it works for Integer exactly as it did for String.

  1. Add a second abstract method to Validator while keeping @FunctionalInterface. What happens?
Solution
public class NotFunctional {
    public static void main(String[] args) {
        Validator<String> v = s -> !s.isEmpty();
        System.out.println(v.validate("x"));
    }
}

@FunctionalInterface
interface Validator<T> {
    boolean validate(T value);
    String name();
}
$ java NotFunctional.java
NotFunctional.java:3: error: incompatible types: Validator is not a functional interface
        Validator<String> v = s -> !s.isEmpty();
                              ^
    multiple non-overriding abstract methods found in interface Validator
NotFunctional.java:8: error: Unexpected @FunctionalInterface annotation
@FunctionalInterface
^
  Validator is not a functional interface
    multiple non-overriding abstract methods found in interface Validator
2 errors
error: compilation failed

A lambda can supply only one method body, so an interface with two abstract methods cannot be a lambda target — and the annotation makes the compiler say so at the interface itself.

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.
What happens if a class inherits the same default method from two interfaces?
The class fails to compile with an 'inherits unrelated defaults' error. Java will not guess which default wins, so the class must override the method itself, and it can call a specific one with InterfaceName.super.method().