Interface vs Abstract Class

2 min read

Object-Oriented Design and SOLID Review — review Java abstractions and choose the right contract mechanism for each design boundary.

1. Introduction

Interfaces and abstract classes are both used to define abstractions in Java, but they solve different design problems.

In production applications, the important question is usually not:

"Can I use an interface or abstract class here?"

The more useful question is:

"What relationship am I trying to model, and which abstraction keeps the code easier to change, test, and maintain?"

During code review, incorrect use of interfaces and abstract classes often indicates deeper design problems such as:

  • Unnecessary inheritance
  • Tight coupling
  • Shared state placed at the wrong abstraction level
  • Large interfaces
  • Implementation details leaking into contracts
  • Classes extending a base class only to reuse a few utility methods
  • Difficulty replacing implementations during testing
  • Difficulty introducing new implementations later

A good design normally uses an interface to describe a capability or contract and an abstract class when related implementations genuinely need shared implementation or state.

2. What This Topic Means

An interface primarily defines what an implementation must provide.

An abstract class can define both what subclasses must provide and how part of that behavior works.

Example:

JAVA
public interface PaymentProcessor {
    PaymentResult process(PaymentRequest request);
}

This contract says that any payment processor must be capable of processing a payment.

Different implementations can provide completely different behavior:

JAVA
public class StripePaymentProcessor implements PaymentProcessor {
    @Override
    public PaymentResult process(PaymentRequest request) {
        return processUsingStripe(request);
    }
}

public class BankPaymentProcessor implements PaymentProcessor {
    @Override
    public PaymentResult process(PaymentRequest request) {
        return processUsingBankGateway(request);
    }
}

An abstract class is more appropriate when implementations share meaningful behavior:

JAVA
public abstract class AbstractPaymentProcessor {
    protected void validateAmount(BigDecimal amount) {
        if (amount == null || amount.signum() <= 0) {
            throw new IllegalArgumentException("Payment amount must be positive");
        }
    }

    public abstract PaymentResult process(PaymentRequest request);
}

Here the abstraction contains reusable implementation logic in addition to an abstract operation.

The design decision should therefore be based on responsibility and relationship, not simply on syntax.

3. Why It Matters in Real Projects

Choosing the wrong abstraction can significantly increase the cost of changing production code.

Maintainability

An interface makes implementations loosely coupled when callers depend only on the contract.

For example:

JAVA
private final PaymentProcessor paymentProcessor;

is usually easier to maintain than:

JAVA
private final StripePaymentProcessor paymentProcessor;

The first version allows the implementation to change without modifying the consumer.

Testability

Interfaces make dependency substitution straightforward.

A service can receive:

JAVA
PaymentProcessor

instead of depending directly on:

JAVA
StripePaymentProcessor

A unit test can then provide a mock or fake implementation.

Scalability of the Design

As systems grow, new implementations frequently appear:

  • Different payment gateways
  • Different notification channels
  • Different file-storage providers
  • Different authentication strategies
  • Different external API clients

A well-defined interface allows these implementations to coexist without forcing consumers to understand them.

Team Development

Interfaces establish clear boundaries between components.

One team may work on:

JAVA
PaymentProcessor

while another develops:

JAVA
RazorpayPaymentProcessor

and another develops:

JAVA
StripePaymentProcessor

The contract reduces coordination around implementation details.

Reliability

Incorrect inheritance can cause subclasses to inherit behavior or mutable state they should not have.

That makes bugs harder to isolate because behavior is distributed across the inheritance hierarchy.

4. Core Concept

The practical distinction is:

Interface

Use an interface when you want to define a contract or capability.

Examples:

JAVA
PaymentProcessor
NotificationSender
UserRepository
FraudChecker
FileStorage
OrderValidator

The caller generally should care about the behavior, not the implementation.

Abstract Class

Use an abstract class when several closely related implementations share real implementation behavior or state.

Examples:

JAVA
AbstractBatchProcessor
AbstractReportGenerator
AbstractExternalApiClient

An abstract class can contain:

  • Instance variables
  • Constructors
  • Abstract methods
  • Concrete methods
  • Protected helper methods
  • Private methods
  • Static methods
  • Initialization logic

Java-Specific Consideration

Java supports implementing multiple interfaces:

JAVA
public class OrderService implements Auditable, Retryable, TransactionalOperation {
}

But Java supports extending only one class:

JAVA
public class CsvReportGenerator extends AbstractReportGenerator {
}

A poor abstract-class decision can therefore consume the class's only inheritance relationship.

