Skip to content

SE 05 Design Patterns

Dr M H B Ariyaratne edited this page Jun 8, 2026 · 1 revision

SE-05: Design Patterns

Part of the Software Engineering Principles series


What Is a Design Pattern?

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

Pattern Categories

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

Creational Patterns

Singleton

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.


Factory Method

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);
    }
}

Builder

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();

Structural Patterns

Adapter

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);
    }
}

Decorator

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");
    }
}

Facade

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.


Behavioural Patterns

Strategy

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);
    }
}

Observer

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
    }
}

Template Method

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 */ }
}

Command

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);
    }
}

Patterns in Frameworks

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

Back to Software Engineering Principles

Clone this wiki locally