Java Interfaces vs Abstract Classes: Contracts, Default Methods, and Sealed Types
โShould this be an interface or an abstract class?โ is one of those questions every Java developer answers weekly, often on autopilot. Getting it right shapes how testable, swappable, and future-proof your code is; getting it wrong calcifies designs that canโt evolve.
This guide explains what interfaces really are (contracts, not classes-lite), what default methods changed, why abstract classes still exist, and how sealed types (Java 17) added a third option nobody had for twenty years.
Interfaces: Capability as a Contract
An interface declares what something can do, saying nothing about how:
public interface PaymentGateway { Receipt charge(String accountId, long amountPaise); boolean supportsCurrency(String isoCode);}Any class can promise the contract with implements, and must then supply every method:
public class RazorpayGateway implements PaymentGateway { @Override public Receipt charge(String accountId, long amountPaise) { /* real API calls */ }
@Override public boolean supportsCurrency(String isoCode) { return "INR".equals(isoCode); }}
public class FakeGateway implements PaymentGateway { // for tests @Override public Receipt charge(String accountId, long amountPaise) { return Receipt.approved("test-" + accountId); } @Override public boolean supportsCurrency(String isoCode) { return true; }}And hereโs the entire reason interfaces exist โ code written against the contract works with every implementation, including ones that donโt exist yet:
public class CheckoutService { private final PaymentGateway gateway; // the interface, never a concrete class
public CheckoutService(PaymentGateway gateway) { // implementation injected this.gateway = gateway; }
public void checkout(Cart cart) { gateway.charge(cart.accountId(), cart.totalPaise()); }}CheckoutService compiles with zero knowledge of Razorpay. Swap gateways, wire in the fake for unit tests, add a new provider next year โ the service never changes. This is โprogram to an interface, not an implementationโ, and itโs the single most valuable design habit in this whole series. Itโs also the backbone of dependency injection frameworks like Spring: theyโre industrial machinery for handing implementations to constructors exactly like this.
Mechanics worth knowing:
- Interface methods are implicitly
public abstract; fields are implicitlypublic static final(constants only โ an interface holds no state). - A class implements many interfaces, while extending only one class.
class Cache implements Closeable, Iterable<Entry>is normal and useful. - Interfaces extend other interfaces, and can extend several.
Default Methods: The Java 8 Earthquake
For Javaโs first two decades, adding a method to a published interface broke every implementor on the planet. That made interfaces effectively frozen โ until Java 8 introduced default methods, bodies in the interface itself:
public interface PaymentGateway { Receipt charge(String accountId, long amountPaise); boolean supportsCurrency(String isoCode);
default Receipt chargeInr(String accountId, long amountPaise) { if (!supportsCurrency("INR")) throw new UnsupportedOperationException("INR not supported"); return charge(accountId, amountPaise); }}Existing implementors keep compiling; they inherit the default and may override it. This is precisely how List gained sort, Collection gained stream, and Iterable gained forEach in Java 8 without breaking thirty years of code.
Guidelines that keep defaults healthy:
- Defaults should be conveniences expressed via the abstract methods, like
chargeInrabove โ not core logic, and never dependent on state (interfaces have none). - Interfaces can also carry
statichelper methods (PaymentGateway.validate(...)) and, since Java 9,privatemethods to share code between defaults. - If two implemented interfaces provide the same default signature, the class must override it (it may pick one explicitly with
PaymentGateway.super.chargeInr(...)). Java thus dodges the diamond problem by forcing you to resolve conflicts, not by guessing.
Functional Interfaces: One Method, Big Consequences
An interface with exactly one abstract method is a functional interface, and lambdas can implement it inline:
@FunctionalInterfacepublic interface RetryPolicy { boolean shouldRetry(int attempt, Exception lastError);}
RetryPolicy threeTimes = (attempt, err) -> attempt < 3;RetryPolicy never = (attempt, err) -> false;The @FunctionalInterface annotation is optional but valuable โ it makes the compiler reject a second abstract method, protecting every lambda that depends on there being exactly one.
The JDK ships the vocabulary youโll use daily โ Predicate<T> (T โ boolean), Function<T,R> (T โ R), Supplier<T> (() โ T), Consumer<T> (T โ void), Comparator<T>. Check java.util.function before inventing your own; a custom name only earns its place when it adds domain meaning, like RetryPolicy. Full treatment in Streams and Lambdas.
Abstract Classes: When Shared State Enters the Picture
An abstract class is a partial implementation: it can hold fields, constructors, and a mix of concrete and abstract methods, but canโt be instantiated:
public abstract class BaseRepository<T> { protected final DataSource ds; // shared state โ interfaces can't do this private final Map<String, T> cache = new HashMap<>();
protected BaseRepository(DataSource ds) { this.ds = ds; }
public T findById(String id) { // shared logic with caching return cache.computeIfAbsent(id, this::load); }
protected abstract T load(String id); // each repository fills this in}Subclasses (UserRepository, OrderRepository) inherit the caching machinery and implement only load. An interface canโt express this โ no fields, no constructor, nowhere to put the cache.
So the honest division of labor:
| Question | Interface | Abstract class |
|---|---|---|
| Shared instance state? | โ | โ fields + constructors |
| Multiple inheritance? | โ implement many | โ extend one |
| Unrelated classes can join? | โ (Comparable spans everything) | impractical |
| Lambda-friendly? | โ if single-method | โ |
| Evolves without breaking users? | โ via defaults | โ via new concrete methods |
Working rule: define the type as an interface โ thatโs what callers and tests depend on. Add an abstract class alongside it only when several implementations genuinely share state or plumbing (AbstractList next to List is the JDKโs own template). Starting with an abstract class as the public type costs your users their single extends slot and couples them to your state โ make that trade knowingly, not by default.
Sealed Interfaces: Closing the Door on Purpose (Java 17)
Classic interfaces are open โ anyone, anywhere can implement them. Usually thatโs the point. But some domains are closed by nature: a payment result is approved, declined, or errored. Nothing else exists. Sealed types let you say so:
public sealed interface PaymentResult permits Approved, Declined, Errored { }
public record Approved(String txnId, long amountPaise) implements PaymentResult { }public record Declined(String reason) implements PaymentResult { }public record Errored(Exception cause) implements PaymentResult { }The compiler now knows the complete set of subtypes โ which unlocks exhaustive switch with no default:
String message = switch (result) { case Approved a -> "Paid: " + a.txnId(); case Declined d -> "Declined: " + d.reason(); case Errored e -> "Retry later";}; // no default needed โ and that's a featureAdd a fourth result type next quarter, and every switch like this fails to compile until you handle it. Compare that with a default branch, which would silently swallow the new case. Sealed hierarchies plus records plus pattern matching is modern Javaโs answer to algebraic data types, and itโs changing how domain models are written โ states as a fixed family of small immutable types, behavior as exhaustive switches.
(A permitted subtype must itself be final, sealed, or explicitly non-sealed; records are implicitly final, which is why they pair so naturally.)
Choosing in Practice: Three Worked Calls
A notification sender with email/SMS/push variants. Callers need send(msg); implementations share nothing but the contract. โ Interface (NotificationSender), one class per channel, injected where needed. Tests use a lambda or fake.
Report generators that all load data, render differently, and share formatting helpers and a template skeleton. Shared state (formatter config) and shared partial logic. โ Interface for the public type (ReportGenerator) if outsiders depend on it, plus an abstract class (AbstractReportGenerator) providing the skeleton; concrete generators extend it. If itโs all internal, the abstract class alone is fine.
The states of an order: Placed, Shipped, Delivered, Cancelled. Fixed, known set; per-state data differs; logic wants exhaustive handling. โ Sealed interface + records, behavior in pattern-matching switches.
Notice the pattern in all three: the decision driver is never โwhich feels more OOโ โ itโs who needs to depend on what, and whether the set of subtypes is open or closed.
The Interfaces You Already Depend On
Worth a tour, because implementing the JDKโs own contracts is how your classes plug into the platformโs machinery:
Comparable<T> โ โI have a natural order.โ Implement compareTo, and your type works with Collections.sort, TreeSet, TreeMap, and Stream.sorted() with no further ceremony. Implement it consistently with equals (the sorted collections use compareTo for equality decisions โ inconsistency makes a TreeSet disagree with a HashSet about membership, a genuinely disorienting bug).
Comparator<T> โ external, composable ordering for types you donโt own or orders beyond the natural one. Modern construction is entirely via factories: Comparator.comparing(User::lastName).thenComparing(User::firstName).
Iterable<T> โ one method (iterator()), and the enhanced for loop works on your type. Any custom collection-ish class earns for-each support this cheaply.
AutoCloseable โ one method (close()), and try-with-resources manages your typeโs lifecycle. If your class holds a connection, file handle, or native resource, implementing this is not optional politeness; itโs how callers avoid leaking you.
Runnable / Callable<T> โ the task contracts every executor consumes. Their existence as interfaces is why lambdas slot straight into thread pools.
The pattern to absorb: the JDKโs collaboration points are all interfaces, almost all tiny. Your own architecture should look the same โ the seams between modules are one- and two-method interfaces, and concrete classes meet only at wiring time.
Frequently Asked Questions
Can an interface have constructors or instance fields? No โ interfaces hold no per-instance state, which is precisely what keeps multiple inheritance of interfaces safe. public static final constants are allowed but use them sparingly; an interface as a constants dumping ground is a recognized antipattern (put constants in the class that owns them, or an enum).
What happens if a class implements two interfaces that both declare the same abstract method? Nothing bad โ one implementation satisfies both contracts, since neither interface supplied behavior. The conflict rules only kick in when defaults collide, and then the compiler forces you to override and choose.
Should every class get an interface? No โ thatโs the โinterface for everythingโ cargo cult, and it doubles your file count for zero flexibility. Extract an interface when there are (or will credibly be) multiple implementations, when you need to break a dependency for testing, or when youโre defining a plugin point. A class with exactly one conceivable implementation can just be a class; refactoring to an interface later is mechanical.
Abstract class with all abstract methods vs. interface โ any difference? Almost none functionally, but the interface costs users nothing (their extends slot stays free) and gains lambda compatibility if itโs single-method. Choose the interface; itโs why the JDKโs own single-method contracts are all interfaces.
Self-Check
- Why can
CheckoutServicebe unit-tested without any payment provider SDK installed? - Your published interface needs a new method and has 40 implementors across teams. What Java 8 feature saves you, and whatโs its constraint?
- A colleague makes
AbstractPaymentGatewaythe type every caller references. What flexibility did callers just lose? - When is omitting
defaultfrom a switch a safety feature rather than sloppiness?
(Answers: it depends only on the PaymentGateway interface, so a fake implementation suffices. Default methods; they canโt use instance state and should delegate to the abstract methods. Their single extends slot, and decoupling from the base classโs state and constructors. When switching over a sealed hierarchy or enum โ the compiler then flags unhandled new cases instead of a default silently absorbing them.)
Next: Encapsulation, Records, and Immutability โ access modifiers done right, why immutable objects simplify everything, and the record syntax that deleted a thousand lines of boilerplate.