Skip to main content

Interfaces vs Abstract Classes

Introduction

You're designing a payment system. Every payment method — credit card, PayPal, crypto — must support authorize() and charge(). But some share common logic (like logging every transaction) while others are completely independent.

Should you use an interface to define the contract? Or an abstract class to share common behavior? This decision comes up in every LLD interview and every real-world codebase.

In S3, we learned about abstraction — hiding complexity behind a public API. Now we'll explore the two primary mechanisms for achieving abstraction in object-oriented design: interfaces and abstract classes. Understanding when to use each is critical for building clean, extensible systems.

By the end of this tutorial, you'll have a clear decision framework for choosing between them — a skill you'll use repeatedly when we apply design patterns in S10–S12.

What Is an Interface?

An interface is a pure contract. It declares what a class must do, but provides zero implementation.

Think of it like a job description. A company posts: "Must be able to cook, plate food, and manage inventory." The job description doesn't tell you how to cook — it lists the required capabilities. Any chef who meets those requirements can fill the role, regardless of their cooking style.

In code terms:

  • An interface defines method signatures (name, parameters, return type)
  • It provides no method bodies — implementing classes must supply all logic
  • A class can implement multiple interfaces (a chef can fulfill multiple job descriptions)
  • Interfaces cannot hold instance state (no attributes that store data)

Why interfaces exist: They solve the problem of disparate classes needing to share a common capability without sharing ancestry. A CreditCardPayment and a CryptoPayment have nothing in common structurally — different data, different processing pipelines — but both must be "chargeable." An interface captures this shared capability without forcing them into the same class hierarchy.

What Is an Abstract Class?

An abstract class is a partial blueprint. It declares some methods that subclasses must implement, but it can also provide shared logic that all subclasses inherit.

Think of it like a recipe template at a restaurant chain. The corporate recipe specifies the sauce, the cooking temperature, and the plating guidelines — every branch follows these. But each branch can customize toppings and garnish. The template provides the common foundation; branches fill in the customizable parts.

In code terms:

  • An abstract class can have abstract methods (no body — subclasses must implement)
  • It can also have concrete methods (full implementation — subclasses inherit for free)
  • It can hold instance state (attributes, constructors)
  • A class can extend only one abstract class (single inheritance in most languages)
  • You cannot instantiate an abstract class directly — it exists to be extended

Why abstract classes exist: They solve the problem of related classes that share both a contract AND behavior. If every notification channel (email, SMS, push) must log before sending and track delivery status, duplicating that logic in each subclass violates DRY (S7). An abstract class provides the shared behavior while requiring each subclass to implement its own send() logic.

Key Differences — Side by Side

FeatureInterfaceAbstract Class
PurposeDefine a contract (what to do)Provide a partial blueprint (what + some how)
Method bodiesNone (pure signatures only)Mix of abstract and concrete methods
State (attributes)No instance variablesCan have instance variables
ConstructorNoYes
Multiple inheritanceA class can implement many interfacesA class can extend only one abstract class
Access modifiersAll methods implicitly publicCan have public, protected, private methods
When to useUnrelated classes share a capabilityRelated classes share common behavior
UML notation<<interface>> stereotype, dashed arrowItalic class name, solid arrow

The fundamental question is: Do your classes share behavior, or just a contract?

  • If they share behavior (common code) → abstract class
  • If they only share a contract (same method signatures but completely different implementations) → interface
  • If you need multiple type identities → interface (since a class can implement many)

Comparison Chart

Comparison chart showing interfaces as pure contracts on the left and abstract classes as partial blueprints on the right, with feature differences highlighted
Comparison chart showing interfaces as pure contracts on the left and abstract classes as partial blueprints on the right, with feature differences highlighted

Visualization

Interfaces vs Abstract Classes — When to Choose Which

Code Example

Let's build a notification system that demonstrates both concepts working together. We'll use an interface for the sending contract (since SMS, email, and push use completely different send mechanisms) and an abstract class for shared behavior (logging, retry logic).

from abc import ABC, abstractmethod
from typing import Dict, Any, List
from datetime import datetime


# Interface — pure contract for anything that can send messages
class Sendable(ABC):
    """Interface: defines WHAT a sender must do, not HOW."""

    @abstractmethod
    def send(self, recipient: str, message: str) -> bool:
        pass

    @abstractmethod
    def validate_recipient(self, recipient: str) -> bool:
        pass


# Abstract class — shared behavior for notification channels
class NotificationChannel(Sendable):
    """Abstract class: provides shared behavior + enforces contract."""

    def __init__(self, channel_name: str, max_retries: int = 3):
        self._channel_name = channel_name
        self._max_retries = max_retries
        self._sent_count = 0

    # Concrete method — shared by ALL subclasses (inherited for free)
    def log(self, message: str) -> None:
        timestamp = datetime.now().strftime("%H:%M:%S")
        print(f"  [{timestamp}] [{self._channel_name}] {message}")

    # Concrete method — retry logic shared across all channels
    def send_with_retry(self, recipient: str, message: str) -> bool:
        if not self.validate_recipient(recipient):
            self.log(f"Invalid recipient: {recipient}")
            return False

        for attempt in range(1, self._max_retries + 1):
            self.log(f"Attempt {attempt}/{self._max_retries}")
            if self.send(recipient, message):
                self._sent_count += 1
                self.log(f"Sent successfully (total: {self._sent_count})")
                return True
            self.log(f"Attempt {attempt} failed")

        self.log("All retries exhausted")
        return False

    # send() and validate_recipient() remain abstract — from Sendable


