← Back to Lecture
Practical Work 10

Design Patterns in Java

Implement classic design patterns to solve common software design problems

Duration 3 hours
Difficulty Advanced
Session 10 - Design Patterns

Objectives

By the end of this practical work, you will be able to:

  • Implement the Singleton pattern for shared resources
  • Use Factory pattern to create objects without specifying classes
  • Apply Builder pattern for complex object construction
  • Implement Strategy pattern for interchangeable algorithms
  • Use Observer pattern for event-driven programming
  • Recognize when to apply each pattern

Prerequisites

  • Strong understanding of Java OOP concepts
  • Experience with interfaces and abstract classes
  • Understanding of polymorphism

Exercise 1: Singleton - Configuration Manager

Problem

Create a ConfigManager that loads application settings from a properties file. Only one instance should exist throughout the application lifecycle.

Requirements

  • Load properties from config.properties file
  • Thread-safe implementation
  • Lazy initialization (load only when first needed)
  • Methods to get string, int, boolean, and double values

Implementation Template

package patterns.singleton;

import java.io.*;
import java.util.Properties;

public class ConfigManager {
    private static volatile ConfigManager instance;  // (#1:volatile for thread safety)
    private final Properties properties;

    private ConfigManager() {  // (#2:Private constructor)
        properties = new Properties();
        loadProperties();
    }

    public static ConfigManager getInstance() {  // (#3:Double-checked locking)
        if (instance == null) {
            synchronized (ConfigManager.class) {
                if (instance == null) {
                    instance = new ConfigManager();
                }
            }
        }
        return instance;
    }

    private void loadProperties() {
        try (InputStream input = getClass().getClassLoader()
                .getResourceAsStream("config.properties")) {
            if (input != null) {
                properties.load(input);
            } else {
                System.err.println("config.properties not found, using defaults");
            }
        } catch (IOException e) {
            System.err.println("Error loading config: " + e.getMessage());
        }
    }

    public String getString(String key, String defaultValue) {
        return properties.getProperty(key, defaultValue);
    }

    public int getInt(String key, int defaultValue) {
        String value = properties.getProperty(key);
        if (value == null) return defaultValue;
        try {
            return Integer.parseInt(value);
        } catch (NumberFormatException e) {
            return defaultValue;
        }
    }

    public boolean getBoolean(String key, boolean defaultValue) {
        String value = properties.getProperty(key);
        if (value == null) return defaultValue;
        return Boolean.parseBoolean(value);
    }

    public double getDouble(String key, double defaultValue) {
        String value = properties.getProperty(key);
        if (value == null) return defaultValue;
        try {
            return Double.parseDouble(value);
        } catch (NumberFormatException e) {
            return defaultValue;
        }
    }
}

Sample config.properties

# Application Configuration
app.name=MyApplication
app.version=1.0.0
app.debug=true

# Database Settings
db.host=localhost
db.port=5432
db.maxConnections=10

# API Settings
api.timeout=30000
api.retries=3

Test Your Implementation

public class ConfigTest {
    public static void main(String[] args) {
        ConfigManager config = ConfigManager.getInstance();

        System.out.println("App Name: " + config.getString("app.name", "Unknown"));
        System.out.println("Debug Mode: " + config.getBoolean("app.debug", false));
        System.out.println("DB Port: " + config.getInt("db.port", 3306));

        // Verify it's the same instance
        ConfigManager config2 = ConfigManager.getInstance();
        System.out.println("Same instance: " + (config == config2));
    }
}

Exercise 2: Factory - Shape Creator

Problem

Create a ShapeFactory that creates different shapes (Circle, Rectangle, Triangle) without the client knowing the concrete classes.

Requirements

  • Common Shape interface with draw() and calculateArea()
  • Factory method that takes shape type as string
  • Support for shape configuration via parameters
  • Extensible design for new shapes

Implementation

package patterns.factory;

// Shape interface
public interface Shape {
    void draw();
    double calculateArea();
    String getName();
}

// Concrete shapes
public class Circle implements Shape {
    private final double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    @Override
    public void draw() {
        System.out.println("Drawing Circle with radius " + radius);
    }

    @Override
    public double calculateArea() {
        return Math.PI * radius * radius;
    }

    @Override
    public String getName() { return "Circle"; }
}

public class Rectangle implements Shape {
    private final double width;
    private final double height;

    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public void draw() {
        System.out.println("Drawing Rectangle " + width + "x" + height);
    }

