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:
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:
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:
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:
private final PaymentProcessor paymentProcessor;is usually easier to maintain than:
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:
PaymentProcessorinstead of depending directly on:
StripePaymentProcessorA 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:
PaymentProcessorwhile another develops:
RazorpayPaymentProcessorand another develops:
StripePaymentProcessorThe 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:
PaymentProcessor
NotificationSender
UserRepository
FraudChecker
FileStorage
OrderValidatorThe 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:
AbstractBatchProcessor
AbstractReportGenerator
AbstractExternalApiClientAn 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:
public class OrderService implements Auditable, Retryable, TransactionalOperation {
}But Java supports extending only one class:
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.
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:
smtpHostand:
connectToSmtpServer()even when those members are irrelevant.
Poor Maintainability
Suppose the base class later introduces additional email-specific fields:
smtpPort
smtpUsername
smtpPasswordEvery subclass still inherits them.
Misleading API
A developer can technically write:
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:
WhatsAppNotificationSender
SlackNotificationSender
TeamsNotificationSenderwould continue expanding an artificial inheritance tree.
9. Reviewer Comment Example
NotificationServicecontains SMTP-specific state and behavior that does not apply to SMS or push notifications. Could we model the commonsendcapability 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
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:
NotificationSenderrepresents exactly what callers need:
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:
EmailNotificationSenderSMS and push implementations are no longer exposed to email-specific infrastructure.
Validation Uses Composition
Common validation is implemented through:
NotificationMessageValidatorThis avoids forcing implementations into an inheritance hierarchy merely for code reuse.
Dependencies Are Easier to Replace
A consuming Spring service can depend on:
NotificationSenderinstead of a concrete notification class.
New Implementations Are Easier to Introduce
A new implementation can simply implement the interface:
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
| Area | Bad Design | Improved Design |
|---|---|---|
| Abstraction | Abstract class mixes multiple responsibilities | Interface represents notification capability |
| Coupling | SMS and push inherit SMTP behavior | Implementations contain only relevant behavior |
| Reuse | Inheritance used for validation reuse | Validation reused through composition |
| Maintainability | Base-class changes affect every subclass | Implementations evolve independently |
| Testability | Tests interact with unnecessary inheritance | Dependencies can be mocked or faked easily |
| Extensibility | New channels enter artificial hierarchy | New channels implement a small contract |
| Readability | Parent-child relationship is misleading | Intent 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:
AbstractNotificationServicecontaining:
- SMTP connection logic
- Message formatting
- Recipient validation
- Retry behavior
- Logging
Six months later, the business introduces:
- SMS
- 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:
@Override
protected String buildEmailSubject() {
return "";
}This is a strong signal that the abstraction is wrong.
A better architecture would expose:
NotificationSenderand compose specialized collaborators such as:
MessageValidator
RetryExecutor
NotificationAuditServiceEach 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:
UserService
UserServiceImplwhen 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:
public interface PaymentProcessor {
default PaymentResult process(PaymentRequest request) {
// Large workflow implementation
}
}This turns the interface into an implementation container.
Exposing Protected Fields
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
@Override
protected void prepareEmailSubject() {
}Empty overrides often indicate that the parent contract does not apply to every subclass.
Giant Interfaces
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:
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.
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:
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:
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:
NotificationSenderdefine 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:
send()may be the only operation they need.
Step 2: Introduce an Interface
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:
private final EmailNotificationService service;with:
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:
PaymentProcessor
NotificationSender
FraudCheckerover implementation-oriented contracts such as:
AbstractPaymentServiceBasewhen 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:
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:
RetryExecutorinstead of forcing every retryable service to extend:
AbstractRetryableServiceKeep 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:
AbstractCommonUtilsInheritance 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:
PaymentServiceImploften communicate less than:
StripePaymentServiceor:
DatabasePaymentRepositoryPrefer names describing implementation responsibility.
Deep Inheritance Hierarchies
Avoid structures such as:
BaseService
AbstractPaymentService
AbstractCardPaymentService
VisaPaymentServiceDeep 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
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?
The only shared behavior here is validation. Consider composition with a validator instead of introducing inheritance solely for code reuse.
This interface exposes operations that several implementations do not support. Can we split it into smaller capability-focused interfaces?
Could we inject PaymentProcessor instead of StripePaymentProcessor so the service is not coupled to a specific gateway implementation?
This subclass overrides three parent methods with empty implementations, which suggests the inheritance relationship may not fit the domain.
The protected mutable field makes subclasses dependent on internal parent state. Can we keep the state private and expose only the required behavior?
This default method contains substantial business logic. Consider moving the implementation to a dedicated service and keeping the interface focused on the contract.
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.
This abstract class is becoming a shared utility container. Could these helpers be moved into focused collaborators instead?
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
PaymentServicea valid abstraction for both payment types? - Does
WalletPaymentServiceneed every inherited member? - What does the empty
generateCardReceipt()implementation indicate? - Should
apiKeybe 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:
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:
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:
protected String apiKey;is accessible to all subclasses.
Additionally:
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
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
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:
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:
PaymentProcessorwhich 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:
PaymentProcessormay have:
StripePaymentProcessor
RazorpayPaymentProcessorConsumers 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:
validate
load
generate
upload
auditThe base class can implement the workflow and expose one abstract method such as:
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:
@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:
PaymentProcessor
NotificationSender
FraudChecker
FileStorageUse 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.