# Concrete implementations — each provides its OWN send/validate logic
class EmailChannel(NotificationChannel):
    def __init__(self):
        super().__init__("EMAIL")

    def send(self, recipient: str, message: str) -> bool:
        self.log(f"Sending email to {recipient}: {message}")
        return True  # Simulated success

    def validate_recipient(self, recipient: str) -> bool:
        return "@" in recipient and "." in recipient


class SMSChannel(NotificationChannel):
    def __init__(self):
        super().__init__("SMS")

    def send(self, recipient: str, message: str) -> bool:
        self.log(f"Sending SMS to {recipient}: {message[:160]}")
        return True

    def validate_recipient(self, recipient: str) -> bool:
        return recipient.startswith("+") and len(recipient) >= 10


class PushChannel(NotificationChannel):
    def __init__(self):
        super().__init__("PUSH")

    def send(self, recipient: str, message: str) -> bool:
        self.log(f"Pushing to device {recipient}: {message}")
        return True

    def validate_recipient(self, recipient: str) -> bool:
        return len(recipient) == 36  # UUID device token


# Usage — polymorphism through both interface and abstract class
def notify_user(channels: List[NotificationChannel], recipient_map: Dict[str, str], message: str) -> None:
    for channel in channels:
        channel_type = type(channel).__name__.replace("Channel", "").lower()
        if channel_type in recipient_map:
            channel.send_with_retry(recipient_map[channel_type], message)


channels = [EmailChannel(), SMSChannel(), PushChannel()]
recipients = {
    "email": "alice@example.com",
    "sms": "+1234567890",
    "push": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}

notify_user(channels, recipients, "Your order #1234 has shipped!")
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;

// Interface — pure contract for anything that can send messages
interface Sendable {
    boolean send(String recipient, String message);
    boolean validateRecipient(String recipient);
}

// Abstract class — shared behavior for notification channels
abstract class NotificationChannel implements Sendable {
    private final String channelName;
    private final int maxRetries;
    private int sentCount = 0;

    protected NotificationChannel(String channelName, int maxRetries) {
        this.channelName = channelName;
        this.maxRetries = maxRetries;
    }

    protected NotificationChannel(String channelName) {
        this(channelName, 3);
    }

    // Concrete method — shared by ALL subclasses
    protected void log(String message) {
        String timestamp = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
        System.out.printf("  [%s] [%s] %s%n", timestamp, channelName, message);
    }

    // Concrete method — retry logic shared across all channels
    public boolean sendWithRetry(String recipient, String message) {
        if (!validateRecipient(recipient)) {
            log("Invalid recipient: " + recipient);
            return false;
        }

        for (int attempt = 1; attempt <= maxRetries; attempt++) {
            log(String.format("Attempt %d/%d", attempt, maxRetries));
            if (send(recipient, message)) {
                sentCount++;
                log("Sent successfully (total: " + sentCount + ")");
                return true;
            }
            log("Attempt " + attempt + " failed");
        }

        log("All retries exhausted");
        return false;
    }

    // send() and validateRecipient() remain abstract — from Sendable
}

// Concrete implementations
class EmailChannel extends NotificationChannel {
    public EmailChannel() {
        super("EMAIL");
    }

    @Override
    public boolean send(String recipient, String message) {
        log("Sending email to " + recipient + ": " + message);
        return true;
    }

    @Override
    public boolean validateRecipient(String recipient) {
        return recipient.contains("@") && recipient.contains(".");
    }
}

class SMSChannel extends NotificationChannel {
    public SMSChannel() {
        super("SMS");
    }

    @Override
    public boolean send(String recipient, String message) {
        log("Sending SMS to " + recipient + ": " + message.substring(0, Math.min(160, message.length())));
        return true;
    }

    @Override
    public boolean validateRecipient(String recipient) {
        return recipient.startsWith("+") && recipient.length() >= 10;
    }
}

class PushChannel extends NotificationChannel {
    public PushChannel() {
        super("PUSH");
    }

    @Override
    public boolean send(String recipient, String message) {
        log("Pushing to device " + recipient + ": " + message);
        return true;
    }

    @Override
    public boolean validateRecipient(String recipient) {
        return recipient.length() == 36; // UUID device token
    }
}

public class Main {
    public static void notifyUser(NotificationChannel[] channels, Map<String, String> recipients, String message) {
        for (NotificationChannel channel : channels) {
            String type = channel.getClass().getSimpleName().replace("Channel", "").toLowerCase();
            if (recipients.containsKey(type)) {
                channel.sendWithRetry(recipients.get(type), message);
            }
        }
    }