    @Override
    public double calculateArea() {
        return width * height;
    }

    @Override
    public String getName() { return "Rectangle"; }
}

public class Triangle implements Shape {
    private final double base;
    private final double height;

    public Triangle(double base, double height) {
        this.base = base;
        this.height = height;
    }

    @Override
    public void draw() {
        System.out.println("Drawing Triangle with base " + base);
    }

    @Override
    public double calculateArea() {
        return 0.5 * base * height;
    }

    @Override
    public String getName() { return "Triangle"; }
}
// Factory class
public class ShapeFactory {

    public enum ShapeType {
        CIRCLE, RECTANGLE, TRIANGLE
    }

    public static Shape createShape(ShapeType type, double... params) {  // (#1:Varargs for flexibility)
        return switch (type) {  // (#2:Switch expression)
            case CIRCLE -> {
                if (params.length < 1) throw new IllegalArgumentException("Circle needs radius");
                yield new Circle(params[0]);
            }
            case RECTANGLE -> {
                if (params.length < 2) throw new IllegalArgumentException("Rectangle needs width and height");
                yield new Rectangle(params[0], params[1]);
            }
            case TRIANGLE -> {
                if (params.length < 2) throw new IllegalArgumentException("Triangle needs base and height");
                yield new Triangle(params[0], params[1]);
            }
        };
    }

    // Alternative: Create from string (useful for config/user input)
    public static Shape createShape(String type, double... params) {
        return createShape(ShapeType.valueOf(type.toUpperCase()), params);
    }
}

// Usage
public class ShapeDemo {
    public static void main(String[] args) {
        Shape circle = ShapeFactory.createShape(ShapeFactory.ShapeType.CIRCLE, 5.0);
        Shape rectangle = ShapeFactory.createShape("rectangle", 4.0, 3.0);
        Shape triangle = ShapeFactory.createShape(ShapeFactory.ShapeType.TRIANGLE, 6.0, 4.0);

        List<Shape> shapes = List.of(circle, rectangle, triangle);

        for (Shape shape : shapes) {
            shape.draw();
            System.out.printf("Area: %.2f%n%n", shape.calculateArea());
        }
    }
}

Exercise 3: Builder - Pizza Order

Problem

Create a PizzaBuilder for constructing complex pizza orders with many optional components.

Requirements

  • Pizza has: size, crust type, sauce, cheese, toppings list
  • Fluent interface for chaining method calls
  • Validation in build() method
  • Immutable Pizza object as result

Implementation

package patterns.builder;

import java.util.*;

public class Pizza {
    private final Size size;
    private final Crust crust;
    private final String sauce;
    private final String cheese;
    private final List<String> toppings;
    private final boolean extraCheese;
    private final String specialInstructions;

    public enum Size { SMALL, MEDIUM, LARGE, EXTRA_LARGE }
    public enum Crust { THIN, REGULAR, THICK, STUFFED }

    // Private constructor - only Builder can create
    private Pizza(Builder builder) {  // (#1:Private constructor)
        this.size = builder.size;
        this.crust = builder.crust;
        this.sauce = builder.sauce;
        this.cheese = builder.cheese;
        this.toppings = List.copyOf(builder.toppings);  // (#2:Immutable copy)
        this.extraCheese = builder.extraCheese;
        this.specialInstructions = builder.specialInstructions;
    }

    // Getters only - no setters (immutable)
    public Size getSize() { return size; }
    public Crust getCrust() { return crust; }
    public String getSauce() { return sauce; }
    public String getCheese() { return cheese; }
    public List<String> getToppings() { return toppings; }
    public boolean hasExtraCheese() { return extraCheese; }
    public String getSpecialInstructions() { return specialInstructions; }

    public double calculatePrice() {
        double base = switch (size) {
            case SMALL -> 8.99;
            case MEDIUM -> 10.99;
            case LARGE -> 12.99;
            case EXTRA_LARGE -> 14.99;
        };

        double toppingsCost = toppings.size() * 1.50;
        double extras = extraCheese ? 2.00 : 0;
        double crustExtra = (crust == Crust.STUFFED) ? 3.00 : 0;

        return base + toppingsCost + extras + crustExtra;
    }

