javadesign-patternsoop

Gang of Four Design Patterns Applied in Real Java Projects

The GoF book landed in 1994. By 1997 we were applying it daily. Here are the patterns that paid off and the ones that over-complicated everything.

·5 min read

Gang of Four Design Patterns Applied in Real Java Projects

The Gang of Four book — Design Patterns: Elements of Reusable Object-Oriented Software — was published in 1994. By 1997 it was on every Java developer's desk. At Motorola we applied it systematically to our network management system. Three years later I had a clear view of which patterns solved real problems and which added indirection in search of elegance.

Here are the five patterns we used most, with real examples from the NMS codebase.

Observer: Decoupling Device State Changes

The NMS needed to notify multiple subsystems when a device changed state — the alert engine, the status display, the audit log. Without a pattern this becomes a hairball of direct method calls.

Observer separates the source of events from the handlers:

public interface DeviceListener {
    void onStatusChange(String deviceIp, DeviceStatus newStatus);
}

public class DeviceRegistry {
    private final Map<String, DeviceStatus>  devices   = new HashMap<>();
    private final List<DeviceListener>       listeners = new ArrayList<>();

    public void addListener(DeviceListener l) {
        listeners.add(l);
    }

    public synchronized void update(String ip, DeviceStatus status) {
        devices.put(ip, status);
        for (DeviceListener l : listeners) {
            l.onStatusChange(ip, status);
        }
    }
}

// Usage
DeviceRegistry registry = new DeviceRegistry();
registry.addListener(new AlertEngine());
registry.addListener(new StatusDisplay());
registry.addListener(new AuditLogger());

DeviceRegistry knows nothing about AlertEngine or StatusDisplay. New consumers can be added without touching the registry. This pattern holds up because it solves a universal problem: one event, many handlers.

Strategy: Swappable Polling Algorithms

Different device types needed different polling strategies. Routers supported SNMP v2. Older hubs only supported v1. Some proprietary devices had a custom HTTP API.

Strategy encapsulates each algorithm behind a common interface:

public interface PollStrategy {
    DeviceStatus poll(String deviceIp);
}

public class SnmpV2PollStrategy implements PollStrategy {
    @Override
    public DeviceStatus poll(String deviceIp) {
        // SNMP v2 GET-BULK implementation
        return null;
    }
}

public class SnmpV1PollStrategy implements PollStrategy {
    @Override
    public DeviceStatus poll(String deviceIp) {
        // SNMP v1 GET implementation
        return null;
    }
}

public class NetworkDevice {
    private final String       ip;
    private final PollStrategy strategy;

    public NetworkDevice(String ip, PollStrategy strategy) {
        this.ip       = ip;
        this.strategy = strategy;
    }

    public DeviceStatus poll() {
        return strategy.poll(ip);
    }
}

New device types require a new PollStrategy implementation — nothing else changes. This was genuinely useful because the number of device types grew throughout the project.

Factory Method: Creating Devices Without Knowing Their Type

When loading device configurations from a database, the loading code should not need to know every concrete device type. Factory Method delegates creation to a subclass:

public abstract class DeviceFactory {
    public abstract NetworkDevice create(String ip, String type);

    // Template method that uses the factory method
    public NetworkDevice createAndRegister(String ip, String type, DeviceRegistry reg) {
        NetworkDevice d = create(ip, type);
        reg.register(d);
        return d;
    }
}

public class MotorolaDeviceFactory extends DeviceFactory {
    @Override
    public NetworkDevice create(String ip, String type) {
        switch (type) {
            case "ROUTER":  return new NetworkDevice(ip, new SnmpV2PollStrategy());
            case "HUB":     return new NetworkDevice(ip, new SnmpV1PollStrategy());
            default: throw new IllegalArgumentException("Unknown device type: " + type);
        }
    }
}

Decorator: Adding Features Without Subclassing

We needed to add caching, retry logic, and timing to polling — in different combinations for different environments. Subclassing every combination is combinatorial. Decorator composes behaviour at runtime:

public class CachingPollStrategy implements PollStrategy {
    private final PollStrategy  delegate;
    private final Map<String, CachedResult> cache = new HashMap<>();
    private final long          ttlMs;

    public CachingPollStrategy(PollStrategy delegate, long ttlMs) {
        this.delegate = delegate;
        this.ttlMs    = ttlMs;
    }

    @Override
    public DeviceStatus poll(String deviceIp) {
        CachedResult cached = cache.get(deviceIp);
        if (cached != null && !cached.isExpired(ttlMs)) {
            return cached.status;
        }
        DeviceStatus status = delegate.poll(deviceIp);
        cache.put(deviceIp, new CachedResult(status));
        return status;
    }
}

// Compose: cache a retrying v2 strategy
PollStrategy strategy =
    new CachingPollStrategy(
        new RetryingPollStrategy(
            new SnmpV2PollStrategy(), 3),
        30_000L);

Each decorator adds one responsibility. They compose cleanly. This is one of the most durable patterns in the book.

Command: Queueing and Undoing Operations

Device configuration changes — update a hostname, restart an interface — needed to be queued, logged and sometimes undone. Command wraps each operation as an object:

public interface Command {
    void execute();
    void undo();
}

public class RestartInterfaceCommand implements Command {
    private final NetworkDevice device;
    private final int           interfaceIndex;

    public RestartInterfaceCommand(NetworkDevice device, int ifIndex) {
        this.device         = device;
        this.interfaceIndex = ifIndex;
    }

    @Override
    public void execute() {
        device.setInterfaceStatus(interfaceIndex, DeviceStatus.RESTARTING);
    }

    @Override
    public void undo() {
        device.setInterfaceStatus(interfaceIndex, DeviceStatus.UP);
    }
}

public class CommandHistory {
    private final Deque<Command> history = new ArrayDeque<>();

    public void execute(Command cmd) {
        cmd.execute();
        history.push(cmd);
    }

    public void undo() {
        if (!history.isEmpty()) {
            history.pop().undo();
        }
    }
}

The undo capability was useful for the operations console. The queuing capability was essential for the batch configuration system.

Patterns That Did Not Pay Off

Singleton. We used it for the DeviceRegistry. It made testing hard — you cannot create an isolated DeviceRegistry per test when there is only one. By 2000 I had removed all singletons and used dependency injection instead.

Abstract Factory. We built one for creating device families. The abstraction layers added more complexity than the flexibility was worth for our one factory variant.

The lesson: patterns solve specific problems. Apply them when you recognise the problem, not to feel sophisticated.