Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
Lesson 6 · Object-Oriented Java
Interfaces, Polymorphism and Composition
Starting from a payment system full of if-else branches, this article shows why interfaces and polymorphism exist, how abstract classes differ from interfaces, what default methods are for, and why composition usually beats inheritance. It also previews the idea behind dependency injection.
For
Learners who understand classes, inheritance and overriding and want to design flexible, testable object-oriented code.
Level
Before you start
Inheritance, Overriding and Overloading (article 5): in particular dynamic dispatch.
On this page
Key takeaways
- Polymorphism lets code work with a general type while each object supplies its own behaviour. It replaces repeated if-else or switch logic on a type with one method call.
- An interface is a contract of behaviour. A class can implement many interfaces. Prefer programming to an interface so that callers do not depend on a particular implementation.
- Use an abstract class when subclasses share state or a partial implementation, and an interface when you are defining a capability that unrelated classes may offer. Default methods let interfaces evolve without breaking implementers.
- Composition means a class holds and uses other objects instead of inheriting from them. It is more flexible than inheritance and easier to test.
- Passing collaborators into a constructor, rather than creating them inside the class, is the idea that dependency injection frameworks automate.
A shop takes payments by card, by UPI and by net banking. A first version of the code is easy to write, and it grows a pattern you will see in many real programs: every time a new payment type appears, someone edits the same if chain in six places and misses one.
This article is about the tool Java offers to stop that: polymorphism through interfaces. It is also the foundation for Spring, because dependency injection is built from exactly these ideas.
What you will learn
#- Why branching on types does not scale, and what to do instead.
- What an interface is, and what polymorphism means.
- When to choose an abstract class and when an interface.
- What default methods are for.
- Why composition is usually better than inheritance.
- How constructor-passed dependencies make code testable.
The problem: branching on type
#Here is the first version of the payment code. It uses a String to say what kind of payment it is and branches on it:
// File: Main.javapublic class Main { static String pay(String method, long paise) { if (method.equals("CARD")) { long fee = paise * 2 / 100; return "card charged " + (paise + fee) + " (fee " + fee + ")"; } else if (method.equals("UPI")) { return "upi debited " + paise; } else if (method.equals("NETBANKING")) { return "netbanking transferred " + paise; } else { throw new IllegalArgumentException("unknown method: " + method); } } static String refund(String method, long paise) { if (method.equals("CARD")) { return "card refund " + paise; } else if (method.equals("UPI")) { return "upi refund " + paise; } return "refund not supported for " + method; // oops: NETBANKING forgotten } public static void main(String[] args) { System.out.println(pay("CARD", 100_000)); System.out.println(pay("UPI", 100_000)); System.out.println(refund("NETBANKING", 100_000)); }}card charged 102000 (fee 2000)upi debited 100000refund not supported for NETBANKINGIt works, and it hides several problems:
- Adding a type means finding every chain (
pay,refund, a fee calculator, a report) and editing it. Miss one and you get a silent bug, such as the forgotten net-banking refund above. - The type is a loose string. A typo (
"UPl") compiles fine and fails at runtime. - Each chain mixes concerns. The code that knows how a card fee works is tangled with code for other methods.
The underlying issue: the knowledge about each payment type is spread across the program instead of living with that type.
The idea: let each type carry its own behaviour
#What we want is one place per payment method that knows everything about it, and callers that simply say "pay". In Java, you express that with an interface.
An interface declares what something can do, without saying how. It is a contract.
// File: PaymentMethod.javapublic interface PaymentMethod { String name(); String pay(long paise); String refund(long paise);}Each payment type implements the contract:
// File: CardPayment.javapublic class CardPayment implements PaymentMethod { @Override public String name() { return "CARD"; } @Override public String pay(long paise) { long fee = paise * 2 / 100; return "card charged " + (paise + fee) + " (fee " + fee + ")"; } @Override public String refund(long paise) { return "card refund " + paise; }}// File: UpiPayment.javapublic class UpiPayment implements PaymentMethod { @Override public String name() { return "UPI"; } @Override public String pay(long paise) { return "upi debited " + paise; } @Override public String refund(long paise) { return "upi refund " + paise; }}// File: Main.javaimport java.util.List;public class Main { static void checkout(PaymentMethod method, long paise) { System.out.println(method.name() + ": " + method.pay(paise)); } public static void main(String[] args) { List<PaymentMethod> methods = List.of(new CardPayment(), new UpiPayment()); for (PaymentMethod method : methods) { checkout(method, 100_000); } PaymentMethod chosen = new UpiPayment(); System.out.println(chosen.refund(50_000)); }}CARD: card charged 102000 (fee 2000)UPI: upi debited 100000upi refund 50000Look closely at checkout. It receives a PaymentMethod. It does not know, and does not need to know, whether it is a card or UPI. It calls pay, and the actual object supplies the behaviour, through the dynamic dispatch you met in the last article. That is polymorphism (from the Greek for "many forms"): code written against a general type works with many specific types.
To add net banking now, you write one new class. The compiler forces it to implement every method of the contract, so you cannot forget refund. And checkout does not change at all. The principle that existing, working code should not need editing to support a new variation is sometimes called the open/closed principle: open for extension, closed for modification.
Formal terminology
#- An interface declares method signatures (and constants). A class that
implementsit must provide all the abstract methods, or itself be abstract. - A class can implement many interfaces (
class A implements B, C), unlikeextends, which allows only one superclass. Interfaces can also extend other interfaces. - A variable can have an interface type (
PaymentMethod method), and it can refer to any object whose class implements it. This is called programming to an interface. - Polymorphism is the ability to treat objects of different classes uniformly through a shared supertype, with each object's own method running.
- The relationship is "can do" or "is usable as", not just "is a kind of".
Default, static and private methods in interfaces
#Before Java 8, adding a method to an interface broke every class that implemented it. Java 8 introduced default methods: a method in an interface with a body, that implementers inherit unless they override it. The motivation was to let the standard library evolve. The Collection interface, for example, gained stream() and removeIf without breaking every existing collection class.
// File: Main.javapublic class Main { interface Greeter { String name(); default String greet() { return "Hello, " + name() + suffix(); } private String suffix() { // private helper (Java 9) return "!"; } static Greeter of(String name) { // static factory method return () -> name; } } static class Formal implements Greeter { public String name() { return "Dr. Rao"; } @Override public String greet() { return "Good day, " + name() + "."; } } public static void main(String[] args) { System.out.println(Greeter.of("Asha").greet()); System.out.println(new Formal().greet()); }}Hello, Asha!Good day, Dr. Rao.Greeter.of("Asha") builds a Greeter from a lambda, which is possible because Greeter has exactly one abstract method (a functional interface, covered in article 15). Formal overrides the default. Use default methods to evolve an interface, and for convenience methods built from the abstract ones. Do not use them to turn interfaces into a back door for multiple inheritance of state, because interfaces cannot hold instance fields.
If a class inherits conflicting defaults from two interfaces, the compiler forces it to override the method and choose, so there is no hidden ambiguity.
Abstract classes: partial implementations
#Sometimes related classes share not only a contract but also state and some of the code. An abstract class can hold fields, constructors and implemented methods, and leave some methods abstract for subclasses to fill in. You cannot instantiate it directly.
// File: Main.javapublic class Main { static abstract class Report { private final String title; protected Report(String title) { this.title = title; } // The template method: fixed steps in a fixed order public final String render() { return "== " + title + " ==\n" + body() + "\n(end of " + title + ")"; } protected abstract String body(); // subclasses must supply this } static class SalesReport extends Report { SalesReport() { super("Sales"); } @Override protected String body() { return "total: 1200"; } } static class StockReport extends Report { StockReport() { super("Stock"); } @Override protected String body() { return "items: 45"; } } public static void main(String[] args) { System.out.println(new SalesReport().render()); System.out.println(new StockReport().render()); }}== Sales ==total: 1200(end of Sales)== Stock ==items: 45(end of Stock)Report fixes the overall structure in render (a template method, which is final so it cannot be broken) and lets each subclass provide the varying part. This is inheritance used well: the subclasses really are reports, they share state (title), and the parent was designed for extension.
Interface or abstract class?
#This is one of the most common Java interview questions. The honest answer is about intent.
Interface | Abstract class | |
|---|---|---|
Describes | A capability or role | A partial base for closely related classes |
Instance state (fields) | No (only constants) | Yes |
Constructors | No | Yes |
Implemented methods |
| Any |
How many can a class use | Many ( | One ( |
Member access levels | Methods are public (except private helpers) | Any |
Typical question | "Can this thing do X?" | "Is this a kind of Y that shares code?" |
A practical guideline: start with an interface. It places the fewest constraints on implementers, which can be unrelated classes. Add an abstract class (which can implement the interface) only if you find shared state or code worth putting in a common base. Java's collections use exactly this pattern: the List interface, with AbstractList as a convenient skeleton.
Composition: has-a instead of is-a
#In the last article we saw that inheritance couples a subclass tightly to its parent. Composition is the alternative: instead of being a kind of something, a class has another object and delegates work to it.
Consider an order service that must take a payment and then notify the customer. You could try to model this with inheritance (CardOrderService extends OrderService, UpiOrderService extends OrderService), and then you would need a new subclass for every combination of payment and notification type. With composition, the service holds the pieces and uses them:
// File: PaymentGateway.javapublic interface PaymentGateway { boolean charge(String customer, long paise);}// File: Notifier.javapublic interface Notifier { void send(String customer, String message);}// File: OrderService.javapublic class OrderService { private final PaymentGateway gateway; private final Notifier notifier; public OrderService(PaymentGateway gateway, Notifier notifier) { this.gateway = gateway; this.notifier = notifier; } public boolean placeOrder(String customer, long paise) { boolean paid = gateway.charge(customer, paise); if (paid) { notifier.send(customer, "Order confirmed for " + paise); } else { notifier.send(customer, "Payment failed"); } return paid; }}// File: Main.javaimport java.util.ArrayList;import java.util.List;public class Main { public static void main(String[] args) { // Fakes, written for this demonstration PaymentGateway alwaysApprove = (customer, paise) -> true; PaymentGateway rejectLarge = (customer, paise) -> paise < 100_000; List<String> sent = new ArrayList<>(); Notifier recorder = (customer, message) -> sent.add(customer + ": " + message); OrderService approving = new OrderService(alwaysApprove, recorder); OrderService cautious = new OrderService(rejectLarge, recorder); approving.placeOrder("asha", 250_000); cautious.placeOrder("ravi", 250_000); cautious.placeOrder("meena", 50_000); sent.forEach(System.out::println); }}asha: Order confirmed for 250000ravi: Payment failedmeena: Order confirmed for 50000Three powerful things are happening:
OrderServicedepends on interfaces, not on concrete classes. It does not know whether the gateway talks to a bank or to a test double.- Dependencies are passed in through the constructor, not created inside the class with
new. The service is given its collaborators. - The behaviour is assembled from parts, so any gateway can be combined with any notifier, without any subclass.
The tiny lambdas used as test doubles here are fakes: simple stand-ins for the real thing, which make the class easy to test without a real payment system. This is the principle behind unit testing with mocks (article 25).
The idea of an object being handed its dependencies instead of constructing them is called dependency injection, and the container that does the handing for you automatically is what Spring is built around (article 31). Nothing in the example above needed a framework, and the framework adds no magic: it automates what new OrderService(gateway, notifier) does by hand.
Checking types: instanceof
#Sometimes you hold a general type and need to ask what it really is. The instanceof operator does that, and since Java 16 it can bind a variable at the same time (pattern matching for instanceof), avoiding a separate cast:
// File: Main.javapublic class Main { interface Shape {} record Circle(double radius) implements Shape {} record Square(double side) implements Shape {} static String describe(Shape shape) { if (shape instanceof Circle c) { return "circle of radius " + c.radius(); } else if (shape instanceof Square s) { return "square of side " + s.side(); } return "unknown shape"; } public static void main(String[] args) { System.out.println(describe(new Circle(2))); System.out.println(describe(new Square(3))); }}circle of radius 2.0square of side 3.0A long chain of instanceof checks is usually a sign of the very problem this article started with: behaviour that should live in the types themselves. Prefer a method on the interface. The legitimate uses are in code that must treat a closed set of data types differently, which sealed types and pattern matching for switch (article 9) handle much better. (The record keyword used here is explained in the same article.)
Common mistakes
#- Branching on a type code (a string, an int or an enum) when polymorphism fits.
- Declaring variables with the implementation type (
CardPayment p) when the interface (PaymentMethod p) would do. - Creating an interface for every class by reflex. Introduce one where there are several implementations or a need to substitute for testing.
- Putting state into what should be an interface, or using an abstract class when no state is shared.
- Reaching for inheritance to reuse code between unrelated things.
- Creating collaborators with
newinside a class, which makes it impossible to substitute them in a test. - A huge interface that forces implementers to supply methods they do not need. Small, focused interfaces are easier to implement and use.
Exercises
#Exercise 1
Predict
#Given Shape s = new Circle(2); where Circle implements Shape, can you call a method that exists only on Circle through s without a cast? Why or why not?
Exercise 2: Implement
#Write an interface Discount with long apply(long paise), and two implementations: PercentageDiscount (takes a percentage) and FlatDiscount (takes an amount in paise, but never goes below zero). Show a method that applies a list of discounts one after another.
Exercise 3: Interface or abstract class?
#For each, choose and justify: (a) a Comparable-like capability that strings, numbers and dates all offer, (b) a family of Employee types that share name, salary and a payroll template, (c) something that can be saved to a file, which unrelated classes may offer.
Exercise 4: Refactor
#A method contains if (type == CIRCLE) ... else if (type == SQUARE) ... else if (type == TRIANGLE) ... to compute an area. Describe the refactoring, and explain what happens when a fourth shape is added.
Exercise 5: Design for testing
#An InvoiceService calls new EmailSender() inside its constructor. Explain why that is hard to test, and show how to change it.
Show solutionHide solution
No, not without a cast. The compiler checks calls against the declared type of the variable, which is Shape. Even though the object is a Circle, the variable only promises the methods of Shape. To call a Circle-only method you must cast, ideally guarded with instanceof (if (s instanceof Circle c) { c.radius(); }), or put the needed behaviour on the interface. If you frequently need to downcast, reconsider the design.
Exercise 2
Show solutionHide solution
public interface Discount { long apply(long paise);}public class PercentageDiscount implements Discount { private final int percent; public PercentageDiscount(int percent) { this.percent = percent; } @Override public long apply(long paise) { return paise - paise * percent / 100; }}public class FlatDiscount implements Discount { private final long amount; public FlatDiscount(long amount) { this.amount = amount; } @Override public long apply(long paise) { return Math.max(0, paise - amount); }}static long applyAll(List<Discount> discounts, long paise) { long result = paise; for (Discount discount : discounts) { result = discount.apply(result); } return result;}The caller knows only Discount. A third kind of discount can be added without touching applyAll. (Each public type goes in its own file in a real project.)
Exercise 3
Show solutionHide solution
(a) Interface: it is a capability offered by unrelated classes, which have no shared implementation. (b) Abstract class: the types share state (name, salary) and a template for payroll, and are closely related. An interface could still describe the role, with an abstract class providing the skeleton. (c) Interface: unrelated classes can be "savable", and a class may need to be savable and have a different superclass, which is only possible with interfaces.
Exercise 4
Show solutionHide solution
Introduce a Shape interface with an area() method, make Circle, Square and Triangle implement it, and replace the chain with shape.area(). Each class holds the formula and data for its own shape. When a fourth shape appears, you add one class. The compiler checks that it implements area(), and no existing code changes. In the original version, you must find and edit every chain, and a forgotten one is a silent bug.
Exercise 5
Show solutionHide solution
new EmailSender() ties InvoiceService to a concrete class that really sends email, so any test that creates an InvoiceService would send real email (or need a real mail server). Extract an interface (Notifier or EmailSender), have InvoiceService accept it in its constructor, and pass in a real implementation in production and a simple fake or mock in tests: new InvoiceService(notifier). The service is now decoupled from how messages are sent, and the test can check what would have been sent.
Where to go next
#- Next:
equals,hashCode,toStringand the Object Contract opens the core Java module with the methods every object inherits. - For behaviour passed as values: Lambdas, Functional Interfaces and Method References.
- For injection by a framework: Dependency Injection and Inversion of Control.
- Technology
- Software Engineering
- #interview preparation
- #Java
- #Java backend
- #Java interview
- #JPA
- #learn Java
- #OOP
- #software engineering
- #Spring Boot
- #SQL
Connected lessons
Linked from 3 guides
Other pages in the library that point here.
Keep reading
More in Technology →Guide · Technology
Heaps and Priority Queues
Guide · Technology
Dynamic Programming Practice: From 1D to 2D
Course · Technology
DSA From Zero to Job-Ready: A Practical Guide with Java

