-
Notifications
You must be signed in to change notification settings - Fork 135
SE 05 Design Patterns
Part of the Software Engineering Principles series
A design pattern is a reusable solution to a commonly occurring problem in software design. Patterns are not ready-made code — they are templates describing how to structure classes and their interactions to solve a specific type of problem.
The concept was formalised by the "Gang of Four" (Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides) in their 1994 book Design Patterns: Elements of Reusable Object-Oriented Software, which catalogued 23 patterns across three categories.
Why learn patterns?
- Shared vocabulary — saying "use a Strategy pattern here" communicates a complete design intent
- Proven solutions — patterns have been refined across thousands of projects
- A foundation for understanding frameworks — most frameworks are patterns applied at scale
| Category | Purpose | Patterns |
|---|---|---|
| Creational | How objects are created | Singleton, Factory Method, Abstract Factory, Builder, Prototype |
| Structural | How objects are composed | Adapter, Bridge, Composite, Decorator, Facade, Flyweight, Proxy |
| Behavioural | How objects communicate | Strategy, Observer, Command, Template Method, Iterator, Chain of Responsibility, State, Mediator, Visitor, Memento, Interpreter |
Ensures a class has only one instance and provides a global point of access to it.
When to use: For resources that should be shared across the application — configuration, connection pools, caches.
public class ApplicationConfig {
private static ApplicationConfig instance;
private Properties props;
private ApplicationConfig() {
props = loadFromDatabase();
}
public static synchronized ApplicationConfig getInstance() {
if (instance == null) {
instance = new ApplicationConfig();
}
return instance;
}
public String get(String key) { return props.getProperty(key); }
}Caution: Singletons introduce global state and can make testing difficult. In Java EE, prefer @ApplicationScoped CDI beans over manual Singletons — the container manages the lifecycle.
Defines an interface for creating an object, but lets subclasses decide which class to instantiate.
When to use: When the exact type of object to create is determined by context.
public abstract class ReportFactory {
public final Report create(ReportRequest request) {
Report report = createReport(request);
report.applyHeader();
return report;
}
protected abstract Report createReport(ReportRequest request);
}
public class PharmacyReportFactory extends ReportFactory {
@Override
protected Report createReport(ReportRequest request) {
return new PharmacyStockReport(request);
}
}
public class BillingReportFactory extends ReportFactory {
@Override
protected Report createReport(ReportRequest request) {
return new BillingSummaryReport(request);
}
}Constructs a complex object step by step, allowing different representations to be produced by the same construction process.
When to use: When an object has many optional parameters or requires a multi-step setup.
public class PatientSearchCriteria {
private final String name;
private final String phone;
private final LocalDate dobFrom;
private final LocalDate dobTo;
private final boolean activeOnly;
private PatientSearchCriteria(Builder b) {
this.name = b.name;
this.phone = b.phone;
this.dobFrom = b.dobFrom;
this.dobTo = b.dobTo;
this.activeOnly = b.activeOnly;
}
public static class Builder {
private String name;
private String phone;
private LocalDate dobFrom;
private LocalDate dobTo;
private boolean activeOnly = true;
public Builder name(String name) { this.name = name; return this; }
public Builder phone(String phone) { this.phone = phone; return this; }
public Builder dobRange(LocalDate from, LocalDate to) {
this.dobFrom = from; this.dobTo = to; return this;
}
public Builder includeInactive() { this.activeOnly = false; return this; }
public PatientSearchCriteria build() { return new PatientSearchCriteria(this); }
}
}
// Usage
PatientSearchCriteria criteria = new PatientSearchCriteria.Builder()
.name("Perera")
.phone("077")
.build();Converts the interface of a class into another interface that clients expect. Allows incompatible interfaces to work together.
When to use: When integrating a third-party library or legacy system whose interface does not match what your code expects.
// Your system expects this
public interface LabResultProvider {
LabResult getResult(String orderNumber);
}
// External LIMS system has this
public class ExternalLimsClient {
public LimsResponse fetchByOrderId(int orderId) { ... }
}
// Adapter bridges the gap
public class LimsAdapter implements LabResultProvider {
private ExternalLimsClient limsClient;
@Override
public LabResult getResult(String orderNumber) {
LimsResponse response = limsClient.fetchByOrderId(Integer.parseInt(orderNumber));
return convertToLabResult(response);
}
}Attaches additional responsibilities to an object dynamically, as an alternative to subclassing for extending functionality.
When to use: When you need to add behaviour to individual objects without affecting others of the same class.
public interface BillPrinter {
void print(Bill bill);
}
public class BasicBillPrinter implements BillPrinter {
public void print(Bill bill) { /* basic print */ }
}
// Decorator adds logging without modifying the original
public class AuditingBillPrinter implements BillPrinter {
private BillPrinter wrapped;
public AuditingBillPrinter(BillPrinter wrapped) { this.wrapped = wrapped; }
@Override
public void print(Bill bill) {
log("Printing bill: " + bill.getId());
wrapped.print(bill);
log("Print complete");
}
}Provides a simplified interface to a complex subsystem.
When to use: When a subsystem has many classes and the caller only needs a small part of the functionality.
// The facade — one simple method covers many coordinated steps
public class PatientAdmissionFacade {
@Inject AdmissionService admissionService;
@Inject BedAllocationService bedService;
@Inject BillingService billingService;
@Inject NotificationService notificationService;
public Admission admit(Patient patient, Ward ward, AdmissionType type) {
Admission admission = admissionService.create(patient, type);
Bed bed = bedService.allocate(ward, admission);
billingService.openInpatientAccount(admission);
notificationService.notifyWard(ward, admission);
return admission;
}
}Callers work with one method instead of coordinating four services directly.
Defines a family of algorithms, encapsulates each one, and makes them interchangeable. Lets the algorithm vary independently from clients that use it.
When to use: When you have multiple ways to perform an operation and want to switch between them.
public interface PricingStrategy {
double calculate(double basePrice, Patient patient);
}
public class StandardPricing implements PricingStrategy {
public double calculate(double basePrice, Patient patient) { return basePrice; }
}
public class InsurancePricing implements PricingStrategy {
public double calculate(double basePrice, Patient patient) {
return basePrice * patient.getInsuranceCoverageRate();
}
}
public class BillingEngine {
private PricingStrategy strategy;
public BillingEngine(PricingStrategy strategy) { this.strategy = strategy; }
public Bill generateBill(Patient patient, List<Service> services) {
double total = services.stream()
.mapToDouble(s -> strategy.calculate(s.getBasePrice(), patient))
.sum();
return new Bill(patient, total);
}
}Defines a one-to-many dependency so that when one object changes state, all its dependents are notified and updated automatically.
When to use: When a change in one object requires updating others, and you do not want those objects tightly coupled.
public interface StockObserver {
void onLowStock(StockItem item);
}
public class StockManager {
private List<StockObserver> observers = new ArrayList<>();
public void addObserver(StockObserver o) { observers.add(o); }
public void issueStock(StockItem item, int quantity) {
item.deduct(quantity);
if (item.getQuantity() < item.getReorderLevel()) {
observers.forEach(o -> o.onLowStock(item));
}
}
}
public class PurchaseOrderTrigger implements StockObserver {
public void onLowStock(StockItem item) {
// automatically raise a purchase order
}
}
public class StockAlertNotifier implements StockObserver {
public void onLowStock(StockItem item) {
// send alert to pharmacy manager
}
}Defines the skeleton of an algorithm in a base class, deferring some steps to subclasses.
When to use: When multiple classes share the same overall process but differ in specific steps.
public abstract class ReportGenerator {
// Template method — defines the algorithm skeleton
public final Report generate(DateRange range) {
List<?> data = fetchData(range);
List<?> processed = processData(data);
return formatReport(processed);
}
protected abstract List<?> fetchData(DateRange range);
protected abstract List<?> processData(List<?> raw);
protected abstract Report formatReport(List<?> processed);
}
public class PharmacySalesReport extends ReportGenerator {
@Override protected List<?> fetchData(DateRange r) { /* fetch pharmacy bills */ }
@Override protected List<?> processData(List<?> raw) { /* aggregate by item */ }
@Override protected Report formatReport(List<?> data) { /* build report */ }
}Encapsulates a request as an object, allowing it to be queued, logged, or undone.
When to use: For operations that need to be queued, scheduled, logged, or undone.
public interface Command {
void execute();
void undo();
}
public class DispenseMedicationCommand implements Command {
private PharmacyService service;
private Prescription prescription;
private DispensingRecord record;
@Override
public void execute() {
record = service.dispense(prescription);
}
@Override
public void undo() {
if (record != null) service.reverseDispensing(record);
}
}Most Java EE features are patterns implemented at framework scale:
| Framework feature | Pattern |
|---|---|
CDI @Inject
|
Dependency Injection (a form of Dependency Inversion) |
| JPA EntityManager | Unit of Work + Identity Map |
| JSF Managed Beans | MVC (Controller) |
| JAX-RS resources | Front Controller |
| CDI Events | Observer |
@Interceptor |
Decorator / Proxy |
| EJB Stateless | Flyweight |
Understanding patterns helps you understand why frameworks work the way they do, not just how to use them.
Previous: SE-04: SOLID Principles
Next: SE-06: Clean Code