Loading learning content...
In the previous pages, we examined abstract classes and interfaces as distinct abstraction mechanisms. Now comes the practical question every designer faces: When should I choose an abstract class?
This isn't always obvious. Both abstract classes and interfaces can define method contracts. Both enable polymorphism. Both support the substitution principle. The distinction lies in what additional capabilities abstract classes provide—and whether those capabilities align with your design needs.
This page establishes clear criteria for when abstract classes are the superior choice, backed by concrete scenarios and real-world examples.
By the end of this page, you will have a decision framework for choosing abstract classes. You'll understand the specific scenarios where abstract classes provide irreplaceable value and recognize when they're being misused.
Before diving into specific scenarios, let's establish the core criteria. Choose an abstract class when:
You have shared implementation that all subclasses should inherit—not just a contract, but actual code
There's common state (instance fields) that all subclasses need
You need controlled inheritance with protected members that only subclasses can access
You're defining a family of related types that share identity, not just capability
You want to enforce an algorithm skeleton where subclasses fill in specific steps (Template Method)
If none of these apply—if you only need method signatures without shared implementation—an interface is likely more appropriate.
| Question | Yes → Abstract Class | No → Interface |
|---|---|---|
| Do subclasses share significant implementation code? | Shared code lives in abstract class | No shared code needed |
| Do all types need common instance state (fields)? | Fields declared in abstract class | No shared state required |
| Is there a clear IS-A relationship? | Types are specialized versions | Types share capability only |
| Should subclasses have protected access? | Need protected helpers/state | All methods public |
| Are you defining an algorithm skeleton? | Template Method applies | No fixed algorithm flow |
It's common for abstract classes to implement interfaces. The interface defines the public contract (for loose coupling), while the abstract class provides a base implementation (for code reuse). Concrete classes extend the abstract class and automatically implement the interface.
The primary reason to choose an abstract class is shared implementation. If 80% of the code would be identical across implementations, duplicating it in each concrete class violates DRY and creates maintenance nightmares.
The Pattern:
The Signal:
If you find yourself copying the same methods between implementations, that's code demanding extraction to a shared location. An abstract class provides that location while maintaining proper inheritance semantics.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
// ═══════════════════════════════════════════════════// SCENARIO: Report generation with shared formatting logic// ═══════════════════════════════════════════════════ // Interface defines the contractpublic interface ReportGenerator { Report generate(ReportRequest request); List<String> getSupportedFormats();} // Abstract class provides MASSIVE shared implementationpublic abstract class AbstractReportGenerator implements ReportGenerator { protected final DataSource dataSource; protected final Logger logger; protected final ReportCache cache; protected AbstractReportGenerator(DataSource dataSource, ReportCache cache) { this.dataSource = dataSource; this.cache = cache; this.logger = LoggerFactory.getLogger(getClass()); } // ─────────────────────────────────────────────────── // SHARED IMPLEMENTATION: 80% of the work is here // ─────────────────────────────────────────────────── @Override public final Report generate(ReportRequest request) { logger.info("Starting report generation: {}", request.getReportType()); long startTime = System.currentTimeMillis(); try { // Check cache first String cacheKey = buildCacheKey(request); Report cached = cache.get(cacheKey); if (cached != null && !request.isForceRefresh()) { logger.debug("Returning cached report"); return cached; } // Validate request (shared validation) validateRequest(request); // Fetch data (shared data access pattern) ReportData data = fetchReportData(request); // Transform data (shared transformations) ReportData transformedData = applyTransformations(data, request); // Format output (SUBCLASS SPECIFIC) Report report = formatReport(transformedData, request); // Post-processing (shared) report = addMetadata(report, request); report = addBranding(report); // Cache result cache.put(cacheKey, report, getCacheDuration()); long duration = System.currentTimeMillis() - startTime; logger.info("Report generated in {}ms", duration); return report; } catch (Exception e) { logger.error("Report generation failed", e); throw new ReportGenerationException("Failed to generate report", e); } } // Shared: Request validation logic private void validateRequest(ReportRequest request) { if (request.getStartDate().isAfter(request.getEndDate())) { throw new InvalidRequestException("Start date must be before end date"); } if (request.getFilters().size() > 10) { throw new InvalidRequestException("Maximum 10 filters allowed"); } // More validation... } // Shared: Data fetching with connection handling private ReportData fetchReportData(ReportRequest request) { try (Connection conn = dataSource.getConnection()) { String query = buildDataQuery(request); // Abstract hook try (PreparedStatement stmt = conn.prepareStatement(query)) { applyQueryParameters(stmt, request); ResultSet rs = stmt.executeQuery(); return mapResultSet(rs); } } catch (SQLException e) { throw new DataAccessException("Failed to fetch report data", e); } } // Shared: Data transformation pipeline private ReportData applyTransformations(ReportData data, ReportRequest request) { ReportData result = data; // Apply filters for (Filter filter : request.getFilters()) { result = result.applyFilter(filter); } // Apply aggregations if (request.hasAggregations()) { result = result.aggregate(request.getAggregations()); } // Apply sorting if (request.getSortBy() != null) { result = result.sortBy(request.getSortBy(), request.getSortOrder()); } return result; } // Shared: Add metadata to all reports private Report addMetadata(Report report, ReportRequest request) { return report.withMetadata( Map.of( "generatedAt", Instant.now().toString(), "requestedBy", request.getRequesterId(), "reportType", request.getReportType(), "parameters", request.getParameterSummary() ) ); } // Shared: Add company branding private Report addBranding(Report report) { return report .withHeader(getCompanyHeader()) .withFooter(getCompanyFooter()); } // ─────────────────────────────────────────────────── // ABSTRACT METHODS: Subclasses implement only these // ─────────────────────────────────────────────────── // Each report type queries data differently protected abstract String buildDataQuery(ReportRequest request); // Each output format has different formatting logic protected abstract Report formatReport(ReportData data, ReportRequest request); // Different report types have different cache needs protected abstract Duration getCacheDuration(); // ─────────────────────────────────────────────────── // PROTECTED HELPERS: Available to subclasses // ─────────────────────────────────────────────────── protected String formatCurrency(BigDecimal amount, String currency) { return NumberFormat.getCurrencyInstance(Locale.forLanguageTag(currency)) .format(amount); } protected String formatDate(LocalDate date) { return date.format(DateTimeFormatter.ISO_LOCAL_DATE); } protected String formatPercentage(double value) { return String.format("%.2f%%", value * 100); }}The Concrete Classes Are Minimal:
With the abstract class handling 80% of the work, concrete classes are focused and simple:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
// PDF Report Generator - only implements what's uniquepublic class PdfReportGenerator extends AbstractReportGenerator { private final PdfRenderer pdfRenderer; public PdfReportGenerator(DataSource ds, ReportCache cache, PdfRenderer renderer) { super(ds, cache); this.pdfRenderer = renderer; } @Override protected String buildDataQuery(ReportRequest request) { // PDF reports need all columns for rich formatting return "SELECT * FROM " + request.getTableName() + " WHERE created_at BETWEEN ? AND ?"; } @Override protected Report formatReport(ReportData data, ReportRequest request) { // PDF-specific formatting PdfDocument doc = pdfRenderer.newDocument(); doc.addTitle(request.getReportTitle()); doc.addTable(data.toTableModel()); doc.addChart(data.toChartData()); return new PdfReport(doc.render()); } @Override protected Duration getCacheDuration() { return Duration.ofHours(24); // PDFs are expensive - cache longer } @Override public List<String> getSupportedFormats() { return List.of("pdf", "pdf/a"); }} // CSV Report Generator - also minimalpublic class CsvReportGenerator extends AbstractReportGenerator { public CsvReportGenerator(DataSource ds, ReportCache cache) { super(ds, cache); } @Override protected String buildDataQuery(ReportRequest request) { // CSV only needs requested columns String columns = String.join(", ", request.getRequestedColumns()); return "SELECT " + columns + " FROM " + request.getTableName() + " WHERE created_at BETWEEN ? AND ?"; } @Override protected Report formatReport(ReportData data, ReportRequest request) { // CSV-specific formatting StringBuilder csv = new StringBuilder(); // Header row csv.append(String.join(",", data.getColumnNames())).append(""); // Data rows for (Object[] row : data.getRows()) { csv.append(Arrays.stream(row) .map(this::escapeCsv) .collect(Collectors.joining(",")) ).append(""); } return new CsvReport(csv.toString()); } @Override protected Duration getCacheDuration() { return Duration.ofMinutes(30); // CSVs are cheap - shorter cache } @Override public List<String> getSupportedFormats() { return List.of("csv", "csv-utf8"); } private String escapeCsv(Object value) { String str = value == null ? "" : value.toString(); if (str.contains(",") || str.contains("\"")) { return "\"" + str.replace("\"", "\"\"") + "\""; } return str; }}A good rule of thumb: if more than 50% of the code would be duplicated across implementations, an abstract class makes sense. If implementations would share only a few utility methods, you might instead use composition (helper classes) with an interface.
Abstract classes shine when all subtypes require common instance state. While interfaces cannot have instance fields (only constants), abstract classes can declare, initialize, and manage state that all subclasses share.
Scenarios Where Common State Is Essential:
Shared dependencies: All implementations need the same collaborators (logger, metrics, connection pool)
Configuration: All types share configuration parameters with some defaults
Mutable state: Counter, cache, or status that concrete methods update
Initialization guarantees: State that must be set up before subclass logic runs
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
// ═══════════════════════════════════════════════════// SCENARIO: HTTP client with shared connection state// ═══════════════════════════════════════════════════ public abstract class ManagedHttpClient { // ─────────────────────────────────────────────────── // COMMON STATE: All HTTP clients need these // ─────────────────────────────────────────────────── // Private: Internal infrastructure subclasses shouldn't touch private final ConnectionPool connectionPool; private final CircuitBreaker circuitBreaker; private final AtomicLong requestCount; private final AtomicLong errorCount; // Protected: Subclasses can access configuration protected final HttpClientConfig config; protected final Logger logger; protected final MetricsRegistry metrics; // ─────────────────────────────────────────────────── // CONSTRUCTOR: Initialize all common state // ─────────────────────────────────────────────────── protected ManagedHttpClient(HttpClientConfig config, MetricsRegistry metrics) { this.config = config; this.metrics = metrics; this.logger = LoggerFactory.getLogger(getClass()); this.requestCount = new AtomicLong(0); this.errorCount = new AtomicLong(0); // Initialize connection pool based on config this.connectionPool = new ConnectionPool( config.getMaxConnections(), config.getConnectionTimeout(), config.getIdleTimeout() ); // Initialize circuit breaker this.circuitBreaker = new CircuitBreaker( config.getCircuitBreakerThreshold(), config.getCircuitBreakerTimeout() ); logger.info( "Initialized {} with {} max connections", getClass().getSimpleName(), config.getMaxConnections() ); } // ─────────────────────────────────────────────────── // METHODS USING COMMON STATE // ─────────────────────────────────────────────────── public final Response execute(Request request) { // Use common state: check circuit breaker if (!circuitBreaker.allowRequest()) { metrics.increment("http.circuit_open"); throw new CircuitOpenException("Circuit breaker is open"); } // Use common state: get pooled connection Connection connection = connectionPool.acquire(config.getConnectionTimeout()); requestCount.incrementAndGet(); try { long startTime = System.currentTimeMillis(); // Subclass-specific execution Response response = executeWithConnection(request, connection); // Update common state based on result long duration = System.currentTimeMillis() - startTime; if (response.isSuccessful()) { circuitBreaker.recordSuccess(); metrics.recordLatency("http.success", duration); } else { handleError(response); } return response; } catch (Exception e) { handleException(e); throw e; } finally { connectionPool.release(connection); } } // Use common state: track and record errors private void handleError(Response response) { errorCount.incrementAndGet(); circuitBreaker.recordFailure(); metrics.increment("http.error." + response.getStatusCode()); } private void handleException(Exception e) { errorCount.incrementAndGet(); circuitBreaker.recordFailure(); metrics.increment("http.exception"); logger.error("Request failed", e); } // Expose statistics using common state public ClientStatistics getStatistics() { return new ClientStatistics( requestCount.get(), errorCount.get(), connectionPool.getActiveConnections(), connectionPool.getIdleConnections(), circuitBreaker.getState() ); } // Lifecycle management using common state public void shutdown() { logger.info("Shutting down HTTP client"); connectionPool.close(); } // ─────────────────────────────────────────────────── // ABSTRACT: Subclasses implement protocol specifics // ─────────────────────────────────────────────────── protected abstract Response executeWithConnection(Request req, Connection conn);} // ═══════════════════════════════════════════════════// CONCRETE IMPLEMENTATIONS: Inherit all the state// ═══════════════════════════════════════════════════ public class RestClient extends ManagedHttpClient { private final ObjectMapper jsonMapper; public RestClient(HttpClientConfig config, MetricsRegistry metrics) { super(config, metrics); // Initialize all common state this.jsonMapper = new ObjectMapper(); } @Override protected Response executeWithConnection(Request request, Connection conn) { // REST-specific: JSON handling if (request.hasBody()) { request.setHeader("Content-Type", "application/json"); request.setBody(jsonMapper.writeValueAsBytes(request.getPayload())); } // Use inherited state: logger, config logger.debug("Executing REST request to {}", request.getUrl()); RawResponse raw = conn.send(request); return new Response( raw.getStatusCode(), jsonMapper.readValue(raw.getBody(), Map.class) ); }} public class SoapClient extends ManagedHttpClient { private final XmlSerializer xmlSerializer; public SoapClient(HttpClientConfig config, MetricsRegistry metrics) { super(config, metrics); this.xmlSerializer = new XmlSerializer(); } @Override protected Response executeWithConnection(Request request, Connection conn) { // SOAP-specific: XML handling String soapEnvelope = xmlSerializer.wrapInEnvelope(request.getPayload()); request.setHeader("Content-Type", "text/xml"); request.setHeader("SOAPAction", request.getAction()); request.setBody(soapEnvelope.getBytes()); // Use inherited state logger.debug("Executing SOAP request: {}", request.getAction()); RawResponse raw = conn.send(request); Object result = xmlSerializer.parseEnvelope(raw.getBody()); return new Response(raw.getStatusCode(), result); }}When you put state in an abstract class, you're committing to an inheritance relationship. All subclasses share that state whether they want it or not. If some implementations don't need certain state, consider composition instead—inject a helper object that manages the state only for implementations that need it.
Abstract classes are ideal for the Template Method Pattern—when you want to define an algorithm's skeleton but let subclasses customize specific steps.
Key Characteristics:
Invariant structure: The algorithm's overall flow is fixed and cannot be changed by subclasses
Customizable steps: Specific operations within the flow are abstract or virtual, allowing customization
Controlled extension: Subclasses enhance behavior within a constrained framework; they cannot break the algorithm
This pattern is impossible with interfaces alone because interfaces can't have final methods or enforce call order. Only abstract classes can guarantee: "This is the algorithm. You can customize these parts, but not the structure."
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
// ═══════════════════════════════════════════════════// SCENARIO: Data import with fixed validation/processing flow// ═══════════════════════════════════════════════════ public abstract class DataImporter<T> { protected final Logger logger; protected final ImportStatistics stats; protected DataImporter() { this.logger = LoggerFactory.getLogger(getClass()); this.stats = new ImportStatistics(); } // ─────────────────────────────────────────────────── // TEMPLATE METHOD: The invariant algorithm skeleton // 'final' ensures subclasses cannot change the flow // ─────────────────────────────────────────────────── public final ImportResult importData(InputStream source) { logger.info("Starting data import"); stats.reset(); try { // Step 1: Initialize (hook - optional customization) initialize(); // Step 2: Parse source (abstract - must implement) List<T> parsedItems = parseSource(source); stats.setTotalRecords(parsedItems.size()); logger.info("Parsed {} records", parsedItems.size()); // Step 3: Validate each item (uses abstract validation) List<T> validItems = new ArrayList<>(); List<ValidationError> errors = new ArrayList<>(); for (int i = 0; i < parsedItems.size(); i++) { T item = parsedItems.get(i); try { // Core validation (shared) validateNotNull(item); // Type-specific validation (abstract) ValidationResult result = validate(item); if (result.isValid()) { validItems.add(item); } else { errors.add(new ValidationError(i, result.getErrors())); stats.incrementInvalid(); } } catch (Exception e) { errors.add(new ValidationError(i, e.getMessage())); stats.incrementInvalid(); } } logger.info("{} valid, {} invalid", validItems.size(), errors.size()); // Step 4: Transform valid items (abstract - must implement) List<T> transformedItems = new ArrayList<>(); for (T item : validItems) { T transformed = transform(item); transformedItems.add(transformed); } // Step 5: Persist (abstract - must implement) int persisted = persist(transformedItems); stats.setSuccessfulRecords(persisted); // Step 6: Cleanup (hook - optional customization) cleanup(); // Step 7: Build result (shared) return buildResult(stats, errors); } catch (Exception e) { logger.error("Import failed", e); return ImportResult.failure(e.getMessage(), stats); } } // ─────────────────────────────────────────────────── // ABSTRACT METHODS: Subclasses MUST customize these // ─────────────────────────────────────────────────── // How to parse the input stream into typed objects protected abstract List<T> parseSource(InputStream source) throws IOException; // Type-specific validation rules protected abstract ValidationResult validate(T item); // Optional transformation before persistence protected abstract T transform(T item); // How to persist the items protected abstract int persist(List<T> items); // ─────────────────────────────────────────────────── // HOOK METHODS: Optional customization points // Default implementation does nothing // ─────────────────────────────────────────────────── // Called before parsing begins protected void initialize() { // Default: nothing // Override to acquire resources, set up connections, etc. } // Called after persistence completes protected void cleanup() { // Default: nothing // Override to release resources, send notifications, etc. } // ─────────────────────────────────────────────────── // SHARED METHODS: Common logic, not customizable // ─────────────────────────────────────────────────── private void validateNotNull(T item) { if (item == null) { throw new ValidationException("Item cannot be null"); } } private ImportResult buildResult(ImportStatistics stats, List<ValidationError> errors) { return ImportResult.builder() .success(errors.isEmpty()) .totalRecords(stats.getTotalRecords()) .successfulRecords(stats.getSuccessfulRecords()) .failedRecords(stats.getInvalidRecords()) .errors(errors) .duration(stats.getDuration()) .build(); }}Concrete Implementations Follow the Template:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
// CSV Importer - follows the template, customizes stepspublic class CsvUserImporter extends DataImporter<User> { private final UserRepository repository; private Connection dbConnection; public CsvUserImporter(UserRepository repository) { super(); this.repository = repository; } @Override protected void initialize() { // Custom setup: acquire database connection dbConnection = repository.getConnection(); logger.debug("Acquired database connection"); } @Override protected List<User> parseSource(InputStream source) throws IOException { // CSV-specific parsing List<User> users = new ArrayList<>(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(source))) { String line; boolean isHeader = true; while ((line = reader.readLine()) != null) { if (isHeader) { isHeader = false; continue; } String[] parts = line.split(","); users.add(new User( parts[0].trim(), // email parts[1].trim(), // name parts.length > 2 ? parts[2].trim() : null // phone )); } } return users; } @Override protected ValidationResult validate(User user) { List<String> errors = new ArrayList<>(); if (!user.getEmail().contains("@")) { errors.add("Invalid email format"); } if (user.getName() == null || user.getName().length() < 2) { errors.add("Name must be at least 2 characters"); } if (repository.existsByEmail(user.getEmail())) { errors.add("Email already exists"); } return new ValidationResult(errors.isEmpty(), errors); } @Override protected User transform(User user) { // Normalize data before saving return user.toBuilder() .email(user.getEmail().toLowerCase().trim()) .name(normalizeCase(user.getName())) .build(); } @Override protected int persist(List<User> users) { return repository.saveAll(users).size(); } @Override protected void cleanup() { // Release database connection if (dbConnection != null) { repository.releaseConnection(dbConnection); logger.debug("Released database connection"); } } private String normalizeCase(String name) { return Arrays.stream(name.split(" ")) .map(word -> Character.toUpperCase(word.charAt(0)) + word.substring(1).toLowerCase()) .collect(Collectors.joining(" ")); }}Template Method is appropriate when you say: 'Every X must do A, then B, then C, in that order—but HOW they do each step varies.' The abstract class guarantees A→B→C. Subclasses define what A, B, and C actually do. Perfect for workflows, processing pipelines, and lifecycle management.
Abstract classes are appropriate when there's a genuine IS-A relationship—when subclasses are truly specialized versions of a common concept, not just types that share capability.
IS-A vs CAN-DO:
Abstract classes model IS-A. Interfaces model CAN-DO. The distinction matters for conceptual clarity and proper use of polymorphism.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
// ═══════════════════════════════════════════════════// TRUE IS-A: Bank account hierarchy// ═══════════════════════════════════════════════════ // Abstract base - all accounts share these characteristicspublic abstract class BankAccount { // All accounts have these properties protected final String accountNumber; protected final String accountHolderName; protected Money balance; protected final List<Transaction> transactionHistory; protected final Instant openedAt; protected BankAccount(String accountNumber, String holderName, Money initialDeposit) { this.accountNumber = accountNumber; this.accountHolderName = holderName; this.balance = initialDeposit; this.transactionHistory = new ArrayList<>(); this.openedAt = Instant.now(); recordTransaction(TransactionType.OPENING_DEPOSIT, initialDeposit); } // All accounts can deposit public void deposit(Money amount) { if (amount.isNegative()) { throw new InvalidOperationException("Cannot deposit negative amount"); } balance = balance.add(amount); recordTransaction(TransactionType.DEPOSIT, amount); } // All accounts have a balance public Money getBalance() { return balance; } // All accounts track transactions public List<Transaction> getTransactionHistory() { return Collections.unmodifiableList(transactionHistory); } // Statement generation common to all public Statement generateStatement(DateRange range) { List<Transaction> relevant = transactionHistory.stream() .filter(t -> range.contains(t.getTimestamp())) .collect(Collectors.toList()); return new Statement(accountNumber, accountHolderName, relevant); } // Withdrawal rules vary by account type public abstract void withdraw(Money amount); // Interest calculation varies by account type public abstract Money calculateInterest(); // Account type specific description public abstract String getAccountType(); protected void recordTransaction(TransactionType type, Money amount) { transactionHistory.add(new Transaction(type, amount, balance, Instant.now())); }} // All specializations are clearly IS-A BankAccount public class SavingsAccount extends BankAccount { private final BigDecimal interestRate; private final int maxWithdrawalsPerMonth; private int withdrawalsThisMonth; public SavingsAccount(String accountNo, String holder, Money initial, BigDecimal interestRate) { super(accountNo, holder, initial); this.interestRate = interestRate; this.maxWithdrawalsPerMonth = 6; this.withdrawalsThisMonth = 0; } @Override public void withdraw(Money amount) { if (withdrawalsThisMonth >= maxWithdrawalsPerMonth) { throw new WithdrawalLimitExceededException( "Maximum " + maxWithdrawalsPerMonth + " withdrawals per month" ); } if (amount.isGreaterThan(balance)) { throw new InsufficientFundsException(); } balance = balance.subtract(amount); withdrawalsThisMonth++; recordTransaction(TransactionType.WITHDRAWAL, amount); } @Override public Money calculateInterest() { return balance.multiply(interestRate).divide(12); // Monthly interest } @Override public String getAccountType() { return "Savings Account"; }} public class CheckingAccount extends BankAccount { private final Money overdraftLimit; public CheckingAccount(String accountNo, String holder, Money initial, Money overdraftLimit) { super(accountNo, holder, initial); this.overdraftLimit = overdraftLimit; } @Override public void withdraw(Money amount) { Money available = balance.add(overdraftLimit); if (amount.isGreaterThan(available)) { throw new InsufficientFundsException( "Amount exceeds balance plus overdraft limit" ); } balance = balance.subtract(amount); // Charge overdraft fee if applicable if (balance.isNegative()) { Money overdraftFee = Money.of("5.00"); balance = balance.subtract(overdraftFee); recordTransaction(TransactionType.OVERDRAFT_FEE, overdraftFee); } recordTransaction(TransactionType.WITHDRAWAL, amount); } @Override public Money calculateInterest() { // Checking accounts typically don't earn interest return Money.zero(); } @Override public String getAccountType() { return "Checking Account"; }}Ask yourself: 'Does saying X IS A Y make conceptual sense?' If yes, and they share significant behavior (not just method signatures), abstract class is appropriate. If no—if they just share what they CAN DO—use an interface.
We've established clear criteria for choosing abstract classes over interfaces. Let's consolidate the decision framework:
What's Next:
We've covered when abstract classes are the right choice. The next page explores the complementary question: When to use interfaces—situations where pure contracts without implementation provide the best design.
You now have a clear decision framework for choosing abstract classes. Use them when you need shared implementation, common state, template methods, or true IS-A relationships. When these don't apply, interfaces are often the better choice—as we'll explore next.