Modern Java interfaces can also contain:

  • Abstract methods
  • Default methods
  • Static methods
  • Private methods
  • Constants

However, the presence of default methods does not mean interfaces should become implementation-heavy base classes.

Default methods are usually most useful for backward-compatible API evolution or small behavior naturally associated with the contract.

5. Important Rules

  • Prefer interfaces for behavioral contracts.
  • Use abstract classes when related implementations genuinely share behavior or state.
  • Do not use inheritance only to reuse utility methods.
  • Avoid large interfaces containing unrelated operations.
  • Do not expose mutable state through abstractions.
  • Keep interface contracts implementation-independent.
  • Do not move business logic into interface default methods merely to avoid creating another class.
  • Prefer constructor injection of interfaces in Spring services.
  • Avoid creating an abstract base class before shared behavior actually exists.
  • Keep protected members in abstract classes limited.
  • Do not force unrelated classes into the same inheritance hierarchy.
  • Prefer composition over inheritance when behavior can be delegated cleanly.
  • Use meaningful abstraction names based on business capability.
  • Avoid interfaces created only because "every service should have an interface."
  • Review whether multiple implementations are realistic before introducing unnecessary abstractions.

6. Bad Code Example

Consider an e-commerce application supporting multiple notification channels.

JAVA
public abstract class NotificationService {
    protected String smtpHost = "smtp.company.com";

    public void validateMessage(String message) {
        if (message == null || message.isBlank()) {
            throw new IllegalArgumentException("Message cannot be empty");
        }
    }

    public void connectToSmtpServer() {
        System.out.println("Connecting to " + smtpHost);
    }

    public abstract void send(String recipient, String message);
}

public class EmailNotificationService extends NotificationService {
    @Override
    public void send(String recipient, String message) {
        validateMessage(message);
        connectToSmtpServer();
        System.out.println("Sending email to " + recipient);
    }
}

public class SmsNotificationService extends NotificationService {
    @Override
    public void send(String recipient, String message) {
        validateMessage(message);
        System.out.println("Sending SMS to " + recipient);
    }
}

public class PushNotificationService extends NotificationService {
    @Override
    public void send(String recipient, String message) {
        validateMessage(message);
        System.out.println("Sending push notification to " + recipient);
    }
}

The base class was apparently introduced to share message validation.

However, it also contains SMTP-specific state and behavior even though SMS and push notifications have nothing to do with SMTP.

7. Problems in the Bad Code

Incorrect Inheritance Relationship

SmsNotificationService and PushNotificationService inherit from a class containing email-specific behavior.

The inheritance hierarchy does not accurately represent the domain.

Unnecessary Coupling

Every notification implementation becomes coupled to:

JAVA
smtpHost

and:

JAVA
connectToSmtpServer()

even when those members are irrelevant.

Poor Maintainability

Suppose the base class later introduces additional email-specific fields:

JAVA
smtpPort
smtpUsername
smtpPassword

Every subclass still inherits them.

Misleading API

A developer can technically write:

JAVA
SmsNotificationService sms = new SmsNotificationService();
sms.connectToSmtpServer();

That operation makes no conceptual sense.

Violation of Abstraction Boundaries

The base class combines:

  • General notification behavior
  • Message validation
  • Email infrastructure details

These responsibilities should not live together.

Future Design Restrictions

Because Java allows only single class inheritance, subclasses lose the ability to extend another genuinely useful base class.

Testing Complexity

Testing SMS functionality now involves a type hierarchy containing unrelated email infrastructure.

8. Code Review Findings

A senior reviewer should notice several warning signs.

Finding 1: Base Class Contains Subtype-Specific Behavior

connectToSmtpServer() applies only to email notifications.

A shared parent should not expose operations that are invalid for some children.

Finding 2: Shared Code Does Not Justify Inheritance

The only meaningful common implementation is message validation.

That behavior could be extracted into:

  • A validator
  • A utility component
  • A dedicated collaborator

Finding 3: Contract Is Missing

The actual business capability is:

"Send a notification."

That behavior is better represented by an interface.

Finding 4: Protected State Creates Coupling

The smtpHost field is implementation-specific infrastructure configuration and should belong to the email implementation.

Finding 5: Future Implementations Will Become Harder

Adding:

JAVA
WhatsAppNotificationSender
SlackNotificationSender
TeamsNotificationSender

would continue expanding an artificial inheritance tree.

9. Reviewer Comment Example

