Loading content...
You now understand what abstract classes are and how abstract methods work. But understanding a tool is different from knowing when to use it. A hammer is well-understood, but that doesn't mean every fastening problem calls for a hammer.
Abstract classes are powerful, but they're not always the right choice. Sometimes interfaces are better. Sometimes concrete inheritance suffices. Sometimes composition beats inheritance entirely. The mark of a skilled designer is knowing which abstraction mechanism fits which situation.
This page establishes clear criteria for when abstract classes are the optimal design choice, and equally important, when they should be avoided.
By the end of this page, you will have a concrete decision framework for choosing abstract classes. You'll understand the specific scenarios that call for abstract classes, recognize the warning signs that suggest other approaches, and be able to defend your design decisions with clear reasoning.
Abstract classes excel when you need both shared implementation and enforced customization within a true IS-A hierarchy. This is the sweet spot that no other mechanism serves as well.
Let's break down each criterion:
Circle IS-A Shape. A PostgresConnection IS-A DatabaseConnection. The relationship is semantic, not just structural.All five criteria should be met. If even one is missing, another mechanism might be more appropriate:
Every abstract class imposes an "inheritance tax"—the cost of single inheritance slot usage. In Java/C#/TypeScript, a class can only extend one parent. Choose abstract classes only when this cost is worth the benefits.
Let's examine concrete scenarios where abstract classes shine:
BaseController, testing frameworks with TestCase, game engines with GameObject1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// FRAMEWORK CODE: Abstract base provides infrastructurepublic abstract class HttpServlet { // Framework provides HTTP parsing, session management, etc. private HttpRequest parseRequest(InputStream in) { /* complex */ return null; } private void sendResponse(HttpResponse response) { /* complex */ } protected HttpSession getSession() { return currentSession; } protected void log(String message) { /* logging infrastructure */ } // USERS MUST implement at least one of these protected abstract void doGet(HttpRequest req, HttpResponse resp); protected abstract void doPost(HttpRequest req, HttpResponse resp); // Template method - framework controls flow public final void service(InputStream in, OutputStream out) { HttpRequest request = parseRequest(in); HttpResponse response = new HttpResponse(out); try { switch (request.getMethod()) { case "GET": doGet(request, response); break; case "POST": doPost(request, response); break; } } catch (Exception e) { log("Error: " + e.getMessage()); response.sendError(500); } sendResponse(response); }} // USER CODE: Just implement the specific behaviorpublic class UserController extends HttpServlet { @Override protected void doGet(HttpRequest req, HttpResponse resp) { String userId = req.getParameter("id"); User user = findUser(userId); resp.json(user); // Uses inherited convenience method } @Override protected void doPost(HttpRequest req, HttpResponse resp) { User user = req.parseBody(User.class); saveUser(user); log("User saved: " + user.getId()); // Uses inherited logging resp.created(); }}Employee hierarchy (salary calculation varies), PaymentMethod (processing varies), Document (rendering varies)123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// Domain hierarchy with significant shared behaviorpublic abstract class Employee { protected String name; protected String employeeId; protected LocalDate hireDate; protected BigDecimal baseSalary; protected Department department; public Employee(String name, String employeeId, BigDecimal baseSalary) { this.name = name; this.employeeId = employeeId; this.hireDate = LocalDate.now(); this.baseSalary = baseSalary; } // Shared behavior: tenure calculation public int getYearsOfService() { return Period.between(hireDate, LocalDate.now()).getYears(); } // Shared behavior: base salary retrieval public BigDecimal getBaseSalary() { return baseSalary; } // Shared behavior: department management public void transferTo(Department newDept) { this.department = newDept; logTransfer(newDept); } // ABSTRACT: Compensation varies significantly by employee type // - Hourly: baseSalary * hoursWorked // - Salaried: baseSalary / 12 // - Commission: baseSalary + (sales * commissionRate) public abstract BigDecimal calculateMonthlyCompensation(); // ABSTRACT: Benefits eligibility varies by type public abstract boolean isEligibleForBonus(); // ABSTRACT: Performance evaluation process differs public abstract PerformanceReview conductReview(); protected void logTransfer(Department dept) { /* ... */ }} // Each employee type provides specific implementationspublic class CommissionEmployee extends Employee { private BigDecimal commissionRate; private BigDecimal monthlySales; @Override public BigDecimal calculateMonthlyCompensation() { // Commission-specific calculation return baseSalary.add(monthlySales.multiply(commissionRate)); } @Override public boolean isEligibleForBonus() { // Commission employees get bonus if sales exceed target return monthlySales.compareTo(SALES_TARGET) > 0; } @Override public PerformanceReview conductReview() { // Sales-focused evaluation return new SalesPerformanceReview(this); }}Knowing when to avoid abstract classes is equally important. Here are scenarios where other mechanisms are preferable:
| Scenario | Why Abstract Class Fails | Better Alternative |
|---|---|---|
| No IS-A relationship | Inheritance implies type substitution; forced hierarchies are brittle | Composition + Interfaces |
| Multiple contracts needed | Single inheritance limits flexibility | Multiple interfaces |
| No shared implementation | Abstract class adds complexity without benefit | Pure interfaces |
| Behavior varies at runtime | Inheritance is compile-time fixed | Strategy pattern / Composition |
| Cross-cutting concerns | Would require diamond inheritance | Mixins, Traits, or Composition |
| Shallow, stable hierarchy | Over-engineering for simple cases | Concrete base class or none |
A Detailed Anti-Example:
Consider a logging system where you might be tempted to create:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
// ❌ BAD: Abstract class for logging// Why is this wrong? abstract class AbstractLogger { protected String prefix; public AbstractLogger(String prefix) { this.prefix = prefix; } // Trivial shared code - not worth abstract class protected String formatMessage(String message) { return "[" + prefix + "] " + message; } public abstract void log(String message);} class FileLogger extends AbstractLogger { public FileLogger() { super("FILE"); } @Override public void log(String message) { writeToFile(formatMessage(message)); }} class ConsoleLogger extends AbstractLogger { public ConsoleLogger() { super("CONSOLE"); } @Override public void log(String message) { System.out.println(formatMessage(message)); }} // PROBLEM: What if we need BOTH file AND console logging?// Cannot extend two abstract classes!// What if we need a logger that's also a MetricCollector?// Single inheritance blocks this. // ✅ GOOD: Interface + Compositioninterface Logger { void log(String message);} interface MetricCollector { void recordMetric(String name, double value);} // Can implement multiple interfacesclass MonitoredFileLogger implements Logger, MetricCollector { private String prefix = "FILE"; @Override public void log(String message) { writeToFile(formatMessage(message)); } @Override public void recordMetric(String name, double value) { // ... metric recording } private String formatMessage(String msg) { return "[" + prefix + "] " + msg; // Duplicated but simple }} // Composition for shared behaviorclass CompositeLogger implements Logger { private final List<Logger> loggers; public CompositeLogger(Logger... loggers) { this.loggers = Arrays.asList(loggers); } @Override public void log(String message) { loggers.forEach(l -> l.log(message)); // Logs to ALL }}If you can foresee needing to combine behaviors from multiple sources, prefer interfaces. Abstract classes lock you into a single inheritance path. Interfaces allow unlimited composition.
Use this decision tree to select the right abstraction mechanism:
Question 1: Is there a genuine IS-A relationship?
Question 2: Do you need to enforce specific method implementations?
Question 3: Is there significant shared implementation to inherit?
Question 4: Might implementations need multiple type identities?
Question 5: Does the abstraction need to hold state?
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// EXAMPLE 1: Authentication strategies// - IS-A? No, "EmailAuth IS-A AuthStrategy" sounds wrong// - Shared state? No// - Multiple implementations at runtime? Yes (strategy pattern)// DECISION: Interface interface AuthStrategy { boolean authenticate(Credentials credentials); String getProviderName();} // --------------------------------------------------------- // EXAMPLE 2: Shape hierarchy// - IS-A? Yes, "Circle IS-A Shape" is semantically correct// - Shared implementation? Yes (color, position logic)// - Enforced implementation? Yes (area, perimeter calculations)// - Single hierarchy sufficient? Yes// DECISION: Abstract Class abstract class Shape { protected Color color; protected Point position; public void moveTo(Point newPosition) { /* shared */ } public Color getColor() { return color; } public abstract double getArea(); public abstract double getPerimeter();} // --------------------------------------------------------- // EXAMPLE 3: Plugin system// - IS-A? Debatable ("MyPlugin IS-A Plugin" is okay)// - Shared implementation? Minimal// - Need to implement other contracts? Yes (Configurable, Startable)// DECISION: Interfaces (multiple) interface Plugin { String getName(); void execute(PluginContext context);} interface Configurable { void configure(Configuration config);} interface Startable { void start(); void stop();} // Plugin can implement all threeclass MyPlugin implements Plugin, Configurable, Startable { // Maximum flexibility}A powerful pattern combines abstract classes (for shared implementation) with interfaces (for type contracts). This provides the benefits of both:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
// INTERFACE: Defines the contract (what)public interface Collection<E> { int size(); boolean isEmpty(); boolean contains(Object o); boolean add(E element); boolean remove(Object o); Iterator<E> iterator(); void clear();} // ABSTRACT CLASS: Provides skeletal implementation (how, partially)public abstract class AbstractCollection<E> implements Collection<E> { // Implemented in terms of abstract methods public boolean isEmpty() { return size() == 0; // Uses abstract size() } // Implemented in terms of iterator public boolean contains(Object o) { for (E element : this) { if (Objects.equals(element, o)) { return true; } } return false; } // Implemented in terms of iterator public void clear() { Iterator<E> it = iterator(); while (it.hasNext()) { it.next(); it.remove(); } } // Useful toString implementation @Override public String toString() { StringBuilder sb = new StringBuilder("["); // ... format elements return sb.toString(); } // STILL ABSTRACT: Subclasses must provide these public abstract int size(); public abstract Iterator<E> iterator(); public abstract boolean add(E element); // Often unsupported} // CONCRETE CLASS: Completes the implementationpublic class ArrayList<E> extends AbstractCollection<E> implements List<E> { private Object[] elements; private int size; @Override public int size() { return size; // Simple - just return field } @Override public Iterator<E> iterator() { return new ArrayListIterator(); // Specific implementation } @Override public boolean add(E element) { ensureCapacity(); elements[size++] = element; return true; } // Inherits: isEmpty(), contains(), clear(), toString() // No code duplication!} // Another concrete class with different storagepublic class LinkedList<E> extends AbstractCollection<E> implements List<E> { private Node<E> head; private int size; @Override public int size() { return size; } @Override public Iterator<E> iterator() { return new LinkedListIterator(); // Different iteration logic } @Override public boolean add(E element) { // Linked list add logic return true; } // ALSO inherits: isEmpty(), contains(), clear(), toString() // Correctly uses its own iterator!}Why This Pattern Is Powerful:
Flexible Typing — Code can depend on the interface (Collection), enabling any implementation
Reduced Boilerplate — Common implementations in AbstractCollection don't need to be rewritten
Direct Interface Implementation — If someone doesn't want the abstract class, they can implement the interface directly
Layered Abstractions — Java's Collections Framework uses this: Collection → Set → AbstractSet → HashSet
This pattern is sometimes called "skeletal implementation" or "abstract interface implementation." It's used extensively in Java's Collections Framework (AbstractList, AbstractSet, AbstractMap) and is a best practice for library design.
Even experienced developers make these mistakes when using abstract classes:
User shouldn't extend JsonSerializable just to get serialization methods—use composition or mixins.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// ❌ MISTAKE: Too much exposed stateabstract class BadBaseClass { protected List<String> items; // Any subclass can corrupt this protected Map<String, Object> cache; // Same problem protected boolean initialized; // Subclasses directly manipulate state, leading to bugs} // ✅ FIXED: Controlled access via methodsabstract class GoodBaseClass { private List<String> items = new ArrayList<>(); // Private private Map<String, Object> cache = new HashMap<>(); private boolean initialized; // Protected METHODS provide controlled access protected void addItem(String item) { if (!initialized) throw new IllegalStateException(); items.add(item); } protected List<String> getItemsCopy() { return new ArrayList<>(items); // Defensive copy } protected void cacheValue(String key, Object value) { cache.put(key, value); } protected Optional<Object> getCached(String key) { return Optional.ofNullable(cache.get(key)); }} // ❌ MISTAKE: Breaking existing subclasses by adding abstract methodabstract class Version1 { public abstract void process();} // Later, adding this breaks all existing implementations:// public abstract void validate(); // Compile error for existing subclasses! // ✅ FIXED: Use hook method for new optional behaviorabstract class Version2 { public abstract void process(); // New in v2, but has default - doesn't break existing code protected void validate() { // Default: no-op, subclasses can override } // Template method uses both public final void processWithValidation() { validate(); // Optional hook process(); // Required abstract }}You now have a complete framework for deciding when abstract classes are the right design choice.
What's Next:
The final page of this module compares abstract classes and interfaces directly. While we've touched on the differences, a systematic comparison will solidify your ability to choose correctly in any situation.
You now have a rigorous decision framework for when to use abstract classes. This knowledge prevents both overuse (unnecessary complexity) and underuse (missing out on code reuse and enforcement benefits).