    @Override
    public String toString() {
        return """
            Pizza Order:
              Size: %s
              Crust: %s
              Sauce: %s
              Cheese: %s%s
              Toppings: %s
              Special: %s
              Price: $%.2f
            """.formatted(
                size, crust, sauce, cheese,
                extraCheese ? " (EXTRA)" : "",
                toppings.isEmpty() ? "None" : String.join(", ", toppings),
                specialInstructions != null ? specialInstructions : "None",
                calculatePrice()
            );
    }

    // Builder class
    public static class Builder {  // (#3:Static inner class)
        // Required parameters
        private final Size size;

        // Optional parameters with defaults
        private Crust crust = Crust.REGULAR;
        private String sauce = "Tomato";
        private String cheese = "Mozzarella";
        private List<String> toppings = new ArrayList<>();
        private boolean extraCheese = false;
        private String specialInstructions = null;

        public Builder(Size size) {  // (#4:Required params in constructor)
            this.size = size;
        }

        public Builder crust(Crust crust) {  // (#5:Fluent setters return this)
            this.crust = crust;
            return this;
        }

        public Builder sauce(String sauce) {
            this.sauce = sauce;
            return this;
        }

        public Builder cheese(String cheese) {
            this.cheese = cheese;
            return this;
        }

        public Builder addTopping(String topping) {
            this.toppings.add(topping);
            return this;
        }

        public Builder addToppings(String... toppings) {
            this.toppings.addAll(Arrays.asList(toppings));
            return this;
        }

        public Builder extraCheese() {
            this.extraCheese = true;
            return this;
        }

        public Builder specialInstructions(String instructions) {
            this.specialInstructions = instructions;
            return this;
        }

        public Pizza build() {  // (#6:Build creates immutable object)
            // Validation
            if (toppings.size() > 10) {
                throw new IllegalStateException("Maximum 10 toppings allowed");
            }
            return new Pizza(this);
        }
    }
}

// Usage
public class PizzaDemo {
    public static void main(String[] args) {
        // Simple pizza
        Pizza margherita = new Pizza.Builder(Pizza.Size.MEDIUM)
            .build();

        // Complex pizza
        Pizza supreme = new Pizza.Builder(Pizza.Size.LARGE)
            .crust(Pizza.Crust.STUFFED)
            .sauce("BBQ")
            .cheese("Cheddar")
            .addToppings("Pepperoni", "Mushrooms", "Onions", "Bell Peppers")
            .extraCheese()
            .specialInstructions("Well done, cut in squares")
            .build();

        System.out.println(margherita);
        System.out.println(supreme);
    }
}

Exercise 4: Strategy - Payment Processing

Problem

Create a payment system that supports multiple payment methods (Credit Card, PayPal, Cryptocurrency) that can be swapped at runtime.

Requirements

  • Common PaymentStrategy interface
  • Different validation rules per payment type
  • Transaction fees calculated differently
  • Easy to add new payment methods

Implementation

package patterns.strategy;

// Strategy interface
public interface PaymentStrategy {
    boolean validate();
    boolean pay(double amount);
    double calculateFee(double amount);
    String getPaymentMethod();
}

// Concrete strategies
public class CreditCardPayment implements PaymentStrategy {
    private final String cardNumber;
    private final String cvv;
    private final String expiry;

    public CreditCardPayment(String cardNumber, String cvv, String expiry) {
        this.cardNumber = cardNumber;
        this.cvv = cvv;
        this.expiry = expiry;
    }

    @Override
    public boolean validate() {
        // Luhn algorithm check (simplified)
        return cardNumber != null && cardNumber.length() == 16
            && cvv != null && cvv.length() == 3;
    }

    @Override
    public boolean pay(double amount) {
        if (!validate()) return false;
        String maskedCard = "****-****-****-" + cardNumber.substring(12);
        System.out.printf("Charging $%.2f to card %s%n", amount + calculateFee(amount), maskedCard);
        return true;  // Simulate success
    }

    @Override
    public double calculateFee(double amount) {
        return amount * 0.029 + 0.30;  // 2.9% + $0.30
    }

    @Override
    public String getPaymentMethod() { return "Credit Card"; }
}

public class PayPalPayment implements PaymentStrategy {
    private final String email;
    private final String password;

    public PayPalPayment(String email, String password) {
        this.email = email;
        this.password = password;
    }

    @Override
    public boolean validate() {
        return email != null && email.contains("@")
            && password != null && password.length() >= 8;
    }