    public static void main(String[] args) {
        NotificationChannel[] channels = { new EmailChannel(), new SMSChannel(), new PushChannel() };
        Map<String, String> recipients = Map.of(
            "email", "alice@example.com",
            "sms", "+1234567890",
            "push", "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
        );

        notifyUser(channels, recipients, "Your order #1234 has shipped!");
    }
}

The Decision Framework

When deciding between an interface and an abstract class, ask yourself these three questions in order:

Question 1: Do the classes share code (not just method signatures)?

If yes → abstract class. Shared logging, validation, state management, retry logic — these are concrete behaviors that should live in one place. If no → interface.

Question 2: Are the classes in the same "family" (is-a relationship)?

If yes → abstract class. EmailChannel, SMSChannel, and PushChannel are all notification channels — they share identity, not just capability. If no (e.g., a Printer and a Logger both have output() but are unrelated) → interface.

Question 3: Does the class need to fulfill multiple contracts?

If yes → at least one must be an interface (since most languages allow only single class inheritance). In practice, the answer is often use both: an abstract class that implements one or more interfaces.

The "both" pattern is extremely common in design patterns:

  • Strategy Pattern (S12): interface Strategy defines the contract, abstract class BaseStrategy provides shared utilities
  • Observer Pattern (S12): interface Observer for the contract, base class for common notification tracking
  • Template Method (S12): abstract class is the core mechanism (defines the algorithm skeleton)

Common Mistakes

Mistake 1: Using an Abstract Class When You Need Multiple Contracts

The problem: Your PaymentProcessor extends AbstractProcessor but also needs to be Auditable and Refundable. With single inheritance, you're stuck.

The fix: Define Auditable and Refundable as interfaces. Have AbstractProcessor implement them, or have the concrete class implement them directly.

Mistake 2: Using an Interface When Classes Share Common Logic

The problem: Five notification classes each implement Loggable and duplicate the exact same logging code in all five.

The fix: Use an abstract class that provides the shared log() method. The interface forced you to duplicate code — the abstract class eliminates it.

Mistake 3: Creating an Abstract Class with ONLY Abstract Methods

The problem: Your abstract class has zero concrete methods — every method is abstract. This is just an interface with extra restrictions (single inheritance).

The fix: If there's no shared code, use an interface. An abstract class with no concrete methods is an interface that restricts your consumers unnecessarily.

Mistake 4: Thinking Python ABC is the Same as Java Interface

The problem: Python's ABC (Abstract Base Class) is technically an abstract class, not an interface — it supports state, constructors, and concrete methods. Python doesn't have a built-in interface keyword.

The fix: In Python, you simulate interfaces by creating ABCs with ONLY @abstractmethod methods and no state. Convention over language enforcement.

When to Use vs When NOT to Use

Use an interface when:

  • Unrelated classes need a shared capability (e.g., Serializable, Comparable, Iterable)
  • You want to enable multiple type identities for a class
  • You're defining a contract for a design pattern (Strategy, Observer, Command from S12)
  • You want maximum flexibility — any class can opt into your contract

Use an abstract class when:

  • Related classes share both contract AND code
  • You need to define a template with required customization points (Template Method, S12)
  • Subclasses need shared state (instance variables, constructors)
  • You want to provide default behavior that subclasses can override

Don't use EITHER when:

  • You have only one implementation and no foreseeable second one — just use a concrete class. YAGNI (S7).
  • The shared behavior is small enough that composition is simpler. Favor composition over inheritance (covered next in this section, S4).

Language-Specific Nuances

Java has a clear distinction:

  • interface keyword for interfaces (can have default methods since Java 8, blurring the line slightly)
  • abstract class keyword for abstract classes
  • A class can implement many interfaces but extend only one class

Python has no interface keyword:

  • Use ABC (Abstract Base Class) from the abc module for both interfaces and abstract classes
  • Convention: if your ABC has only @abstractmethod methods and no state → treat it as an interface
  • Convention: if your ABC has concrete methods and/or state → it's an abstract class
  • Python supports multiple inheritance (unlike Java), so the "multiple interfaces" advantage is less relevant

Key takeaway: The concept of interfaces vs abstract classes is universal and language-agnostic. The implementation details vary, but the design decision — contract vs shared behavior — applies everywhere.

Key Takeaways

  • Interface = pure contract, no implementation, multiple inheritance supported — use when unrelated classes share a capability
  • Abstract class = partial blueprint with shared behavior AND abstract methods — use when related classes share code
  • The decision comes down to: Do the classes share behavior, or just a contract?
  • In practice, the most powerful pattern is both together: an abstract class that implements an interface
  • Every design pattern in S10–S12 uses this distinction — Strategy uses interfaces for swappable algorithms, Template Method uses abstract classes for algorithm skeletons
  • Don't create an abstract class with only abstract methods — that's just an interface with unnecessary restrictions
  • This understanding directly feeds into the OOP relationships (Association, Aggregation, Composition, Dependency) covered next in this section