Here’s the answer interviewers want first: use an interface to describe what a type can do, and an abstract class to share how it does it. An interface is a contract that any class can sign, alongside other contracts. An abstract class is a partly built superclass: it can hold fields, run a constructor and give subclasses ready-made code, but a class can only extend one of them.
Java 8 blurred the line by letting interfaces contain method bodies (default and static methods), and Java 17 added sealed types. Most follow-up questions are about those two changes, so the comparison below covers modern Java, not the Java 7 version that older tutorials still teach.
Side-by-side comparison
| Interface | Abstract class | |
|---|---|---|
| How many per class | Any number (implements A, B, C) |
One (extends Base) |
| Instance fields | No; any field is implicitly public static final |
Yes, any visibility |
| Constructors | No | Yes, run via super(...) from subclasses |
| Method bodies | default, static and private methods (Java 8 and 9) |
Any concrete method |
| Abstract methods | Implicitly public abstract |
Declared abstract, any visibility except private |
| Member visibility | Public, plus private helpers since Java 9 |
public, protected, package-private, private |
| Usable as a lambda target | Yes, if it has exactly one abstract method | No |
Can be sealed (Java 17) |
Yes | Yes |
| Typical role | Capability or API contract (Comparable, List, Runnable) |
Shared skeleton with state (AbstractList, HttpServlet) |
The JDK itself pairs them. List is the interface everyone codes against, and AbstractList is an abstract class that implements most of List on top of two methods, get(int) and size(), so you don’t write contains, indexOf and iterators yourself. That pairing (interface for callers, optional abstract base for implementers) is still the most useful pattern to describe in an interview.
A worked example: notifications with retries
Say a service sends notifications by email, SMS and Slack. Callers shouldn’t care which. That’s a capability, so it’s an interface:
public interface Notifier {
void send(Message message);
default void sendAll(List<Message> messages) { // behaviour, no state
for (Message m : messages) {
send(m);
}
}
static Notifier noop() { // a factory on the interface
return message -> { };
}
}Notifier has one abstract method, so a lambda can implement it, which is what noop() does. The default method gives every implementation sendAll for free, and an implementation can still override it with a batch API.
Now the email and SMS notifiers both need the same retry logic, with a configured attempt limit and a failure counter. That’s shared state plus shared code, which an interface can’t hold. It belongs in an abstract class:
public abstract class RetryingNotifier implements Notifier {
private final int maxAttempts;
private int failures; // instance state: impossible in an interface
protected RetryingNotifier(int maxAttempts) {
if (maxAttempts < 1) {
throw new IllegalArgumentException("maxAttempts must be at least 1");
}
this.maxAttempts = maxAttempts;
}
@Override
public final void send(Message message) { // the template: subclasses can't change it
for (int attempt = 1; ; attempt++) {
try {
deliver(message);
return;
} catch (DeliveryException e) { // an unchecked exception
failures++;
if (attempt == maxAttempts) {
throw e;
}
}
}
}
protected abstract void deliver(Message message); // the one step subclasses fill in
public int failures() {
return failures;
}
}
public final class EmailNotifier extends RetryingNotifier {
private final SmtpClient smtp;
public EmailNotifier(SmtpClient smtp) {
super(3);
this.smtp = smtp;
}
@Override
protected void deliver(Message message) {
smtp.send(message.to(), message.body());
}
}Treat these listings as illustrative: they’re plain Java 17, written carefully but not compiled for this page, and Message, SmtpClient and DeliveryException stand in for your own types.
Each piece is doing something only an abstract class can do. The constructor validates maxAttempts once for every subclass. The private fields keep retry bookkeeping out of subclass hands. The protected abstract deliver method is a hook for subclasses that isn’t part of the public API, and interface methods can’t be protected. Marking send as final is the template method pattern: the algorithm is fixed and only one step varies.
Callers still depend on Notifier. A Slack notifier with its own retry strategy can implement Notifier directly without inheriting any of this, and a test can pass Notifier.noop(). If the code had used only the abstract class as the type everywhere, every implementation would be forced into the retry hierarchy.
What default methods changed, and what they didn’t
Default methods exist so interfaces can evolve. Java 8 added forEach to Iterable and stream() to Collection; without defaults, every existing collection class in the world would have stopped compiling. They also let you put small conveniences next to the contract, like Comparator.reversed() and thenComparing.
They didn’t give interfaces state. A default method can only call other methods of the interface (or static helpers). It can’t remember anything between calls, can’t have a constructor, and can’t be protected. Saying “since Java 8 interfaces and abstract classes are basically the same” is the misconception interviewers are fishing for.
Multiple inheritance of behaviour does bring the diamond problem back, and Java settles it with three rules:
- Classes win. A method inherited from a superclass beats any interface default with the same signature.
- More specific interfaces win. If
B extends Aand both define the default,B’s version is used. - Otherwise the class must choose. Two unrelated interfaces with the same default force the class to override:
interface Walker { default String move() { return "walk"; } }
interface Swimmer { default String move() { return "swim"; } }
class Duck implements Walker, Swimmer {
@Override
public String move() {
return Walker.super.move() + " and " + Swimmer.super.move(); // "walk and swim"
}
}Without the override, javac rejects Duck with an error saying it inherits unrelated defaults for move() from Walker and Swimmer. The Interface.super.method() syntax is how you call a specific parent’s default.
One more restriction worth knowing: an interface can’t declare a default equals, hashCode or toString. Rule 1 means Object’s versions would always win, so the compiler refuses rather than letting you write code that never runs.
Sealed types: a separate axis
Java 17’s sealed keyword limits which classes may extend or implement a type. It applies to interfaces and abstract classes equally, so it doesn’t settle the comparison; it adds a second question: should this hierarchy be open to anyone, or closed to a known list?
A closed hierarchy is ideal for results and messages, where the code that consumes them needs to handle every case:
public sealed interface PaymentResult permits Approved, Declined, NeedsReview { }
public record Approved(String authCode) implements PaymentResult { }
public record Declined(String reason) implements PaymentResult { }
public record NeedsReview(Duration slaWindow) implements PaymentResult { }
static String describe(PaymentResult result) {
return switch (result) { // no default branch needed
case Approved a -> "approved " + a.authCode();
case Declined d -> "declined: " + d.reason();
case NeedsReview n -> "review within " + n.slaWindow().toHours() + "h";
};
}Records are implicitly final, so they satisfy the rule that each permitted subtype is final, sealed or non-sealed. Pattern matching in switch became a standard feature in Java 21, and with a sealed parent the compiler checks the switch is exhaustive: add a Refunded record to the permits list and every switch that forgot it stops compiling. A sealed interface is usually the better choice here, because records can’t extend classes, so a sealed abstract class can’t have record subtypes.
How to choose in real code
Start with an interface for anything other code depends on: services, repositories, strategies, callbacks. It keeps implementations swappable and makes testing easy. Add an abstract class only when several implementations genuinely share state or a fixed algorithm, and have it implement the interface rather than replace it.
Signs you picked wrong: an abstract class with no fields and only abstract methods (that’s an interface wearing a costume), or an interface whose default methods keep wishing they could store something (that logic wants a class). Deep abstract class hierarchies are another warning; composition, where a class holds a helper object instead of inheriting from it, is often cleaner than a third level of extends.
Follow-ups you might get, with short answers:
- Can an abstract class have no abstract methods? Yes. It just can’t be instantiated, which is sometimes the whole point.
- Can an abstract class have a constructor if you can’t create it? Yes, and subclasses call it through
super(...). - Can an interface have private methods? Since Java 9, yes, to share code between default methods.
- Why can’t a lambda implement an abstract class? Lambdas target functional interfaces only; a class may need constructor arguments and state that a lambda can’t provide.
Interview exercise: extending a published API
Your team publishes a library with public interface RateLimiter { boolean tryAcquire(); }. Dozens of other teams implement it. You now want to add tryAcquire(int permits). How do you do it without breaking them, and would an abstract class have been a better original choice?
Answer
Add it as a default method with a reasonable fallback:
public interface RateLimiter {
boolean tryAcquire();
default boolean tryAcquire(int permits) {
for (int i = 0; i < permits; i++) {
if (!tryAcquire()) {
return false; // note: permits already taken are not returned
}
}
return true;
}
}Existing implementations keep compiling and inherit the fallback; implementations that can acquire several permits atomically override it. Point out the weakness honestly: the default isn’t atomic, so document it and encourage overriding.
An abstract class would have allowed the same evolution (add a concrete method), but at a high cost: every implementer would have used up their only superclass on your library, and none could implement RateLimiter alongside extending their own base class. Keeping the interface, and optionally offering an AbstractRateLimiter for shared plumbing, gives callers the stable contract and implementers the choice.