    @Override
    public boolean pay(double amount) {
        if (!validate()) return false;
        System.out.printf("Processing PayPal payment of $%.2f for %s%n",
            amount + calculateFee(amount), email);
        return true;
    }

    @Override
    public double calculateFee(double amount) {
        return amount * 0.034 + 0.49;  // 3.4% + $0.49
    }

    @Override
    public String getPaymentMethod() { return "PayPal"; }
}

public class CryptoPayment implements PaymentStrategy {
    private final String walletAddress;
    private final String currency;  // BTC, ETH, etc.

    public CryptoPayment(String walletAddress, String currency) {
        this.walletAddress = walletAddress;
        this.currency = currency.toUpperCase();
    }

    @Override
    public boolean validate() {
        return walletAddress != null && walletAddress.length() >= 26;
    }

    @Override
    public boolean pay(double amount) {
        if (!validate()) return false;
        System.out.printf("Sending $%.2f worth of %s to %s%n",
            amount, currency, walletAddress.substring(0, 10) + "...");
        return true;
    }

    @Override
    public double calculateFee(double amount) {
        return amount * 0.01;  // 1% - lower fees
    }

    @Override
    public String getPaymentMethod() { return "Crypto (" + currency + ")"; }
}
// Context class that uses the strategy
public class PaymentProcessor {
    private PaymentStrategy strategy;

    public void setPaymentStrategy(PaymentStrategy strategy) {  // (#1:Strategy can be changed)
        this.strategy = strategy;
    }

    public boolean processPayment(double amount) {
        if (strategy == null) {
            throw new IllegalStateException("Payment strategy not set");
        }

        System.out.println("Processing payment via " + strategy.getPaymentMethod());
        double fee = strategy.calculateFee(amount);
        System.out.printf("Amount: $%.2f, Fee: $%.2f, Total: $%.2f%n",
            amount, fee, amount + fee);

        return strategy.pay(amount);
    }
}

// Usage
public class PaymentDemo {
    public static void main(String[] args) {
        PaymentProcessor processor = new PaymentProcessor();
        double orderTotal = 99.99;

        // Pay with credit card
        processor.setPaymentStrategy(
            new CreditCardPayment("4111111111111111", "123", "12/25"));
        processor.processPayment(orderTotal);

        System.out.println();

        // Switch to PayPal
        processor.setPaymentStrategy(
            new PayPalPayment("user@example.com", "securepass123"));
        processor.processPayment(orderTotal);

        System.out.println();

        // Switch to Crypto
        processor.setPaymentStrategy(
            new CryptoPayment("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", "BTC"));
        processor.processPayment(orderTotal);
    }
}

Exercise 5: Observer - Stock Price Alerts

Problem

Create a stock monitoring system where observers can subscribe to price updates and get notified when prices change.

Requirements

  • Multiple observers can watch the same stock
  • Observers can set price thresholds for alerts
  • Support for different notification types (email, SMS, app)
  • Clean subscribe/unsubscribe mechanism

Implementation

package patterns.observer;

import java.util.*;

// Observer interface
public interface StockObserver {
    void update(String symbol, double oldPrice, double newPrice);
    String getObserverId();
}

// Subject interface
public interface StockSubject {
    void addObserver(StockObserver observer);
    void removeObserver(StockObserver observer);
    void notifyObservers(double oldPrice, double newPrice);
}

// Concrete Subject
public class Stock implements StockSubject {
    private final String symbol;
    private final String companyName;
    private double price;
    private final List<StockObserver> observers = new ArrayList<>();

    public Stock(String symbol, String companyName, double initialPrice) {
        this.symbol = symbol;
        this.companyName = companyName;
        this.price = initialPrice;
    }

    @Override
    public void addObserver(StockObserver observer) {
        observers.add(observer);
        System.out.println(observer.getObserverId() + " subscribed to " + symbol);
    }

    @Override
    public void removeObserver(StockObserver observer) {
        observers.remove(observer);
        System.out.println(observer.getObserverId() + " unsubscribed from " + symbol);
    }

    @Override
    public void notifyObservers(double oldPrice, double newPrice) {
        for (StockObserver observer : observers) {
            observer.update(symbol, oldPrice, newPrice);
        }
    }