NotificationService contains SMTP-specific state and behavior that does not apply to SMS or push notifications. Could we model the common send capability as an interface and keep SMTP configuration inside the email implementation?

Another useful review comment:

The shared validation logic alone does not appear strong enough to justify inheritance. Consider extracting validation into a separate collaborator so notification implementations remain independent.

10. Improved Code

JAVA
public interface NotificationSender {
    void send(String recipient, String message);
}

public class NotificationMessageValidator {
    public void validate(String recipient, String message) {
        if (recipient == null || recipient.isBlank()) {
            throw new IllegalArgumentException("Recipient cannot be empty");
        }
        if (message == null || message.isBlank()) {
            throw new IllegalArgumentException("Message cannot be empty");
        }
    }
}

public class EmailNotificationSender implements NotificationSender {
    private final NotificationMessageValidator validator;
    private final String smtpHost;

    public EmailNotificationSender(NotificationMessageValidator validator, String smtpHost) {
        this.validator = validator;
        this.smtpHost = smtpHost;
    }

    @Override
    public void send(String recipient, String message) {
        validator.validate(recipient, message);
        connectToSmtpServer();
        System.out.println("Sending email to " + recipient);
    }

    private void connectToSmtpServer() {
        System.out.println("Connecting to " + smtpHost);
    }
}

public class SmsNotificationSender implements NotificationSender {
    private final NotificationMessageValidator validator;

    public SmsNotificationSender(NotificationMessageValidator validator) {
        this.validator = validator;
    }

    @Override
    public void send(String recipient, String message) {
        validator.validate(recipient, message);
        System.out.println("Sending SMS to " + recipient);
    }
}

public class PushNotificationSender implements NotificationSender {
    private final NotificationMessageValidator validator;

    public PushNotificationSender(NotificationMessageValidator validator) {
        this.validator = validator;
    }

    @Override
    public void send(String recipient, String message) {
        validator.validate(recipient, message);
        System.out.println("Sending push notification to " + recipient);
    }
}

11. Improved Code Explanation

Contract Is Explicit

The interface:

JAVA
NotificationSender

represents exactly what callers need:

JAVA
void send(String recipient, String message);

Consumers do not need to know whether the notification is sent using SMTP, SMS APIs, or push-notification infrastructure.

Implementation Details Stay Local

SMTP configuration is now contained inside:

JAVA
EmailNotificationSender

SMS and push implementations are no longer exposed to email-specific infrastructure.

Validation Uses Composition

Common validation is implemented through:

JAVA
NotificationMessageValidator

This avoids forcing implementations into an inheritance hierarchy merely for code reuse.

Dependencies Are Easier to Replace

A consuming Spring service can depend on:

JAVA
NotificationSender

instead of a concrete notification class.

New Implementations Are Easier to Introduce

A new implementation can simply implement the interface:

JAVA
public class SlackNotificationSender implements NotificationSender {
    @Override
    public void send(String recipient, String message) {
    }
}

No artificial parent-child relationship is required.

12. Bad Code vs Improved Code

AreaBad DesignImproved Design
AbstractionAbstract class mixes multiple responsibilitiesInterface represents notification capability
CouplingSMS and push inherit SMTP behaviorImplementations contain only relevant behavior
ReuseInheritance used for validation reuseValidation reused through composition
MaintainabilityBase-class changes affect every subclassImplementations evolve independently
TestabilityTests interact with unnecessary inheritanceDependencies can be mocked or faked easily
ExtensibilityNew channels enter artificial hierarchyNew channels implement a small contract
ReadabilityParent-child relationship is misleadingIntent is immediately visible

13. Real Project Scenario

Assume an e-commerce platform sends notifications after order processing.

Initially, only email notifications exist.

A developer creates:

JAVA
AbstractNotificationService

containing:

  • SMTP connection logic
  • Message formatting
  • Recipient validation
  • Retry behavior
  • Logging

Six months later, the business introduces:

  • SMS
  • WhatsApp
  • Mobile push notifications

Developers begin extending the same abstract class because the notification system already depends on it.

Soon the SMS implementation inherits:

  • SMTP configuration
  • Email formatting methods
  • Email subject generation
  • SMTP retry logic

Developers override methods with empty implementations to make the hierarchy work.

For example:

JAVA
@Override
protected String buildEmailSubject() {
    return "";
}

This is a strong signal that the abstraction is wrong.

A better architecture would expose:

JAVA
NotificationSender

and compose specialized collaborators such as:

JAVA
MessageValidator
RetryExecutor
NotificationAuditService

