Abstract Classes vs Interfaces
TL;DR: Abstract classes share both a contract and implementation across subclasses. Interfaces share only a contract. If the implementations share actual code, use an abstract class. If they only share method signatures, use an interface.

Why This Matters in Interviews
Every LLD interview requires you to decide how classes relate to each other. The abstract class vs interface decision comes up in almost every problem: rate limiters, file systems, payment processors, notification systems. Getting it wrong leads to rigid hierarchies that are painful to extend or unnecessary coupling between unrelated classes.
The question you should ask yourself every time: do the implementations share code, or do they only share a contract?
Abstract Class: Shared Implementation + Shared Contract
Use an abstract class when subclasses share actual behavior, not just method signatures. The abstract class provides the common code so subclasses don't duplicate it.
Example: File System. Both File and Folder have a name, a parent reference, and a getPath() method that walks up the parent chain. That's real shared code.
abstract class FileSystemEntry {
private String name;
private FileSystemEntry parent;
public FileSystemEntry(String name, FileSystemEntry parent) {
this.name = name;
this.parent = parent;
}
public String getName() { return name; }
// Shared implementation — both File and Folder compute paths the same way
public String getPath() {
if (parent == null) return "/" + name;
return parent.getPath() + "/" + name;
}
// Subclasses must provide their own size calculation
public abstract long getSize();
}
class File extends FileSystemEntry {
private long sizeBytes;
public File(String name, FileSystemEntry parent, long sizeBytes) {
super(name, parent);
this.sizeBytes = sizeBytes;
}
@Override
public long getSize() { return sizeBytes; }
}
class Folder extends FileSystemEntry {
private List<FileSystemEntry> children = new ArrayList<>();
public Folder(String name, FileSystemEntry parent) {
super(name, parent);
}
public void addEntry(FileSystemEntry entry) { children.add(entry); }
@Override
public long getSize() {
return children.stream().mapToLong(FileSystemEntry::getSize).sum();
}
}
class FileSystemEntry {
protected:
std::string name;
FileSystemEntry* parent;
public:
FileSystemEntry(std::string name, FileSystemEntry* parent)
: name(std::move(name)), parent(parent) {}
virtual ~FileSystemEntry() = default;
std::string getName() const { return name; }
// Shared implementation
std::string getPath() const {
if (!parent) return "/" + name;
return parent->getPath() + "/" + name;
}
virtual long getSize() const = 0; // Pure virtual — subclasses must implement
};
class File : public FileSystemEntry {
long sizeBytes;
public:
File(std::string name, FileSystemEntry* parent, long sizeBytes)
: FileSystemEntry(std::move(name), parent), sizeBytes(sizeBytes) {}
long getSize() const override { return sizeBytes; }
};
class Folder : public FileSystemEntry {
std::vector<FileSystemEntry*> children;
public:
Folder(std::string name, FileSystemEntry* parent)
: FileSystemEntry(std::move(name), parent) {}
void addEntry(FileSystemEntry* entry) { children.push_back(entry); }
long getSize() const override {
long total = 0;
for (auto* child : children) total += child->getSize();
return total;
}
};
from abc import ABC, abstractmethod
class FileSystemEntry(ABC):
def __init__(self, name: str, parent: "FileSystemEntry | None"):
self._name = name
self._parent = parent
def get_name(self) -> str:
return self._name
# Shared implementation
def get_path(self) -> str:
if self._parent is None:
return "/" + self._name
return self._parent.get_path() + "/" + self._name
@abstractmethod
def get_size(self) -> int: ...
class File(FileSystemEntry):
def __init__(self, name: str, parent: FileSystemEntry | None, size_bytes: int):
super().__init__(name, parent)
self._size_bytes = size_bytes
def get_size(self) -> int:
return self._size_bytes
class Folder(FileSystemEntry):
def __init__(self, name: str, parent: FileSystemEntry | None):
super().__init__(name, parent)
self._children: list[FileSystemEntry] = []
def add_entry(self, entry: FileSystemEntry) -> None:
self._children.append(entry)
def get_size(self) -> int:
return sum(child.get_size() for child in self._children)
The key insight: getPath() is identical for both File and Folder. It belongs in the abstract class because duplicating it would be a maintenance problem.
Interface: Shared Contract Only
Use an interface when classes share behavior but have zero common implementation. They agree on what they do, not how they do it.
Example: Rate Limiter. A TokenBucketLimiter and a SlidingWindowLogLimiter both answer the question "should this request be allowed?" But their internal state and algorithms are completely different.
interface Limiter {
boolean allowRequest(String clientId);
}
class TokenBucketLimiter implements Limiter {
private Map<String, Long> tokenCounts = new HashMap<>();
private final long maxTokens;
private final long refillRate;
public TokenBucketLimiter(long maxTokens, long refillRate) {
this.maxTokens = maxTokens;
this.refillRate = refillRate;
}
@Override
public boolean allowRequest(String clientId) {
// Refill tokens based on elapsed time, then consume one
long tokens = tokenCounts.getOrDefault(clientId, maxTokens);
if (tokens <= 0) return false;
tokenCounts.put(clientId, tokens - 1);
return true;
}
}
class SlidingWindowLogLimiter implements Limiter {
private Map<String, List<Long>> requestLogs = new HashMap<>();
private final int maxRequests;
private final long windowMillis;
public SlidingWindowLogLimiter(int maxRequests, long windowMillis) {
this.maxRequests = maxRequests;
this.windowMillis = windowMillis;
}
@Override
public boolean allowRequest(String clientId) {
// Keep a log of timestamps, evict old ones, check count
long now = System.currentTimeMillis();
List<Long> log = requestLogs.computeIfAbsent(clientId, k -> new ArrayList<>());
log.removeIf(ts -> ts < now - windowMillis);
if (log.size() >= maxRequests) return false;
log.add(now);
return true;
}
}
// C++ has no "interface" keyword — use a pure virtual class
class Limiter {
public:
virtual ~Limiter() = default;
virtual bool allowRequest(const std::string& clientId) = 0;
};
class TokenBucketLimiter : public Limiter {
std::unordered_map<std::string, long> tokenCounts;
long maxTokens;
long refillRate;
public:
TokenBucketLimiter(long maxTokens, long refillRate)
: maxTokens(maxTokens), refillRate(refillRate) {}
bool allowRequest(const std::string& clientId) override {
long tokens = tokenCounts.count(clientId) ? tokenCounts[clientId] : maxTokens;
if (tokens <= 0) return false;
tokenCounts[clientId] = tokens - 1;
return true;
}
};
class SlidingWindowLogLimiter : public Limiter {
std::unordered_map<std::string, std::vector<long>> requestLogs;
int maxRequests;
long windowMillis;
public:
SlidingWindowLogLimiter(int maxRequests, long windowMillis)
: maxRequests(maxRequests), windowMillis(windowMillis) {}
bool allowRequest(const std::string& clientId) override {
auto now = std::chrono::system_clock::now().time_since_epoch().count();
auto& log = requestLogs[clientId];
log.erase(std::remove_if(log.begin(), log.end(),
[&](long ts) { return ts < now - windowMillis; }), log.end());
if ((int)log.size() >= maxRequests) return false;
log.push_back(now);
return true;
}
};
from typing import Protocol
class Limiter(Protocol):
def allow_request(self, client_id: str) -> bool: ...
class TokenBucketLimiter:
def __init__(self, max_tokens: int, refill_rate: int):
self._token_counts: dict[str, int] = {}
self._max_tokens = max_tokens
self._refill_rate = refill_rate
def allow_request(self, client_id: str) -> bool:
tokens = self._token_counts.get(client_id, self._max_tokens)
if tokens <= 0:
return False
self._token_counts[client_id] = tokens - 1
return True
class SlidingWindowLogLimiter:
def __init__(self, max_requests: int, window_millis: int):
self._request_logs: dict[str, list[float]] = {}
self._max_requests = max_requests
self._window_millis = window_millis
def allow_request(self, client_id: str) -> bool:
import time
now = time.time() * 1000
log = self._request_logs.setdefault(client_id, [])
log[:] = [ts for ts in log if ts >= now - self._window_millis]
if len(log) >= self._max_requests:
return False
log.append(now)
return True
Notice that TokenBucketLimiter and SlidingWindowLogLimiter share zero fields, zero helper methods, and zero logic. Forcing them into an abstract class would add an unnecessary inheritance relationship with no benefit.
Interview tip: When the interviewer says "design a rate limiter," start with an interface. If they ask you to support multiple algorithms (token bucket, sliding window, fixed window), each one implements the same interface. Do not create an abstract
BaseLimiterwith shared fields that no implementation actually uses.
Language-Specific Details
Java: Interfaces vs Abstract Classes
Java makes the distinction explicit:
- A class can implement multiple interfaces but extend only one abstract class.
- Since Java 8, interfaces can have default methods -- methods with a body that implementing classes inherit unless they override.
interface Searchable {
List<String> search(String query);
}
interface Sortable {
void sort();
}
// A class can implement both — no conflict
class DocumentStore implements Searchable, Sortable {
@Override
public List<String> search(String query) { /* ... */ }
@Override
public void sort() { /* ... */ }
}
Default methods let you add methods to an interface without breaking existing implementations:
interface Limiter {
boolean allowRequest(String clientId);
// Default method — implementations get this for free
default boolean allowBatch(List<String> clientIds) {
return clientIds.stream().allMatch(this::allowRequest);
}
}
Interview tip: Default methods blur the line between interfaces and abstract classes. Use them sparingly for convenience methods that have an obvious implementation in terms of the interface's other methods. Don't use them to smuggle shared state into an interface.
C++: Pure Virtual Classes as Interfaces
C++ has no interface keyword. The convention is a class with all pure virtual methods and a virtual destructor:
// This IS an interface in C++ — all methods are pure virtual
class Limiter {
public:
virtual ~Limiter() = default;
virtual bool allowRequest(const std::string& clientId) = 0;
};
// C++ allows multiple inheritance, so a class can "implement" multiple interfaces
class AuditableLimiter : public Limiter, public Auditable {
// Must implement all pure virtual methods from both
};
C++ allows multiple inheritance of classes with state, which can lead to the diamond problem. Prefer pure virtual classes (interfaces) for multiple inheritance and single inheritance for abstract classes with shared state.
Python: ABC and Protocol
Python offers two approaches:
ABC (Abstract Base Class) -- explicit inheritance, checked at instantiation:
from abc import ABC, abstractmethod
class FileSystemEntry(ABC):
@abstractmethod
def get_size(self) -> int: ...
# TypeError at instantiation if get_size() is not implemented
Protocol -- structural typing, no inheritance required:
from typing import Protocol
class Limiter(Protocol):
def allow_request(self, client_id: str) -> bool: ...
# Any class with allow_request(str) -> bool satisfies Limiter
# No need to explicitly inherit from it
Python's duck typing means you often don't need formal interfaces at all. If it has an allow_request method, it works. Protocol adds static type checking on top of that flexibility.
Interview tip: In a Python LLD interview, use ABC when you want to enforce that subclasses implement certain methods (the class will refuse to instantiate otherwise). Use Protocol when you want type-safe duck typing without forcing an inheritance relationship.
The Decision Framework
Ask one question: do the implementations share code?
| Abstract Class | Interface | |
|---|---|---|
| Shared state (fields) | Yes -- subclasses inherit fields | No -- each implementation has its own fields |
| Shared methods | Yes -- concrete methods are inherited | No (Java default methods are the exception) |
| Multiple inheritance | No (Java), Yes (C++, Python) | Yes (all languages) |
| When to use | Implementations share actual code | Implementations share only method signatures |
| LLD example | FileSystemEntry (name, parent, getPath) |
Limiter (allowRequest) |
| Common mistake | Using it when implementations share no state | Avoiding it when implementations DO share code |
The Common Mistake
The most frequent mistake in LLD interviews: creating an abstract class when implementations share no state.
// BAD: Abstract class with no shared implementation
abstract class BaseLimiter {
public abstract boolean allowRequest(String clientId);
}
class TokenBucketLimiter extends BaseLimiter { /* ... */ }
class SlidingWindowLogLimiter extends BaseLimiter { /* ... */ }
This looks harmless, but it blocks both limiters from extending any other class (TokenBucketLimiter can't extend MetricsCollector, for example). An interface gives you the same contract without the inheritance cost:
// GOOD: Interface when there's no shared code
interface Limiter {
boolean allowRequest(String clientId);
}
class TokenBucketLimiter implements Limiter { /* ... */ }
class SlidingWindowLogLimiter implements Limiter { /* ... */ }
Interview tip: If your abstract class has zero concrete methods and zero fields, it should be an interface. An abstract class with only abstract methods is just an interface wearing a disguise.