    public void setPrice(double newPrice) {
        double oldPrice = this.price;
        this.price = newPrice;

        double changePercent = ((newPrice - oldPrice) / oldPrice) * 100;
        System.out.printf("%n%s (%s): $%.2f -> $%.2f (%.2f%%)%n",
            symbol, companyName, oldPrice, newPrice, changePercent);

        notifyObservers(oldPrice, newPrice);  // (#1:Notify all observers)
    }

    public String getSymbol() { return symbol; }
    public double getPrice() { return price; }
}
// Concrete Observers
public class PriceAlertObserver implements StockObserver {
    private final String userId;
    private final double targetPrice;
    private final boolean alertAbove;  // true = alert when above, false = below

    public PriceAlertObserver(String userId, double targetPrice, boolean alertAbove) {
        this.userId = userId;
        this.targetPrice = targetPrice;
        this.alertAbove = alertAbove;
    }

    @Override
    public void update(String symbol, double oldPrice, double newPrice) {
        boolean shouldAlert = alertAbove
            ? (oldPrice < targetPrice && newPrice >= targetPrice)
            : (oldPrice > targetPrice && newPrice <= targetPrice);

        if (shouldAlert) {
            System.out.printf("  ALERT [%s]: %s %s target $%.2f (now $%.2f)%n",
                userId, symbol,
                alertAbove ? "crossed above" : "dropped below",
                targetPrice, newPrice);
        }
    }

    @Override
    public String getObserverId() {
        return "Alert-" + userId;
    }
}

public class PercentChangeObserver implements StockObserver {
    private final String userId;
    private final double threshold;  // percentage

    public PercentChangeObserver(String userId, double thresholdPercent) {
        this.userId = userId;
        this.threshold = thresholdPercent;
    }

    @Override
    public void update(String symbol, double oldPrice, double newPrice) {
        double changePercent = Math.abs((newPrice - oldPrice) / oldPrice) * 100;

        if (changePercent >= threshold) {
            String direction = newPrice > oldPrice ? "UP" : "DOWN";
            System.out.printf("  MOVEMENT [%s]: %s moved %.2f%% %s%n",
                userId, symbol, changePercent, direction);
        }
    }

    @Override
    public String getObserverId() {
        return "Movement-" + userId;
    }
}

public class LoggingObserver implements StockObserver {
    private final String logName;

    public LoggingObserver(String logName) {
        this.logName = logName;
    }

    @Override
    public void update(String symbol, double oldPrice, double newPrice) {
        System.out.printf("  LOG [%s]: %s price changed from $%.2f to $%.2f%n",
            logName, symbol, oldPrice, newPrice);
    }

    @Override
    public String getObserverId() {
        return "Logger-" + logName;
    }
}
// Usage demo
public class StockDemo {
    public static void main(String[] args) {
        // Create stocks
        Stock apple = new Stock("AAPL", "Apple Inc.", 150.00);
        Stock google = new Stock("GOOGL", "Alphabet Inc.", 140.00);

        // Create observers
        StockObserver aliceAlert = new PriceAlertObserver("Alice", 155.00, true);
        StockObserver bobAlert = new PriceAlertObserver("Bob", 145.00, false);
        StockObserver charlieMovement = new PercentChangeObserver("Charlie", 3.0);
        StockObserver logger = new LoggingObserver("System");

        // Subscribe observers
        apple.addObserver(aliceAlert);
        apple.addObserver(bobAlert);
        apple.addObserver(charlieMovement);
        apple.addObserver(logger);

        google.addObserver(charlieMovement);
        google.addObserver(logger);

        // Simulate price changes
        System.out.println("\n=== Market Simulation ===");

        apple.setPrice(152.00);  // Small change
        apple.setPrice(156.00);  // Alice gets alert
        apple.setPrice(144.00);  // Bob gets alert, Charlie sees big drop

        google.setPrice(145.00);  // Charlie sees 3.5% change
    }
}

Exercises to Complete

Additional Tasks

  1. Singleton: Add a method to reload configuration at runtime
  2. Factory: Add a Hexagon shape and update the factory
  3. Builder: Create a ComputerBuilder for configuring PCs
  4. Strategy: Add BankTransferPayment strategy
  5. Observer: Add EmailNotificationObserver that formats email alerts

Deliverables

Bonus Challenges

Advanced Decorator: Implement a notification decorator that adds logging to any observer
Advanced Command: Create an order system with undo/redo using Command pattern
Expert State Machine: Implement a vending machine with states (Idle, HasMoney, Dispensing)

Resources