Java · lesson 19 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Interfaces in Java — A Deep Dive
Go beyond the basics — learn interface segregation, default methods, functional interfaces, and how interfaces enable real-world design patterns.
By SkillByExample · Updated
An interface is a contract: the methods a type promises, with no instance state. At the
intermediate level the question shifts from “how do I implement one?” to “how should I design
one?” — how big, how layered, how open. Each example is a single file run with the JDK 21 source
launcher (java File.java).
What an interface can contain
Abstract methods, default methods with bodies, static helpers, private helpers shared by
defaults (Java 9+), and constants.
public class Anatomy {
public static void main(String[] args) {
PaymentGateway gw = new FakeGateway();
System.out.println(gw.charge("cus_1", 19.99));
System.out.println(gw.chargeWithRetry("cus_2", 5.00));
System.out.println(PaymentGateway.format(19.99));
System.out.println("retries allowed: " + PaymentGateway.MAX_RETRIES);
}
}
interface PaymentGateway {
int MAX_RETRIES = 3; // implicitly public static final
String charge(String customer, double amount); // abstract
default String chargeWithRetry(String customer, double amount) {
return log("retrying up to " + MAX_RETRIES) + charge(customer, amount);
}
static String format(double amount) { return String.format("$%.2f", amount); }
private String log(String msg) { return "[" + msg + "] "; } // helper for defaults only
}
class FakeGateway implements PaymentGateway {
public String charge(String customer, double amount) {
return "charged " + customer + " " + PaymentGateway.format(amount);
}
}
$ java Anatomy.java
charged cus_1 $19.99
[retrying up to 3] charged cus_2 $5.00
$19.99
retries allowed: 3
FakeGateway wrote one method and got chargeWithRetry for free. format is called through the
interface name, never through an instance, and log is invisible outside the interface.
Interface segregation: fat interfaces force fake methods
One large interface forces every implementor to provide methods it has no use for.
public class FatInterface {
public static void main(String[] args) {
UserService svc = new InMemoryUsers();
svc.save("alice");
System.out.println(svc.find("alice"));
svc.sendWelcomeEmail("alice");
}
}
interface UserService {
void save(String user);
String find(String user);
void sendWelcomeEmail(String user);
byte[] exportCsv();
}
class InMemoryUsers implements UserService {
private final java.util.Set<String> users = new java.util.HashSet<>();
public void save(String u) { users.add(u); }
public String find(String u) { return users.contains(u) ? "found " + u : "no " + u; }
public void sendWelcomeEmail(String u) { throw new UnsupportedOperationException("no email here"); }
public byte[] exportCsv() { throw new UnsupportedOperationException("no export here"); }
}
$ java FatInterface.java
found alice
Exception in thread "main" java.lang.UnsupportedOperationException: no email here
at InMemoryUsers.sendWelcomeEmail(FatInterface.java:21)
at FatInterface.main(FatInterface.java:6)
The type said InMemoryUsers could send email; it couldn’t, and the lie surfaced only at runtime.
Splitting the interface makes the capability visible in the type:
import java.util.*;
public class Segregated {
public static void main(String[] args) {
InMemoryUsers repo = new InMemoryUsers();
UserNotifier notifier = user -> System.out.println("welcome mail to " + user);
register("alice", repo, notifier);
System.out.println(repo.find("alice"));
}
static void register(String user, UserRepository repo, UserNotifier notifier) {
repo.save(user);
notifier.sendWelcome(user);
}
}
interface UserRepository { void save(String user); String find(String user); }
interface UserNotifier { void sendWelcome(String user); }
class InMemoryUsers implements UserRepository {
private final Set<String> users = new HashSet<>();
public void save(String u) { users.add(u); }
public String find(String u) { return users.contains(u) ? "found " + u : "no " + u; }
}
$ java Segregated.java
welcome mail to alice
found alice
register asks for exactly the two capabilities it uses. UserNotifier has one method, so a
lambda implements it, and passing an InMemoryUsers as a notifier is now a compile error rather
than a runtime surprise.
Interfaces extending interfaces
An interface can extend several others and add defaults built on their methods.
import java.util.*;
public class Layered {
public static void main(String[] args) {
MemoryFs fs = new MemoryFs();
fs.write("/a.txt", "hello");
fs.copy("/a.txt", "/b.txt");
Readable reader = fs; // narrow view: read-only
System.out.println(reader.read("/b.txt"));
System.out.println(fs instanceof Writable);
}
}
interface Readable { String read(String path); }
interface Writable { void write(String path, String content); }
interface ReadWritable extends Readable, Writable {
default void copy(String from, String to) { write(to, read(from)); }
}
class MemoryFs implements ReadWritable {
private final Map<String, String> files = new HashMap<>();
public String read(String p) { return files.get(p); }
public void write(String p, String text) { files.put(p, text); }
}
$ java Layered.java
hello
true
A MemoryFs is a ReadWritable, a Readable and a Writable at once. Handing out the
Readable view lets a caller read without being able to write through that reference.
Functional interfaces as pipeline steps
With exactly one abstract method, an interface can be implemented by a lambda or method reference, and default methods can compose those lambdas.
public class Pipeline {
@FunctionalInterface
interface Transformer<T, R> {
R transform(T input);
default <V> Transformer<T, V> andThen(Transformer<R, V> next) {
return input -> next.transform(transform(input));
}
}
public static void main(String[] args) {
Transformer<String, String> trim = String::trim;
Transformer<String, String> lower = String::toLowerCase;
Transformer<String, Integer> words = s -> s.split("\\s+").length;
Transformer<String, Integer> pipeline = trim.andThen(lower).andThen(words);
System.out.println(pipeline.transform(" The Quick BROWN fox "));
System.out.println(trim.andThen(lower).transform(" MiXeD "));
}
}
$ java Pipeline.java
4
mixed
andThen returns a new lambda wrapping two others, so the pipeline is nested calls, not a list
of steps. The JDK’s java.util.function.Function has the same andThen, plus compose for the
other direction.
Strategy: swap implementations behind an interface
import java.util.*;
public class Pricing {
interface Discount { double apply(double price); default String label() { return getClass().getSimpleName(); } }
static class NoDiscount implements Discount { public double apply(double p) { return p; } }
static class Percent implements Discount {
private final double pct;
Percent(double pct) { this.pct = pct; }
public double apply(double p) { return p * (1 - pct / 100); }
public String label() { return pct + "% off"; }
}
public static void main(String[] args) {
List<Discount> strategies = List.of(new NoDiscount(), new Percent(15), p -> Math.max(0, p - 30));
for (Discount d : strategies) {
System.out.printf("%-12s %.2f%n", d.label().startsWith("Pricing$$Lambda") ? "flat -30" : d.label(), d.apply(120));
}
}
}
$ java Pricing.java
NoDiscount 120.00
15.0% off 102.00
flat -30 90.00
The loop calls the same two methods on every strategy. The third one is a lambda: Discount has
one abstract method, and its default label() reports the lambda’s generated class name, which
starts with Pricing$$Lambda — so the loop prints a friendlier label for it.
Sealed interfaces (Java 17)
sealed ... permits fixes the set of implementations. A switch over the interface can then be
exhaustive without a default.
public class Shapes {
sealed interface Shape permits Circle, Square, Triangle {}
record Circle(double r) implements Shape {}
record Square(double side) implements Shape {}
record Triangle(double base, double height) implements Shape {}
static double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.r() * c.r();
case Square sq -> sq.side() * sq.side();
case Triangle t -> 0.5 * t.base() * t.height();
};
}
public static void main(String[] args) {
for (Shape s : new Shape[] {new Circle(1), new Square(2), new Triangle(3, 4)}) {
System.out.printf("%s -> %.2f%n", s, area(s));
}
}
}
$ java Shapes.java
Circle[r=1.0] -> 3.14
Square[side=2.0] -> 4.00
Triangle[base=3.0, height=4.0] -> 6.00
Add a fourth record to permits without a matching case and area stops compiling. An ordinary
interface can’t offer that: anyone could implement it anywhere.
Test doubles through interfaces
Code that depends on an interface can be tested with a fake that records calls instead of sending real email.
import java.util.*;
public class Registration {
interface EmailService { void send(String to, String subject); }
static class RecordingEmail implements EmailService {
final List<String> sent = new ArrayList<>();
public void send(String to, String subject) { sent.add(to + ": " + subject); }
}
static class UserRegistration {
private final EmailService email;
UserRegistration(EmailService email) { this.email = email; }
void register(String address) {
if (!address.contains("@")) throw new IllegalArgumentException("bad address " + address);
email.send(address, "Welcome!");
}
}
public static void main(String[] args) {
RecordingEmail fake = new RecordingEmail();
UserRegistration reg = new UserRegistration(fake);
reg.register("alice@example.com");
try { reg.register("bob"); } catch (IllegalArgumentException e) { System.out.println("rejected: " + e.getMessage()); }
System.out.println("emails sent: " + fake.sent);
System.out.println(fake.sent.size() == 1 ? "TEST PASSED" : "TEST FAILED");
}
}
$ java Registration.java
rejected: bad address bob
emails sent: [alice@example.com: Welcome!]
TEST PASSED
UserRegistration never knows it was handed a fake. The same class runs in production with an
SMTP implementation passed to its constructor; nothing in it changes.
Practice
- Define a
SortStrategyinterface and an insertion-sort implementation, use it through a helper that sorts a copy of a list, and check it agrees withCollections.sort.
Solution
import java.util.*;
public class Sorting {
interface SortStrategy { void sort(List<Integer> list); }
static class InsertionSort implements SortStrategy {
public void sort(List<Integer> a) {
for (int i = 1; i < a.size(); i++) {
int key = a.get(i), j = i - 1;
while (j >= 0 && a.get(j) > key) { a.set(j + 1, a.get(j)); j--; }
a.set(j + 1, key);
}
}
}
static List<Integer> sorted(List<Integer> data, SortStrategy s) {
List<Integer> copy = new ArrayList<>(data);
s.sort(copy);
return copy;
}
public static void main(String[] args) {
List<Integer> data = List.of(5, 2, 8, 1, 9, 3);
List<Integer> mine = sorted(data, new InsertionSort());
List<Integer> jdk = sorted(data, Collections::sort);
System.out.println(mine + " agrees: " + mine.equals(jdk));
}
}
$ java Sorting.java
[1, 2, 3, 5, 8, 9] agrees: true
Collections::sort fits SortStrategy because it takes a List and returns nothing — a method
reference can stand in for a whole strategy class.
- Add a
compose(Transformer<V, T> before)default toTransformerthat runsbeforefirst, and showlower.compose(trim)gives the same result astrim.andThen(lower).
Solution
public class Compose {
@FunctionalInterface
interface Transformer<T, R> {
R transform(T input);
default <V> Transformer<T, V> andThen(Transformer<R, V> next) { return in -> next.transform(transform(in)); }
default <V> Transformer<V, R> compose(Transformer<V, T> before) { return in -> transform(before.transform(in)); }
}
public static void main(String[] args) {
Transformer<String, String> trim = String::trim;
Transformer<String, String> lower = String::toLowerCase;
System.out.println("[" + lower.compose(trim).transform(" HeLLo ") + "]");
System.out.println("[" + trim.andThen(lower).transform(" HeLLo ") + "]");
}
}
$ java Compose.java
[hello]
[hello]
a.compose(b) means “b, then a”; b.andThen(a) means the same thing written in reading order.
- A gateway once offered
fullRefund(id)implemented asrefund(id, -1), using-1to mean “everything”. Replace that sentinel with two methods:refund(id, amount)and adefault refundAll(id)built on an abstractamountCharged(id).
Solution
import java.util.HashMap;
import java.util.Map;
public class Refunds {
interface Gateway {
double amountCharged(String id);
String refund(String id, double amount);
default String refundAll(String id) { return refund(id, amountCharged(id)); }
}
public static void main(String[] args) {
Map<String, Double> charges = new HashMap<>(Map.of("ch_1", 40.0));
Gateway gw = new Gateway() {
public double amountCharged(String id) { return charges.get(id); }
public String refund(String id, double amount) { return "refunded " + amount + " on " + id; }
};
System.out.println(gw.refund("ch_1", 10));
System.out.println(gw.refundAll("ch_1"));
}
}
$ java Refunds.java
refunded 10.0 on ch_1
refunded 40.0 on ch_1
Each method now means one thing, and no implementation has to remember that -1 is special. A
real refund could also reject negative amounts outright.
- Add a
Rectrecord to the sealedShapehierarchy but not to theswitch. What does the compiler report?
Solution
public class SealedMissing {
sealed interface Shape permits Circle, Square, Rect {}
record Circle(double r) implements Shape {}
record Square(double side) implements Shape {}
record Rect(double w, double h) implements Shape {}
static double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.r() * c.r();
case Square sq -> sq.side() * sq.side();
};
}
public static void main(String[] args) { System.out.println(area(new Rect(2, 3))); }
}
$ java SealedMissing.java
SealedMissing.java:8: error: the switch expression does not cover all possible input values
return switch (s) {
^
1 error
error: compilation failed
The compiler knows every permitted subtype, so a missing case is caught at build time rather than
falling into a default branch at runtime.