Interface or abstract class: which boundary fits?

Choose an interface for a capability contract or an abstract class for shared family state, setup and behavior. Inheritance and polymorphism · Object-oriented programming.

A receipt service must send the same receipt through a screen or through a locker, and another part of the program must send reports that all start by writing the same audit label. Java gives two tools for these two needs: an interface, which lists what a type can do, and an abstract class, which is an unfinished parent that supplies shared state and setup. Watch what the client must know, then what the family must share.

Start with the capability

Imagine a receipt service that can deliver through a screen or a locker. The client only needs the deliver capability. The two channels are not one natural family, and the client should not depend on either concrete class.

One capability, two receipt channels

Read the steps as text

  1. ReceiptChannel is an interface. ScreenChannel implements ReceiptChannel. LockerChannel implements ReceiptChannel. The client has not run yet.
  2. The client declares channel with the type ReceiptChannel and stores a ScreenChannel object in it.
  3. The client calls channel.deliver("R-18"). The compiler checks deliver against the declared type ReceiptChannel, which offers it.
  4. Through the ReceiptChannel port, the ScreenChannel object runs its own deliver.
  5. The method deliver in ScreenChannel returns "screen: R-18".
  6. The client stores a new LockerChannel object in channel; the type ReceiptChannel stays the same.
  7. The client repeats channel.deliver("R-18"). The compiler checks deliver against ReceiptChannel again, and the declared type has not changed.
  8. Through the ReceiptChannel port, the LockerChannel object runs its own deliver.
  9. The method deliver in LockerChannel returns "locker: R-18".

The ReceiptChannel interface is a contract. A variable typed as that interface can refer to either implementation that declares implements ReceiptChannel. Changing the object changes the delivery detail while the client-facing operation stays stable. The interface boundary does not require the implementations to share a superclass or instance fields.

interface ReceiptChannel {
    String deliver(String receipt);
}

final class ScreenChannel implements ReceiptChannel {
    public String deliver(String receipt) { return "screen: " + receipt; }
}

final class LockerChannel implements ReceiptChannel {
    public String deliver(String receipt) { return "locker: " + receipt; }
}

ReceiptChannel channel = new ScreenChannel();
channel.deliver("R-18");
from abc import ABC, abstractmethod

class ReceiptChannel(ABC):
    @abstractmethod
    def deliver(self, receipt):
        pass

class ScreenChannel(ReceiptChannel):
    def deliver(self, receipt):
        print("screen:", receipt)

channel = ScreenChannel()
channel.deliver("R-18")

One class can promise more than one capability: class ScreenChannel implements ReceiptChannel, Archivable. Each interface is a separate view of the same object; call the set of methods a variable's declared type offers that variable's port. A variable typed ReceiptChannel has a port that shows only deliver, so channel.archive("R-18") does not compile even though the object has archive; assign the same object to an Archivable variable to reach it.

One object, two ports

Read the steps as text

  1. ReceiptChannel is an interface. Archivable is an interface. ScreenChannel implements ReceiptChannel and Archivable. The client has not run yet.
  2. The client declares s with the type ScreenChannel and stores a ScreenChannel object in it.
  3. The client declares c with the type ReceiptChannel, and it points at the same ScreenChannel object as s. ReceiptChannel offers only deliver.
  4. The client declares ar with the type Archivable, and it points at the same ScreenChannel object as s. Archivable offers only archive.
  5. The client calls c.deliver("R-18"). The compiler checks deliver against the declared type ReceiptChannel, which offers it.
  6. Through the ReceiptChannel port, the ScreenChannel object runs its own deliver. The method deliver in ScreenChannel returns "screen: R-18".
  7. The client calls c.archive("R-18"). The compiler checks archive against the declared type ReceiptChannel. It has no archive. The object behind it is not consulted.
  8. The call is rejected at compile time, so no method runs.
  9. The client calls ar.archive("R-18"). The compiler checks archive against the declared type Archivable, which offers it.
  10. Through the Archivable port, the ScreenChannel object runs its own archive. The method archive in ScreenChannel returns "archived".
interface Archivable { String archive(String receipt); }

final class ScreenChannel implements ReceiptChannel, Archivable {
    public String deliver(String receipt) { return "screen: " + receipt; }
    public String archive(String receipt) { return "archived"; }
}

ScreenChannel s = new ScreenChannel();
ReceiptChannel c = s;
Archivable ar = s;
c.deliver("R-18");
ar.archive("R-18");

The Python tab is a qualified comparison: ABC and @abstractmethod express a Python abstract base class, not a Java interface. Python ABCs can also hold concrete methods and instance setup. At run time Python refuses to create an object from an ABC, or from a subclass that has not implemented every @abstractmethod, and raises TypeError. Python also has typing.Protocol, a structural contract: any class with a matching deliver method satisfies it, with no implements or base class, and normally only a type checker enforces it (isinstance works only if the Protocol is marked @runtime_checkable, and then it only checks that the method exists). Neither is a Java interface.

The method kinds have different jobs

An interface can share a small behavior without becoming a stateful base class. The method kind tells you what the implementing object can receive.

One interface, four method kinds