Each channel can then use only the components it actually needs.

14. Production Impact

Wrong interface-versus-abstract-class decisions usually do not create immediate performance failures.

Their biggest production impact is architectural.

Increased Regression Risk

Changing shared behavior in an abstract parent can unintentionally alter every subclass.

Harder Debugging

A production bug may originate from:

  • Parent class behavior
  • Overridden child behavior
  • Protected state
  • Template methods
  • Initialization order

Developers must inspect several classes to understand one execution path.

Slower Feature Development

New implementations become harder to add when developers must fit them into an inappropriate inheritance hierarchy.

Fragile Refactoring

A modification to the base class may require retesting many apparently unrelated subclasses.

Incorrect Runtime Behavior

A subclass can accidentally inherit behavior that should never apply to it.

15. Common Developer Mistakes

Creating Interfaces for Every Class

Example:

JAVA
UserService
UserServiceImpl

when there is only one implementation and no meaningful abstraction boundary.

An interface may still be justified, but creating it automatically adds unnecessary indirection.

Using Abstract Classes Only for Code Reuse

Developers sometimes create inheritance simply because two classes share ten lines of code.

Shared code does not automatically imply an "is-a" relationship.

Putting Too Much Logic in Default Methods

Example:

JAVA
public interface PaymentProcessor {
    default PaymentResult process(PaymentRequest request) {
        // Large workflow implementation
    }
}

This turns the interface into an implementation container.

Exposing Protected Fields

JAVA
protected PaymentRepository repository;

Protected mutable dependencies create unnecessary coupling between the parent and children.

Prefer private fields and carefully designed protected methods when an abstract class is genuinely required.

Empty Overrides

JAVA
@Override
protected void prepareEmailSubject() {
}

Empty overrides often indicate that the parent contract does not apply to every subclass.

Giant Interfaces

JAVA
public interface CustomerOperations {
    void createCustomer();
    void updateCustomer();
    void deleteCustomer();
    void sendEmail();
    void generateInvoice();
    void exportReport();
    void resetPassword();
}

Such interfaces usually combine unrelated responsibilities.

Choosing an Abstract Class Because There Is One Implementation Today

Inheritance should represent a meaningful relationship, not assumptions about implementation count.

16. Edge Cases

Default Interface Methods

Adding a default method can preserve compatibility with existing implementations.

For example:

JAVA
public interface PaymentProcessor {
    PaymentResult process(PaymentRequest request);

    default boolean supportsRefund() {
        return false;
    }
}

This may be reasonable when extending a widely implemented contract.

However, avoid putting complex domain workflows into default methods.

Conflicting Default Methods

A class can implement two interfaces defining the same default method.

JAVA
public interface AuditLogger {
    default void log() {
        System.out.println("Audit");
    }
}

public interface RequestLogger {
    default void log() {
        System.out.println("Request");
    }
}

The implementing class must resolve the conflict.

Stateful Requirements

If subclasses genuinely need common immutable or managed state, an abstract class may be appropriate.

Constructor Requirements

Interfaces cannot require implementing classes to invoke a particular constructor.

An abstract class can enforce constructor initialization.

Existing Public APIs

Changing a public abstract class to an interface can break clients.

Backward compatibility must be considered.

Serialization

Changing class hierarchies in serialized models can introduce compatibility issues.

Avoid unnecessary inheritance in persistent or serialized domain objects.

17. Performance Considerations

Interface versus abstract-class dispatch is generally not a meaningful application-level performance concern.

The JVM heavily optimizes normal method invocation.

Do not select an abstract class because someone assumes:

"Class calls are faster than interface calls."

In typical Spring Boot applications, performance is much more likely to be dominated by:

  • Database queries
  • Network calls
  • JSON serialization
  • External APIs
  • Disk I/O
  • Large collection processing
  • Lock contention

The meaningful performance concern is indirect.

Poor abstractions can encourage:

  • Duplicate processing
  • Incorrect shared caching
  • Repeated initialization
  • Unnecessary object dependencies

But these are design consequences rather than inherent interface-performance problems.

Choose based primarily on design correctness, maintainability, and required behavior.

18. Security Considerations

Interfaces and abstract classes are not inherently security mechanisms.

However, abstraction design can affect security boundaries.

Sensitive Operations

Consider:

JAVA
public interface UserService {
    void createUser();
    void updateUser();
    void deleteUser();
    void resetPassword();
    void exportSensitiveData();
}

A large interface may expose more capabilities than a consumer needs.

Smaller contracts can help enforce least-privilege design at the application layer.

