Design Patterns in Java
Implement classic design patterns to solve common software design problems
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.propertiesfile - 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
Shapeinterface withdraw()andcalculateArea() - 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
PaymentStrategyinterface - 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
- Singleton: Add a method to reload configuration at runtime
- Factory: Add a
Hexagonshape and update the factory - Builder: Create a
ComputerBuilderfor configuring PCs - Strategy: Add
BankTransferPaymentstrategy - Observer: Add
EmailNotificationObserverthat 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)