Read the steps as text

  1. ReceiptChannel is an interface. ScreenChannel implements ReceiptChannel. The client has not run yet.
  2. The client declares channel with the type ScreenChannel and stores a ScreenChannel object in it.
  3. The client calls channel.preview("R-18"). The compiler checks preview against the declared type ScreenChannel, which offers it.
  4. The ScreenChannel object has no preview of its own, so it receives the default one that ReceiptChannel supplies.
  5. The body of preview calls trim, which ReceiptChannel holds as a private helper. The method preview in ReceiptChannel returns "preview: R-18".
  6. The client calls channel.label(). The compiler checks label against the declared type ScreenChannel. A static interface method is called through the interface name, not through an object.
  7. The call is rejected at compile time because static methods are not inherited. It is reached as ReceiptChannel.label instead.
  8. The client calls channel.trim("R-18"). The compiler checks trim against the declared type ScreenChannel. A private method can only be named inside its own interface.
  9. The call is rejected at compile time because a private method belongs to its interface alone. Only bodies inside it reach trim.
  10. The client calls ReceiptChannel.label(). The compiler checks label against the interface name ReceiptChannel, which declares it as static. The static method label belongs to ReceiptChannel itself, so no object takes part.
  11. The method label in ReceiptChannel returns "receipt".
  12. The program tries new ReceiptChannel(). The compiler refuses to build the interface ReceiptChannel, which has no constructor.

A Java interface method with no body and no default, static or private modifier is implicitly public and abstract. A default method has a body and is inherited as instance behavior when an implementation does not override it. A static method is called through the interface name, such as ReceiptChannel.label(), and is not inherited by implementing objects. A private method is a helper for the interface’s own method bodies; it is neither inherited nor overridden.

interface ReceiptChannel {
    int MAX_LENGTH = 40;
    String deliver(String receipt);
    default String preview(String r) { return "preview: " + trim(r); }
    private String trim(String r) { return r.length() > MAX_LENGTH ? r.substring(0, MAX_LENGTH) : r; }
    static String label() { return "receipt"; }
}

With ScreenChannel channel, channel.preview("R-18") works, but channel.label() does not compile; write ReceiptChannel.label().

The interface may declare fields, but each is implicitly public static final and must be initialized. That is a constant shared by the type, not a separate field on every channel. Constructors belong to classes. A class implementing the interface can have its own fields and constructors.

The compact distinction matters: default shares behavior, while an abstract class can share behavior and per-object state. If two unrelated interfaces contribute conflicting defaults, Java requires an override to resolve the conflict; the later inheritance diamond lesson walks through the full conflict path.

A family with shared setup

Now consider two report channels from one family. Every channel records the same audit label and uses the same audit operation, but each concrete channel supplies its own send operation. That is a natural place for an abstract class.

One family, shared setup

Read the steps as text

  1. AuditedChannel is an abstract class. ScreenReport extends AuditedChannel. The client has not run yet.
  2. The program tries new AuditedChannel("x"). The compiler refuses to build the abstract class AuditedChannel, even though it declares a constructor.
  3. The constructor of the abstract class AuditedChannel runs first. It sets auditLabel to "screen".
  4. The constructor of ScreenReport runs next.
  5. The client declares report with the type AuditedChannel and stores a ScreenReport object in it.
  6. The client calls report.send("R-18"). The compiler checks send against the declared type AuditedChannel, which offers it.
  7. The ScreenReport object behind the AuditedChannel variable runs its own send.
  8. The body of send calls audit, which AuditedChannel holds.
  9. The method send in ScreenReport returns "screen: R-18".

AuditedChannel cannot be constructed directly because it is abstract; new AuditedChannel("x") fails to compile even though the class has a constructor, because that constructor exists only to be run by a subclass. When ScreenReport is constructed, its abstract superclass constructor runs first and initializes the inherited auditLabel field. The concrete subclass then supplies send. This is shared family setup, not a promise that every unrelated type belongs in the same hierarchy.

abstract class AuditedChannel {
    protected final String auditLabel;

    protected AuditedChannel(String auditLabel) {
        this.auditLabel = auditLabel;
    }

    protected String audit(String receipt) {
        return auditLabel + ": " + receipt;
    }

    abstract String send(String receipt);
}

final class ScreenReport extends AuditedChannel {
    ScreenReport() { super("screen"); }
    String send(String receipt) { return audit(receipt); }
}
from abc import ABC, abstractmethod

class AuditedChannel(ABC):
    def __init__(self, audit_label):
        self.audit_label = audit_label

    def audit(self, receipt):
        print(self.audit_label, receipt)

    @abstractmethod
    def send(self, receipt):
        pass

class ScreenReport(AuditedChannel):
    def __init__(self):
        super().__init__("screen")

    def send(self, receipt):
        self.audit(receipt)

The Java and Python examples share a teaching shape, not a feature-by-feature promise. Python’s ABC machinery decides whether the abstract method is satisfied; Java’s abstract class and constructor rules are the Java claims shown here.

Ask two questions before choosing. Can a type satisfy the operation without inheriting the same data and setup? That points toward an interface. Must every member of one family receive the same invariant state and preparation? That points toward an abstract class. A default method can support a small shared behavior, but it does not answer the second question because it cannot add interface instance state or a constructor.

Recap

  • An interface names a capability that different implementations can provide, and a class can provide several; each variable sees only its own interface's methods.
  • Java default shares instance behavior; static and private methods stay qualified to the interface or its own bodies.
  • An abstract class is a partial family implementation when state, constructor setup or concrete behavior belongs to every subclass.
Go deeper

An interface default method can reduce repeated behavior, but it does not turn interface constants into per-instance fields. A default conflict is a contract-resolution problem: the implementing class or a more specific interface must supply the decision. Keep that conflict separate from the simpler choice taught here.

Python’s abc module also permits an abstract method to contain an implementation that a subclass may reach through super(). A registered virtual subclass receives the ABC relationship without receiving its method implementations. That is why a Python ABC is a useful comparison, not a Java interface mirror.

Continue to method dispatch to trace what a later call does with the boundary you chose. Practise choosing a small operation contract through enqueue and dequeue in Queue Fundamentals.