Sensitive State in Base Classes

Avoid protected fields such as:

JAVA
protected String apiSecret;

Every subclass automatically gains access to that field.

Secrets should normally be managed through dedicated secure configuration and private dependencies.

Authentication and Authorization

Do not assume an abstract parent method automatically makes every subclass secure.

Security enforcement should be explicit and consistently tested.

Logging

Shared base classes should not log:

  • Authentication tokens
  • Passwords
  • Payment details
  • Personal information
  • API secrets

19. Testing Considerations

Test the Contract

If several classes implement:

JAVA
NotificationSender

define reusable contract tests where appropriate.

Each implementation should satisfy fundamental expectations such as:

  • Valid requests are accepted.
  • Invalid recipients are rejected.
  • Invalid messages are rejected.
  • Infrastructure failures are propagated or translated correctly.

Unit Test Implementations Independently

Email tests should verify email-specific behavior without loading SMS functionality.

SMS tests should verify SMS-specific behavior independently.

Test Abstract-Class Shared Behavior

When an abstract class is appropriate, test the common implementation through a small concrete test subclass or real subclasses.

Test Overrides

Verify that subclasses do not accidentally bypass critical parent behavior.

Integration Tests

For infrastructure implementations, integration tests may verify:

  • SMTP communication
  • SMS provider communication
  • External gateway contracts
  • Spring dependency injection configuration

Negative Tests

Test:

  • Null inputs
  • Blank values
  • Unsupported operations
  • External API failures
  • Invalid configuration

20. Refactoring Guidelines

Changing an existing abstract-class design should be done incrementally.

Step 1: Identify the Real Contract

Find what consumers actually require.

For example:

JAVA
send()

may be the only operation they need.

Step 2: Introduce an Interface

JAVA
public interface NotificationSender {
    void send(String recipient, String message);
}

Step 3: Make Existing Implementations Implement the Contract

Do this without changing behavior.

Step 4: Change Consumers to Depend on the Interface

Replace:

JAVA
private final EmailNotificationService service;

with:

JAVA
private final NotificationSender sender;

where appropriate.

Step 5: Extract Shared Collaborators

Move reusable behavior such as validation into dedicated classes.

Step 6: Move Subtype-Specific State

SMTP configuration belongs in the email implementation.

SMS configuration belongs in the SMS implementation.

Step 7: Add Tests Before Removing the Base Class

Tests should verify existing behavior before structural refactoring.

Step 8: Remove the Abstract Parent

Remove it only after no behavior depends on it.

This reduces the risk of changing business behavior during refactoring.

21. Best Practices

Depend on Capabilities

Prefer:

JAVA
PaymentProcessor
NotificationSender
FraudChecker

over implementation-oriented contracts such as:

JAVA
AbstractPaymentServiceBase

when consumers need only a capability.

Keep Interfaces Cohesive

A consumer should not be forced to implement operations it does not need.

Use Abstract Classes for Genuine Shared Lifecycle

For example, a batch-processing framework might reasonably contain:

JAVA
public abstract class AbstractBatchProcessor<T> {
    public final void execute(List<T> records) {
        validate(records);
        beforeProcessing();
        process(records);
        afterProcessing();
    }

    protected void validate(List<T> records) {
        if (records == null) {
            throw new IllegalArgumentException("Records cannot be null");
        }
    }

    protected void beforeProcessing() {
    }

    protected abstract void process(List<T> records);

    protected void afterProcessing() {
    }
}

Here the base class defines a real processing lifecycle.

That can justify an abstract class.

Prefer Composition for Optional Behavior

If retry behavior is reusable, compose:

JAVA
RetryExecutor

instead of forcing every retryable service to extend:

JAVA
AbstractRetryableService

Keep Base Classes Stable

Changes to abstract base classes can affect many subclasses.

Treat them as important shared APIs.

Minimize Protected Surface Area

Expose only extension points subclasses genuinely need.

22. Practices to Avoid

Abstract Base Classes Used as Utility Containers

Avoid:

JAVA
AbstractCommonUtils

Inheritance is not a substitute for proper utility or collaborator design.

Interfaces with Dozens of Unrelated Methods

Large contracts increase coupling and violate interface segregation.

Marker Interfaces Without Purpose

Do not create empty interfaces unless they have a real framework, type-system, or architectural role.

Impl Naming Everywhere

Names such as:

JAVA
PaymentServiceImpl

often communicate less than:

JAVA
StripePaymentService

or:

JAVA
DatabasePaymentRepository

