Abstract Classes Still Have a Place in Java — Here Is When to Reach for Them

by Eric Hanson, Backend Developer at Clean Systems Consulting

What changed in Java 8

Before Java 8, the distinction was mechanical: interfaces defined contracts with no implementation; abstract classes provided partial implementation. If you needed shared behavior, you used an abstract class. If you needed a contract multiple unrelated classes could satisfy, you used an interface.

default methods in Java 8 blurred this. Interfaces can now provide method implementations, hold static utility methods, and with private methods (Java 9+) share code between their own default implementations. An interface can do most of what an abstract class used to do exclusively.

The question worth asking is no longer "which one can do what I need?" — both often can. It's "which one correctly models what I'm building?"

What interfaces model: behavioral contracts

An interface declares what an object can do. Comparable<T>, Iterable<T>, AutoCloseable — these are capabilities. A class implements them to announce that it satisfies a contract. The implementing class might be completely unrelated to any other implementor; the interface is the only connection between them.

String, Integer, and LocalDate all implement Comparable<T>. They share nothing structurally. The interface expresses a capability they each happen to have.

This is the core design signal: if the relationship between implementing classes is "they can all do X," an interface is correct. If the relationship is "they are all kinds of X," an abstract class may be more appropriate.

What abstract classes model: incomplete implementations

An abstract class declares what an object is, partially implements it, and defers the rest to subclasses. The subclasses are specializations of a common concept — not merely classes that happen to share a capability.

The canonical example: a template method pattern where the algorithm structure is fixed but specific steps are customizable:

public abstract class DataImporter {
    // Template method — fixed algorithm structure
    public final void importData(InputStream source) {
        List<Record> raw = parse(source);         // step 1: subclass-defined
        List<Record> valid = validate(raw);        // step 2: shared implementation
        List<Record> transformed = transform(valid); // step 3: subclass-defined
        persist(transformed);                      // step 4: shared implementation
    }

    protected abstract List<Record> parse(InputStream source);
    protected abstract List<Record> transform(List<Record> records);

    private List<Record> validate(List<Record> records) {
        return records.stream()
            .filter(Record::isValid)
            .collect(Collectors.toList());
    }

    private void persist(List<Record> records) {
        repository.saveAll(records);
    }
}

public class CsvImporter extends DataImporter {
    @Override
    protected List<Record> parse(InputStream source) { /* CSV parsing */ }

    @Override
    protected List<Record> transform(List<Record> records) { /* CSV-specific transform */ }
}

The algorithm — parse, validate, transform, persist — is defined once in the abstract class. CsvImporter and JsonImporter fill in the format-specific steps. The final on importData prevents subclasses from breaking the algorithm structure.

This pattern has no clean interface equivalent. You could extract the steps into an interface, but then the algorithm structure isn't enforced anywhere — a class that implements the interface can ignore validate or call steps out of order. The abstract class enforces the contract and provides the shared implementation.

The three things abstract classes still do that interfaces can't

Instance state with controlled access. Interfaces cannot have instance fields. If the shared implementation requires shared mutable state, an abstract class is the only option:

public abstract class RateLimitedClient {
    private final int maxRequestsPerSecond;
    private final RateLimiter limiter;

    protected RateLimitedClient(int maxRequestsPerSecond) {
        this.maxRequestsPerSecond = maxRequestsPerSecond;
        this.limiter = RateLimiter.create(maxRequestsPerSecond);
    }

    protected void acquirePermit() {
        limiter.acquire(); // shared rate limiting logic, shared state
    }

    public abstract Response execute(Request request);
}

limiter is shared instance state owned by the abstract class. Every subclass inherits it and calls acquirePermit() before making requests. There's no way to express this in an interface — interfaces can have static fields (implicitly public static final) but not per-instance fields.

Constructor enforcement. Abstract class constructors run when a subclass is instantiated. You can enforce required initialization before any subclass code runs:

public abstract class AuditedEntity {
    private final String createdBy;
    private final Instant createdAt;

    protected AuditedEntity(String createdBy) {
        Objects.requireNonNull(createdBy, "createdBy must not be null");
        this.createdBy = createdBy;
        this.createdAt = Instant.now();
    }

