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.