Prefer names describing implementation responsibility.

Deep Inheritance Hierarchies

Avoid structures such as:

JAVA
BaseService
    AbstractPaymentService
        AbstractCardPaymentService
            VisaPaymentService

Deep inheritance makes behavior difficult to follow.

Protected Mutable State

Avoid allowing subclasses to modify shared parent state without strict control.

Empty Method Overrides

They frequently reveal incorrect abstraction.

Premature Interfaces

Do not automatically introduce interfaces when no meaningful contract exists.

23. Code Review Checklist

  • Does this abstraction represent a real business capability?
  • Does the caller need the concrete implementation?
  • Would an interface reduce unnecessary coupling here?
  • Does the abstract class contain state genuinely shared by every subclass?
  • Does every subclass need every method inherited from the parent?
  • Are any subclasses overriding inherited methods with empty implementations?
  • Is inheritance being used only for code reuse?
  • Could composition provide the same reuse more cleanly?
  • Is the interface small and cohesive?
  • Are implementation-specific methods leaking into the interface?
  • Are default interface methods becoming too complex?
  • Are protected fields exposing unnecessary mutable state?
  • Does the base class define a meaningful lifecycle or invariant?
  • Could this class need to extend another class later?
  • Can implementations be replaced easily during unit testing?
  • Are Spring services injecting contracts rather than unnecessary concrete implementations?
  • Is the abstraction making the code simpler rather than merely adding another layer?

24. Common Pull Request Review Comments

  1. This base class contains SMTP-specific behavior, so it does not appear to be a valid abstraction for SMS implementations. Could we move the common contract to an interface?
  1. The only shared behavior here is validation. Consider composition with a validator instead of introducing inheritance solely for code reuse.
  1. This interface exposes operations that several implementations do not support. Can we split it into smaller capability-focused interfaces?
  1. Could we inject PaymentProcessor instead of StripePaymentProcessor so the service is not coupled to a specific gateway implementation?
  1. This subclass overrides three parent methods with empty implementations, which suggests the inheritance relationship may not fit the domain.
  1. The protected mutable field makes subclasses dependent on internal parent state. Can we keep the state private and expose only the required behavior?
  1. This default method contains substantial business logic. Consider moving the implementation to a dedicated service and keeping the interface focused on the contract.
  1. Do we need an interface for this class yet? There is one internal implementation and no current substitution boundary; the additional abstraction may not provide value.
  1. This abstract class is becoming a shared utility container. Could these helpers be moved into focused collaborators instead?
  1. Please check whether composition is more appropriate here; the subclasses do not appear to have a genuine is-a relationship with the base class.

25. Code Review Exercise

Review the following implementation.

Identify:

  • Problems
  • Code smells
  • Risks
  • Incorrect abstraction decisions
  • Maintainability concerns
  • Possible improvements

public abstract class PaymentService { protected String apiKey; protected PaymentRepository paymentRepository;

``` public PaymentService(String apiKey, PaymentRepository paymentRepository) { this.apiKey = apiKey; this.paymentRepository = paymentRepository; }

public void validate(PaymentRequest request) { if (request == null) { throw new RuntimeException("Invalid request"); } }

public void connectToCardGateway() { System.out.println("Connecting using API key: " + apiKey); }

public abstract PaymentResult pay(PaymentRequest request);

public abstract void generateCardReceipt(PaymentRequest request); ```

}

public class CardPaymentService extends PaymentService { public CardPaymentService(String apiKey, PaymentRepository paymentRepository) { super(apiKey, paymentRepository); }

``` @Override public PaymentResult pay(PaymentRequest request) { validate(request); connectToCardGateway(); return new PaymentResult("SUCCESS"); }

@Override public void generateCardReceipt(PaymentRequest request) { System.out.println("Generating card receipt"); } ```

}

public class WalletPaymentService extends PaymentService { public WalletPaymentService(String apiKey, PaymentRepository paymentRepository) { super(apiKey, paymentRepository); }

``` @Override public PaymentResult pay(PaymentRequest request) { validate(request); return new PaymentResult("SUCCESS"); }

@Override public void generateCardReceipt(PaymentRequest request) { } ```

}

Questions for the learner:

  • Is PaymentService a valid abstraction for both payment types?
  • Does WalletPaymentService need every inherited member?
  • What does the empty generateCardReceipt() implementation indicate?
  • Should apiKey be available to every implementation?
  • Is logging the API key safe?
  • Is inheritance required for validation?
  • What interface would represent the actual payment capability?
  • Which responsibilities should be extracted?

