Java · lesson 24 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
11Also part of: Low-Level Design
SOLID Principles in Java
Learn the five SOLID design principles by running Java examples: each shows the violation, what breaks, and the refactor that fixes it.
By SkillByExample · Updated
SOLID names five design principles, collected by Robert C. Martin, that keep object-oriented code
easy to change and test. Each section below shows a small violation, runs it so you can see what
goes wrong, then runs the refactor. Every example is a single file run with the JDK 21 source
launcher (java File.java).
S — Single Responsibility
A class should have one, and only one, reason to change.
import java.util.ArrayList;
import java.util.List;
public class SrpDemo {
record Item(String name, double price, int qty) {
double subtotal() { return price * qty; }
}
static class Order { // domain rules only
final List<Item> items = new ArrayList<>();
void add(String name, double price, int qty) { items.add(new Item(name, price, qty)); }
double total() { return items.stream().mapToDouble(Item::subtotal).sum(); }
}
static class InvoiceFormatter { // presentation only
String format(Order o) {
StringBuilder sb = new StringBuilder();
for (Item i : o.items) sb.append("%-8s %d x %6.2f%n".formatted(i.name(), i.qty(), i.price()));
return sb.append("TOTAL %15.2f".formatted(o.total())).toString();
}
}
static class Notifier { // delivery only
void send(String to, String body) { System.out.println("to " + to + ":\n" + body); }
}
public static void main(String[] args) {
Order order = new Order();
order.add("Widget", 25.0, 2);
order.add("Gadget", 15.0, 1);
new Notifier().send("ada@example.com", new InvoiceFormatter().format(order));
}
}
$ java SrpDemo.java
to ada@example.com:
Widget 2 x 25.00
Gadget 1 x 15.00
TOTAL 65.00
If Order also formatted invoices and sent mail, a change to the email provider or the invoice
layout would mean editing — and retesting — the class that computes totals. Split along those
reasons to change, each class can be tested alone: Order with no I/O at all, InvoiceFormatter
with a hand-built order.
O — Open/Closed
Open for extension, closed for modification.
import java.util.List;
public class OcpDemo {
interface Discount {
double apply(double price);
String description();
}
record Percentage(double pct) implements Discount {
public double apply(double p) { return p * (1 - pct / 100); }
public String description() { return pct + "% off"; }
}
record Flat(double amount) implements Discount {
public double apply(double p) { return Math.max(0, p - amount); }
public String description() { return "$" + amount + " off"; }
}
record HalfPrice() implements Discount { // added later: no existing code edited
public double apply(double p) { return p / 2; }
public String description() { return "half price"; }
}
static double checkout(double total, Discount d) { return d.apply(total); }
public static void main(String[] args) {
for (Discount d : List.of(new Percentage(10), new Flat(10), new HalfPrice()))
System.out.printf("%-12s -> %.2f%n", d.description(), checkout(65.0, d));
}
}
$ java OcpDemo.java
10.0% off -> 58.50
$10.0 off -> 55.00
half price -> 32.50
The alternative — if (type.equals("PERCENT")) ... else if (type.equals("FLAT")) inside
checkout — needs an edit to tested code for every new discount. Here checkout only knows the
Discount interface, so HalfPrice arrived as a new class and nothing that already worked was
touched.
L — Liskov Substitution
Subtypes must be usable anywhere their parent type is expected.
public class LspBugDemo {
static class Rectangle {
protected double width, height;
void setWidth(double w) { width = w; }
void setHeight(double h) { height = h; }
double area() { return width * height; }
}
static class Square extends Rectangle {
@Override void setWidth(double s) { width = s; height = s; }
@Override void setHeight(double s) { width = s; height = s; }
}
static void resize(Rectangle r) { // written against Rectangle's contract
r.setWidth(5);
r.setHeight(3);
System.out.println(r.getClass().getSimpleName() + " area = " + r.area() + " (expected 15.0)");
}
public static void main(String[] args) {
resize(new Rectangle());
resize(new Square());
}
}
$ java LspBugDemo.java
Rectangle area = 15.0 (expected 15.0)
Square area = 9.0 (expected 15.0)
resize relies on width and height being independent, which Rectangle promises. Square
compiles fine as a subclass but breaks that promise: setHeight(3) silently rewrites the width
too. A square is a rectangle mathematically, but not a mutable Rectangle behaviourally.
import java.util.List;
public class LspFixDemo {
interface Shape { double area(); }
record Rectangle(double width, double height) implements Shape {
public double area() { return width * height; }
}
record Square(double side) implements Shape {
public double area() { return side * side; }
}
public static void main(String[] args) {
List<Shape> shapes = List.of(new Rectangle(5, 3), new Square(3));
for (Shape s : shapes) System.out.println(s + " area = " + s.area());
}
}
$ java LspFixDemo.java
Rectangle[width=5.0, height=3.0] area = 15.0
Square[side=3.0] area = 9.0
Making both immutable siblings under a Shape interface removes the false promise: there are no
setters to misbehave, and every Shape honours the one contract it declares — area().
I — Interface Segregation
Clients should not be forced to depend on methods they do not use.
public class IspDemo {
interface AllInOne { void print(String doc); void scan(String doc); void fax(String doc); }
static class BasicPrinter implements AllInOne {
public void print(String doc) { System.out.println("printing " + doc); }
public void scan(String doc) { throw new UnsupportedOperationException("scan"); }
public void fax(String doc) { throw new UnsupportedOperationException("fax"); }
}
interface Printer { void print(String doc); }
interface Scanner { void scan(String doc); }
record SimplePrinter() implements Printer {
public void print(String doc) { System.out.println("printing " + doc); }
}
public static void main(String[] args) {
AllInOne fat = new BasicPrinter();
fat.print("report.pdf");
try { fat.scan("report.pdf"); }
catch (UnsupportedOperationException e) { System.out.println("runtime surprise: " + e); }
Printer lean = new SimplePrinter();
lean.print("report.pdf");
// lean.scan("report.pdf"); // would not compile: Printer has no scan method
}
}
$ java IspDemo.java
printing report.pdf
runtime surprise: java.lang.UnsupportedOperationException: scan
printing report.pdf
The fat interface forces BasicPrinter to stub methods it cannot support, and the mistake only
shows up when someone calls them at runtime. With narrow Printer/Scanner interfaces a class
implements exactly what it can do, and calling scan on a Printer is a compile error instead.
D — Dependency Inversion
Depend on abstractions, not concretions.
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class DipDemo {
record User(long id, String name) {}
interface UserRepository { Optional<User> findById(long id); }
static class InMemoryUserRepository implements UserRepository {
private final Map<Long, User> users = new HashMap<>(Map.of(1L, new User(1, "Ada")));
public Optional<User> findById(long id) { return Optional.ofNullable(users.get(id)); }
}
static class UserService {
private final UserRepository repo;
UserService(UserRepository repo) { this.repo = repo; } // injected, not new'd
String greet(long id) { return repo.findById(id).map(u -> "Hello, " + u.name()).orElse("Unknown user " + id); }
}
public static void main(String[] args) {
UserService real = new UserService(new InMemoryUserRepository());
System.out.println(real.greet(1) + " | " + real.greet(2));
UserService stubbed = new UserService(id -> Optional.of(new User(id, "Test User")));
System.out.println(stubbed.greet(42));
}
}
$ java DipDemo.java
Hello, Ada | Unknown user 2
Hello, Test User
UserService never names a concrete repository, so the same greet code runs against an
in-memory store, a real database class, or — in a test — a lambda. The lambda works because
UserRepository has a single abstract method; add save to it and the stub needs a full class,
one more reason to keep interfaces small.
How the five fit together
| Principle | In the examples |
|---|---|
| SRP | Order, InvoiceFormatter, Notifier each change for one reason |
| OCP | New Discount types arrive as new classes |
| LSP | Square and Rectangle share an honest Shape contract |
| ISP | Printer and Scanner instead of one fat device interface |
| DIP | UserService gets its UserRepository injected |
Practice
- Add a
Stacked(Discount first, Discount second)discount that applies both in order, without changingDiscount,checkout, or any existing discount.
Solution
public class StackedDiscount {
interface Discount { double apply(double price); }
record Percentage(double pct) implements Discount {
public double apply(double p) { return p * (1 - pct / 100); }
}
record Flat(double amount) implements Discount {
public double apply(double p) { return Math.max(0, p - amount); }
}
record Stacked(Discount first, Discount second) implements Discount {
public double apply(double p) { return second.apply(first.apply(p)); }
}
public static void main(String[] args) {
System.out.printf("%.2f%n", new Stacked(new Percentage(10), new Flat(10)).apply(65.0));
System.out.printf("%.2f%n", new Stacked(new Flat(10), new Percentage(10)).apply(65.0));
}
}
$ java StackedDiscount.java
48.50
49.50
Order matters: 10% then $10 off is not the same as $10 then 10%. Stacked is itself a
Discount, so stacks can be nested.
- Give
DipDemoa second implementation that records every lookup, and use it in place of the in-memory one to check which idsgreetasked for — without changingUserService.
Solution
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class RecordingRepo {
record User(long id, String name) {}
interface UserRepository { Optional<User> findById(long id); }
static class UserService {
private final UserRepository repo;
UserService(UserRepository repo) { this.repo = repo; }
String greet(long id) { return repo.findById(id).map(u -> "Hello, " + u.name()).orElse("Unknown user " + id); }
}
public static void main(String[] args) {
List<Long> lookups = new ArrayList<>();
UserRepository spy = id -> { lookups.add(id); return Optional.empty(); };
UserService service = new UserService(spy);
service.greet(7);
service.greet(9);
System.out.println("looked up: " + lookups);
}
}
$ java RecordingRepo.java
looked up: [7, 9]
This hand-written “spy” is exactly what mocking libraries such as Mockito generate for you.
- Split
AllInOneso aMultiFunctionDevicecan implement bothPrinterandScanner, and write a method that accepts only things that can scan.
Solution
public class MultiFunction {
interface Printer { void print(String doc); }
interface Scanner { String scan(String doc); }
static class MultiFunctionDevice implements Printer, Scanner {
public void print(String doc) { System.out.println("printing " + doc); }
public String scan(String doc) { return "scan-of-" + doc; }
}
static void archive(Scanner s, String doc) { System.out.println("archived " + s.scan(doc)); }
public static void main(String[] args) {
MultiFunctionDevice mfd = new MultiFunctionDevice();
mfd.print("contract.pdf");
archive(mfd, "contract.pdf");
}
}
$ java MultiFunction.java
printing contract.pdf
archived scan-of-contract.pdf
archive asks for the smallest capability it needs, so any scanner works — including one that
cannot print.