Java · lesson 25 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
Design Patterns in Java
Learn the most important Gang of Four design patterns with production-grade Java implementations — Singleton, Factory, Builder, Observer, and Strategy.
By SkillByExample · Updated
Design patterns are named solutions to recurring design problems. They are blueprints for
organising classes, not code to copy, and knowing them lets you say “this is a Strategy” and be
understood. Each example below is a single file run with the JDK 21 source launcher
(java File.java).
Singleton: exactly one instance
Some objects should exist once per application — configuration, a registry, a connection pool.
The holder idiom creates the instance lazily, and the JVM’s class-initialisation lock makes that
thread-safe without synchronized on every call.
import java.util.Map;
public class SingletonDemo {
public static void main(String[] args) {
System.out.println("before first use");
AppConfig a = AppConfig.getInstance();
AppConfig b = AppConfig.getInstance();
System.out.println("same object? " + (a == b));
System.out.println("db.host = " + a.get("db.host"));
}
}
final class AppConfig {
private static final class Holder {
static final AppConfig INSTANCE = new AppConfig();
}
private final Map<String, String> props;
private AppConfig() {
System.out.println("loading config (runs once)");
props = Map.of("db.host", System.getenv().getOrDefault("DB_HOST", "localhost"));
}
static AppConfig getInstance() { return Holder.INSTANCE; }
String get(String key) { return props.get(key); }
}
$ java SingletonDemo.java
before first use
loading config (runs once)
same object? true
db.host = localhost
“loading config” prints after “before first use”: Holder isn’t initialised until
getInstance() first touches it, and never again. The constructor is private, so new AppConfig() outside the class is a compile error. DB_HOST was not set in this shell, so the
default applied.
Factory: decouple creation from use
A factory method picks the concrete class from a runtime value, so callers depend only on the interface.
public class FactoryDemo {
public static void main(String[] args) {
Notification n = NotificationFactory.create("email");
n.send("alice@example.com", "Your order has shipped.");
NotificationFactory.create("SMS").send("+15551234567", "Your order has shipped.");
NotificationFactory.create("carrier-pigeon");
}
}
interface Notification { void send(String to, String msg); }
class NotificationFactory {
static Notification create(String channel) {
return switch (channel.toLowerCase()) {
case "email" -> (to, msg) -> System.out.println("Email -> " + to + ": " + msg);
case "sms" -> (to, msg) -> System.out.println("SMS -> " + to + ": " + msg);
default -> throw new IllegalArgumentException("Unknown channel: " + channel);
};
}
}
$ java FactoryDemo.java
Email -> alice@example.com: Your order has shipped.
SMS -> +15551234567: Your order has shipped.
Exception in thread "main" java.lang.IllegalArgumentException: Unknown channel: carrier-pigeon
at NotificationFactory.create(FactoryDemo.java:17)
at FactoryDemo.main(FactoryDemo.java:6)
main never names a concrete class; Notification is a functional interface, so each channel
here is a lambda (in real code, often a full class). An unknown channel fails fast at creation
instead of producing an object that silently does nothing.
Builder: many optional parameters
A constructor like new HttpRequest("POST", url, null, 10000, false, headers) is unreadable. A
builder names every argument, supplies defaults, and validates once in build().
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
public class BuilderDemo {
public static void main(String[] args) {
HttpRequest req = HttpRequest.builder("POST", "https://api.example.com/orders")
.header("Authorization", "Bearer token123")
.header("Content-Type", "application/json")
.timeout(10_000)
.build();
System.out.println(req);
System.out.println(HttpRequest.builder("GET", "https://example.com").build());
HttpRequest.builder("GET", "https://example.com").timeout(-1).build();
}
}
record HttpRequest(String method, String url, Map<String, String> headers, int timeoutMs) {
static Builder builder(String method, String url) { return new Builder(method, url); }
static final class Builder {
private final String method, url;
private final Map<String, String> headers = new LinkedHashMap<>();
private int timeoutMs = 5_000; // default
private Builder(String method, String url) { this.method = method; this.url = url; }
Builder header(String k, String v) { headers.put(k, v); return this; }
Builder timeout(int ms) { timeoutMs = ms; return this; }
HttpRequest build() {
if (timeoutMs <= 0) throw new IllegalStateException("timeout must be positive: " + timeoutMs);
return new HttpRequest(method, url, Collections.unmodifiableMap(new LinkedHashMap<>(headers)), timeoutMs);
}
}
}
$ java BuilderDemo.java
HttpRequest[method=POST, url=https://api.example.com/orders, headers={Authorization=Bearer token123, Content-Type=application/json}, timeoutMs=10000]
HttpRequest[method=GET, url=https://example.com, headers={}, timeoutMs=5000]
Exception in thread "main" java.lang.IllegalStateException: timeout must be positive: -1
at HttpRequest$Builder.build(BuilderDemo.java:30)
at BuilderDemo.main(BuilderDemo.java:14)
Each setter returns this, which is what makes the chain work. The second request shows the
default timeout, and the third shows validation happening once, in build(). The headers are
copied into a fresh LinkedHashMap so they keep insertion order and later builder calls can’t
change a request that was already built; Map.copyOf would also copy, but its iteration order
is unspecified and can differ from run to run.
Strategy: swap an algorithm at runtime
Extract each algorithm behind an interface and hold it in a field; the class using it never changes.
import java.util.*;
public class StrategyDemo {
public static void main(String[] args) {
List<String> words = List.of("kiwi", "Banana", "apple", "fig");
Sorter sorter = new Sorter(Comparator.naturalOrder());
System.out.println(sorter.sort(words));
sorter.setStrategy(String.CASE_INSENSITIVE_ORDER);
System.out.println(sorter.sort(words));
sorter.setStrategy(Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder()));
System.out.println(sorter.sort(words));
}
}
class Sorter {
private Comparator<String> strategy;
Sorter(Comparator<String> strategy) { this.strategy = strategy; }
void setStrategy(Comparator<String> s) { this.strategy = s; }
List<String> sort(List<String> in) {
List<String> copy = new ArrayList<>(in);
copy.sort(strategy);
return copy;
}
}
$ java StrategyDemo.java
[Banana, apple, fig, kiwi]
[apple, Banana, fig, kiwi]
[fig, kiwi, apple, Banana]
Comparator is the Strategy pattern in the JDK. Natural order puts uppercase before lowercase,
which is why “Banana” comes first in line one. Sorter.sort is identical for all three runs;
only the strategy object changed.
Observer: publish once, notify many
Producers publish events; subscribers register independently. Neither side knows the other’s classes.
import java.util.*;
import java.util.function.Consumer;
public class ObserverDemo {
record OrderPlaced(String orderId, double total, String customer) {}
public static void main(String[] args) {
EventBus<OrderPlaced> bus = new EventBus<>();
bus.subscribe(e -> System.out.println("Email sent to " + e.customer()));
bus.subscribe(e -> System.out.println("Inventory reserved for " + e.orderId()));
bus.subscribe(e -> System.out.println("Analytics: total $" + e.total()));
bus.publish(new OrderPlaced("ORD-001", 59.99, "alice@example.com"));
}
}
class EventBus<T> {
private final List<Consumer<T>> listeners = new ArrayList<>();
void subscribe(Consumer<T> l) { listeners.add(l); }
void publish(T event) { listeners.forEach(l -> l.accept(event)); }
}
$ java ObserverDemo.java
Email sent to alice@example.com
Inventory reserved for ORD-001
Analytics: total $59.99
One publish call reaches all three subscribers, in subscription order. Adding a fourth needs no
change to EventBus or to the code that publishes.
Decorator: layer behaviour, and order matters
A decorator implements the same interface as the object it wraps. Stacking them combines behaviours — and the stacking order changes what each layer sees.
import java.util.*;
public class StoreDemo {
public static void main(String[] args) {
DataStore a = new LoggingStore(new EncryptingStore(new MemoryStore()));
a.save("token", "abc123");
DataStore b = new EncryptingStore(new LoggingStore(new MemoryStore()));
b.save("token", "abc123");
}
}
interface DataStore { void save(String key, String value); }
class MemoryStore implements DataStore {
private final Map<String, String> data = new HashMap<>();
public void save(String k, String v) { data.put(k, v); }
}
class LoggingStore implements DataStore {
private final DataStore inner;
LoggingStore(DataStore inner) { this.inner = inner; }
public void save(String k, String v) { System.out.println("[LOG] save " + k + "=" + v); inner.save(k, v); }
}
class EncryptingStore implements DataStore {
private final DataStore inner;
EncryptingStore(DataStore inner) { this.inner = inner; }
public void save(String k, String v) { inner.save(k, "ENC(" + new StringBuilder(v).reverse() + ")"); }
}
$ java StoreDemo.java
[LOG] save token=abc123
[LOG] save token=ENC(321cba)
In a the logger is outermost, so it logs the plaintext before encryption — a secret leaked to
the logs. In b the logger sits inside the encryptor and only ever sees ciphertext. (The
“encryption” is a reversed string; never use it for real data.)
Pattern selection guide
| Problem | Pattern |
|---|---|
| Exactly one instance | Singleton |
| Choose a concrete class at runtime | Factory |
| Many optional constructor arguments | Builder |
| Swap an algorithm at runtime | Strategy |
| Notify many objects of an event | Observer |
| Add behaviour without changing a class | Decorator |
| Fixed algorithm with variable steps | Template Method |
Practice
- Rewrite
AppConfigas an enum singleton and show that two lookups return the same instance.
Solution
public class EnumSingleton {
public static void main(String[] args) {
System.out.println(Config.INSTANCE == Config.valueOf("INSTANCE"));
System.out.println(Config.INSTANCE.get("db.host"));
}
}
enum Config {
INSTANCE;
private final java.util.Map<String, String> props = java.util.Map.of("db.host", "localhost");
String get(String key) { return props.get(key); }
}
$ java EnumSingleton.java
true
localhost
The JVM creates each enum constant exactly once, so the enum is a singleton with no holder class and no private constructor to write.
- Make
EventBus.publishkeep going when one listener throws: catch, report, continue.
Solution
import java.util.*;
import java.util.function.Consumer;
public class SafeBus {
public static void main(String[] args) {
EventBus<String> bus = new EventBus<>();
bus.subscribe(e -> System.out.println("first got " + e));
bus.subscribe(e -> { throw new IllegalStateException("second is broken"); });
bus.subscribe(e -> System.out.println("third got " + e));
bus.publish("ORD-002");
}
}
class EventBus<T> {
private final List<Consumer<T>> listeners = new ArrayList<>();
void subscribe(Consumer<T> l) { listeners.add(l); }
void publish(T event) {
for (Consumer<T> l : listeners) {
try { l.accept(event); }
catch (RuntimeException ex) { System.out.println("listener failed: " + ex.getMessage()); }
}
}
}
$ java SafeBus.java
first got ORD-002
listener failed: second is broken
third got ORD-002
Without the try, the exception would escape forEach and the third listener would never run.
- Add a
"push"channel toNotificationFactoryand send a message through it.
Solution
public class PushFactory {
public static void main(String[] args) {
NotificationFactory.create("push").send("device-42", "Sale starts now");
}
}
interface Notification { void send(String to, String msg); }
class NotificationFactory {
static Notification create(String channel) {
return switch (channel.toLowerCase()) {
case "email" -> (to, msg) -> System.out.println("Email -> " + to + ": " + msg);
case "sms" -> (to, msg) -> System.out.println("SMS -> " + to + ": " + msg);
case "push" -> (to, msg) -> System.out.println("Push -> " + to + ": " + msg);
default -> throw new IllegalArgumentException("Unknown channel: " + channel);
};
}
}
$ java PushFactory.java
Push -> device-42: Sale starts now
Only the factory changed; every caller that asks for a channel by name can now get push.
- Add a
CountingStoredecorator that countssavecalls, wrap it around an encrypting chain (EncryptingStoreoverMemoryStore) as inStoreDemo, and print the count after three saves.
Solution
import java.util.*;
public class CountingDemo {
public static void main(String[] args) {
CountingStore store = new CountingStore(new EncryptingStore(new MemoryStore()));
store.save("a", "1"); store.save("b", "2"); store.save("a", "3");
System.out.println("saves: " + store.count);
}
}
interface DataStore { void save(String key, String value); }
class MemoryStore implements DataStore {
private final Map<String, String> data = new HashMap<>();
public void save(String k, String v) { data.put(k, v); }
}
class EncryptingStore implements DataStore {
private final DataStore inner;
EncryptingStore(DataStore inner) { this.inner = inner; }
public void save(String k, String v) { inner.save(k, "ENC(" + new StringBuilder(v).reverse() + ")"); }
}
class CountingStore implements DataStore {
private final DataStore inner;
int count = 0;
CountingStore(DataStore inner) { this.inner = inner; }
public void save(String k, String v) { count++; inner.save(k, v); }
}
$ java CountingDemo.java
saves: 3
For counting, position in the chain doesn’t matter — every layer sees every call. It matters only for decorators that look at or change the data, like logging versus encryption.