26. Exercise Solution

Issue 1: Card-Specific Behavior in General Parent

The method:

JAVA
connectToCardGateway()

is irrelevant to wallet payments.

It should not exist in a parent shared by both payment types.

Issue 2: Card-Specific Contract

The method:

JAVA
generateCardReceipt()

forces wallet payments to implement an operation they do not support.

The empty implementation confirms the abstraction is incorrect.

Issue 3: Sensitive API Key Exposure

The field:

JAVA
protected String apiKey;

is accessible to all subclasses.

Additionally:

JAVA
System.out.println("Connecting using API key: " + apiKey);

can expose a secret in logs.

This is a production security risk.

Issue 4: Weak Exception Type

JAVA
throw new RuntimeException("Invalid request");

provides little semantic information.

A validation-specific exception is clearer.

Issue 5: Inheritance Used for Validation Reuse

Validation can be provided through composition.

Improved Design

JAVA
public interface PaymentProcessor {
    PaymentResult pay(PaymentRequest request);
}

public class PaymentRequestValidator {
    public void validate(PaymentRequest request) {
        if (request == null) {
            throw new IllegalArgumentException("Payment request cannot be null");
        }
        if (request.getAmount() == null || request.getAmount().signum() <= 0) {
            throw new IllegalArgumentException("Payment amount must be positive");
        }
    }
}

public class CardPaymentProcessor implements PaymentProcessor {
    private final CardGateway cardGateway;
    private final PaymentRequestValidator validator;
    private final PaymentRepository paymentRepository;

    public CardPaymentProcessor(
            CardGateway cardGateway,
            PaymentRequestValidator validator,
            PaymentRepository paymentRepository) {
        this.cardGateway = cardGateway;
        this.validator = validator;
        this.paymentRepository = paymentRepository;
    }

    @Override
    public PaymentResult pay(PaymentRequest request) {
        validator.validate(request);
        PaymentResult result = cardGateway.charge(request);
        paymentRepository.save(result);
        return result;
    }

    public CardReceipt generateReceipt(PaymentRequest request) {
        return new CardReceipt(request.getId());
    }
}

public class WalletPaymentProcessor implements PaymentProcessor {
    private final WalletGateway walletGateway;
    private final PaymentRequestValidator validator;
    private final PaymentRepository paymentRepository;

    public WalletPaymentProcessor(
            WalletGateway walletGateway,
            PaymentRequestValidator validator,
            PaymentRepository paymentRepository) {
        this.walletGateway = walletGateway;
        this.validator = validator;
        this.paymentRepository = paymentRepository;
    }

    @Override
    public PaymentResult pay(PaymentRequest request) {
        validator.validate(request);
        PaymentResult result = walletGateway.charge(request);
        paymentRepository.save(result);
        return result;
    }
}

Why This Is Better

PaymentProcessor defines only the common capability:

JAVA
pay()

Card-specific receipt generation remains inside the card implementation.

Gateway-specific credentials can remain encapsulated inside gateway configuration rather than being inherited.

Validation is reusable without inheritance.

Both implementations can evolve independently.

The caller can depend only on:

JAVA
PaymentProcessor

which makes substitution and testing easier.

27. Interview Perspective

Interface versus abstract class frequently appears in interviews, but experienced interviewers usually move beyond definitions.

Instead of asking only:

"What is the difference between interface and abstract class?"

they may present a scenario.

Example:

"We have Stripe, Razorpay, PayPal, and bank-transfer payment implementations. Would you use an interface or abstract class?"

A strong answer should not simply say:

"Interface because Java supports multiple inheritance."

A better answer explains the design.

For example:

"I would start with a PaymentProcessor interface because processing a payment is a capability and implementations can differ significantly. If several implementations later share a meaningful workflow or state, I would consider extracting a reusable collaborator first. I would introduce an abstract base class only if there is a genuine common lifecycle that belongs to every implementation."

Senior-level interviews may ask about:

  • Composition versus inheritance
  • Interface segregation
  • Dependency inversion
  • Spring dependency injection
  • Testing and mocking
  • Default methods
  • Backward compatibility
  • Template Method pattern
  • Shared state
  • API evolution
  • Multiple implementations

28. Interview Questions and Answers

Basic Question

Question: What is the practical difference between an interface and an abstract class in Java?

Answer:

An interface primarily defines a behavioral contract, while an abstract class can define both a contract and shared implementation or state.

Use an interface when callers should depend on a capability.