    public String getCreatedBy() { return createdBy; }
    public Instant getCreatedAt() { return createdAt; }
}

Any class extending AuditedEntity must call super(createdBy) — the compiler enforces it. The audit fields are set exactly once, at construction, before any subclass code runs. Interfaces have no constructor mechanism; default method initialization runs only when called, not at construction time.

Protected API surface. Abstract classes can declare protected methods — part of the contract for subclasses, invisible to external callers. Interfaces cannot; all interface methods are implicitly public. This matters when the abstraction has a meaningful distinction between the external API and the extension points:

public abstract class HttpHandler {
    // Public API — what callers invoke
    public final Response handle(Request request) {
        Request authenticated = authenticate(request); // internal step
        return process(authenticated);
    }

    // Extension point — what subclasses implement
    protected abstract Response process(Request request);

    // Internal step — not visible outside the hierarchy
    private Request authenticate(Request request) { /* ... */ }
}

process is protected — subclasses implement it, but external code can't call it directly. With an interface, process would have to be public, exposing an implementation detail that callers shouldn't invoke.

The multiple-interface advantage interfaces retain

Abstract classes impose single inheritance. A class extending DataImporter can't also extend AuditedEntity. Interfaces don't have this constraint — a class can implement any number of interfaces.

This is the primary reason to prefer interfaces for behavioral contracts: they compose. CsvImporter implements Parseable, Transformable, Auditable is legal. CsvImporter extends DataImporter, AuditedEntity is not.

When you find yourself wanting multiple inheritance — two separate sets of shared behavior that a class needs — the correct answer is usually interfaces with default methods for the shareable parts, and composition with explicit delegation for state that used to require abstract class fields.

// Instead of: class ReportGenerator extends DataImporter, AuditedEntity
public class ReportGenerator extends DataImporter {
    private final AuditLog auditLog;  // composition for audit behavior

    public ReportGenerator(String createdBy) {
        this.auditLog = new AuditLog(createdBy);
    }
}

When the choice reveals a design problem

If you're genuinely unsure whether to use an interface or abstract class, the uncertainty is sometimes a signal that the abstraction itself isn't well-defined.

An abstract class with only abstract methods and no shared state or implementation is better expressed as an interface. An interface with default methods that require access to state — forcing implementors to expose internal state through additional methods — is trying to be an abstract class without the tools for it.

The question that resolves most cases: does the shared behavior require shared state or a fixed execution sequence? If yes, abstract class. If no — if each implementor can satisfy the contract independently — interface.

Java's standard library reflects this: AbstractList, AbstractMap, AbstractQueue are abstract classes that provide significant shared implementation over mutable state. List, Map, Queue are interfaces that define the contract. The abstract classes exist for convenience — you don't have to implement them, but they eliminate the boilerplate if you're building a standard collection. You could extend AbstractList and override only get and size to have a working read-only list. Without it, you'd implement every method on List from scratch.

That's the pattern worth internalizing: abstract classes are for partial implementations that would be burdensome to duplicate. Interfaces are for contracts that each implementor satisfies independently. When both seem to fit, ask whether the implementation could reasonably differ between implementors — if it could, it belongs in the subclass, not the abstract class.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Hiring Backend Developers in New York Takes 11 Weeks. Here Is What Smart Founders Do Instead

You posted the role eight weeks ago. You've done six technical screens. Your top candidate just accepted an offer somewhere else.

Read more

Microservices Sound Great Until You Have to Maintain Them

Microservices trade one class of problem for several others. The architecture is legitimate — but teams routinely adopt it before they have the operational maturity to survive it.

Read more

Spring Cloud Vault in Production — Configuration, Failover, and the Secrets You Shouldn't Store

Getting Spring Cloud Vault working in development is straightforward. Running it reliably in production requires understanding lease renewal behavior, startup failure modes, high availability configuration, and the categories of secrets that Vault handles well versus those where it adds complexity without benefit.

Read more

When Your Feature Works Locally but Fails in Production

You run your code, it works perfectly on your machine. Deploy it… and everything breaks. This is the nightmare every developer dreads.

Read more