Use an abstract class when closely related implementations genuinely share behavior, state, or a common lifecycle.

Intermediate Question

Question: When would you prefer an interface for a Spring Boot service?

Answer:

An interface is useful when the service represents a replaceable capability with multiple or potentially independent implementations.

For example:

JAVA
PaymentProcessor

may have:

JAVA
StripePaymentProcessor
RazorpayPaymentProcessor

Consumers can depend on PaymentProcessor, reducing coupling to a specific provider.

However, creating UserService and UserServiceImpl automatically for every service provides little value unless the interface establishes a meaningful boundary.

Advanced Question

Question: Interfaces support default methods. Does that remove the need for abstract classes?

Answer:

No.

Default methods allow interfaces to provide limited behavior, particularly when evolving contracts without breaking existing implementations.

Abstract classes still provide capabilities interfaces do not model in the same way, including:

  • Instance state
  • Constructors
  • Controlled protected extension points
  • Common initialization
  • Shared lifecycle implementation

A large amount of stateful workflow logic inside interface default methods is usually a design smell.

Scenario-Based Question

Question: Multiple report generators perform validation, load data, generate output, upload the result, and audit completion. Only the output-generation step differs. Would you use an interface or abstract class?

Answer:

An abstract class may be reasonable if the lifecycle is genuinely invariant:

JAVA
validate
load
generate
upload
audit

The base class can implement the workflow and expose one abstract method such as:

JAVA
generateReport()

This resembles the Template Method pattern.

However, if the steps need to vary independently, composition may still be better.

Code-Review Question

Question: During a PR review, you see three subclasses overriding four parent methods with empty implementations. What does that indicate?

Answer:

It strongly suggests that the parent abstraction is too broad or that the inheritance relationship is invalid.

I would check whether:

  • The parent contains subtype-specific behavior
  • The hierarchy violates interface segregation
  • Smaller interfaces would represent the capabilities better
  • Composition could replace inheritance

Empty overrides should not be accepted without examining the abstraction.

Real-Project Question

Question: Your application currently has only one implementation of CustomerService. Should you create CustomerService and CustomerServiceImpl?

Answer:

Not automatically.

An interface should represent a useful boundary, not a naming convention.

I would create one if there is a clear reason such as:

  • Multiple implementations
  • Plugin architecture
  • External module boundary
  • Public contract
  • Implementation substitution
  • Significant architectural separation

Spring and modern mocking frameworks do not require an interface merely to unit-test a service.

Spring Boot Question

Question: Why is constructor injection with an interface commonly used in Spring?

Answer:

It allows the consuming class to depend on an abstraction.

Example:

JAVA
@Service
public class CheckoutService {
    private final PaymentProcessor paymentProcessor;

    public CheckoutService(PaymentProcessor paymentProcessor) {
        this.paymentProcessor = paymentProcessor;
    }
}

CheckoutService does not know the gateway-specific implementation.

Configuration can determine whether Spring injects Stripe, Razorpay, or another implementation.

Design Question

Question: Is composition always better than inheritance?

Answer:

No.

Composition is usually safer when the goal is behavior reuse or runtime flexibility.

Inheritance is appropriate when there is a genuine subtype relationship and the base class defines behavior or invariants that logically belong to every subclass.

The goal is not to eliminate inheritance but to use it intentionally.

29. Quick Rule to Remember

Use an interface for a capability; use an abstract class only when the implementations genuinely share a common implementation, state, or lifecycle.

30. Final Takeaway

Interfaces and abstract classes should not be selected based only on syntax or habit.

The developer should first identify the relationship being modeled.

Use an interface when different components should satisfy the same behavioral contract:

JAVA
PaymentProcessor
NotificationSender
FraudChecker
FileStorage

Use an abstract class when related implementations genuinely share a meaningful implementation or lifecycle.

During Pull Request review, pay particular attention to:

  • Subclasses inheriting irrelevant methods
  • Empty overrides
  • Protected mutable state
  • Base classes containing subtype-specific logic
  • Interfaces containing unrelated methods
  • Inheritance used only for utility-method reuse
  • Large default methods
  • Concrete dependencies where a useful contract already exists
  • Unnecessary interfaces providing no architectural value

Avoid choosing inheritance simply because two classes contain similar code.

First ask whether the classes genuinely have an inheritance relationship.

If the goal is only code reuse, composition is often the cleaner design.

The production-quality principle is simple:

Keep contracts focused, keep implementation details encapsulated, and introduce inheritance only when the domain genuinely requires it.