Skip to content

Design Patterns Best Practices

Overview

This guide covers essential best practices for effectively implementing design patterns in Java applications. It provides guidance on pattern selection, implementation considerations, common pitfalls to avoid, and recommendations for combining patterns to solve complex problems.

Prerequisites

  • Understanding of core design patterns (Creational, Structural, Behavioral, J2EE)
  • Experience with Java programming
  • Familiarity with SOLID principles
  • Basic knowledge of software architecture concepts

Learning Objectives

  • Learn to select the appropriate design patterns for specific problems
  • Understand how to implement patterns correctly and efficiently
  • Recognize patterns in existing codebases
  • Avoid common pitfalls in design pattern implementation
  • Apply patterns in combination to solve complex problems
  • Keep up with evolving design pattern practices

Table of Contents

  1. Pattern Selection Guidelines
  2. Implementation Best Practices
  3. Combining Design Patterns
  4. Refactoring to Patterns
  5. Testing Pattern Implementations
  6. Performance Considerations
  7. Documentation Guidelines
  8. Modern Alternatives
  9. Anti-Patterns to Avoid
  10. Evolving Patterns

Pattern Selection Guidelines

Understand the Problem First

Before choosing a design pattern, thoroughly understand the problem you're trying to solve:

  • Clear Requirements: Ensure you have a clear understanding of the functional and non-functional requirements.
  • Current vs. Future Needs: Consider both immediate needs and likely future requirements.
  • Constraints: Understand technical constraints, performance requirements, and scalability needs.

Selection Criteria

When selecting a pattern, consider:

  1. Primary Intent: Does the pattern's primary purpose align with your problem?
  2. Applicability: Do your problem characteristics match the pattern's applicability criteria?
  3. Consequences: Are the trade-offs introduced by the pattern acceptable?
  4. Simplicity: Is this the simplest solution to your problem?
  5. Team Familiarity: Is your team familiar with the pattern or can they quickly learn it?

Common Selection Mistakes

  • Pattern Overuse: Using patterns when simpler solutions would suffice
  • Force-fitting: Trying to make a problem fit a pattern rather than vice versa
  • Premature Abstraction: Applying patterns before understanding the real problem
  • Resume-Driven Development: Using patterns to show off rather than solve problems

Implementation Best Practices

Follow SOLID Principles

Design patterns should reinforce, not replace, solid engineering principles:

  • Single Responsibility: Each class should have a single reason to change
  • Open/Closed: Open for extension, closed for modification
  • Liskov Substitution: Subtypes must be substitutable for their base types
  • Interface Segregation: Clients shouldn't depend on interfaces they don't use
  • Dependency Inversion: Depend on abstractions, not concretions

Context Awareness

  • Adapt to the Codebase: Implement patterns in a way that fits the existing codebase style
  • Consider Language Features: Use language-specific features to improve pattern implementation
  • Framework Integration: Ensure patterns work well with frameworks in use

Naming Conventions

  • Clear Pattern Indication: Name classes to indicate the pattern being used (e.g., UserFactory, OrderBuilder)
  • Consistent Terminology: Use consistent naming across the codebase for similar pattern implementations
  • Self-Documenting: Names should reflect roles in the pattern

Implementation Examples - Do's and Don'ts

Singleton Pattern - Do:

public enum Singleton {
    INSTANCE;

    public void doSomething() {
        // Implementation
    }
}

Singleton Pattern - Don't:

public class BadSingleton {
    private static BadSingleton instance;

    private BadSingleton() {}

    public static BadSingleton getInstance() {
        if (instance == null) {
            instance = new BadSingleton(); // Not thread-safe
        }
        return instance;
    }
}

Combining Design Patterns

Design patterns are often more powerful when combined. Common effective combinations include:

MVC + Observer

The Model-View-Controller pattern often uses the Observer pattern to notify Views of Model changes:

// Model implements Observable (or uses PropertyChangeSupport)
// Views implement Observer
// Controller updates Model, which notifies Views

Factory Method + Strategy

Use Factory Method to create Strategy implementations:

public class PaymentProcessorFactory {
    public PaymentStrategy createPaymentStrategy(String type) {
        if ("credit".equals(type)) {
            return new CreditCardPaymentStrategy();
        } else if ("paypal".equals(type)) {
            return new PayPalPaymentStrategy();
        }
        throw new IllegalArgumentException("Unknown payment type");
    }
}

Decorator + Composite

Use Decorator to add behavior to Composite structures:

// Base component interface
Component component = new ConcreteComponent();
// Add decorators
component = new SecurityDecorator(component);
component = new LoggingDecorator(component);
// Use in composite structure
CompositeComponent composite = new CompositeComponent();
composite.add(component);

Additional Effective Combinations

  • Builder + Factory Method: Factory Method creates Builders for complex objects
  • Adapter + Facade: Facade uses Adapters to integrate different subsystems
  • Chain of Responsibility + Command: Commands travel through a processing chain
  • Proxy + Decorator: Proxy controls access, Decorator adds behavior

Refactoring to Patterns

Recognizing Pattern Opportunities

Look for these code smells that often indicate opportunities for patterns:

  • Duplicate Code: May indicate need for Template Method or Strategy
  • Large Switch Statements: Often replaceable with Strategy or Command
  • High Coupling: May benefit from Observer or Mediator
  • Complex Object Creation: Consider Factory, Builder, or Prototype
  • Feature Envy: May indicate need for moving behavior via Command or Strategy

Refactoring Steps

  1. Identify the Problem: Determine what specific issue needs addressing
  2. Select Candidate Pattern: Choose pattern(s) that might solve the problem
  3. Test Understanding: Ensure you understand how the pattern addresses the issue
  4. Create Tests: Write tests to verify current behavior
  5. Refactor Incrementally: Apply the pattern in small, testable steps
  6. Verify with Tests: Ensure all tests pass after each step
  7. Review: Assess if the pattern solved the original problem

Example: Refactoring to Strategy Pattern

Before:

public class PaymentProcessor {
    public void processPayment(String type, double amount) {
        if ("credit".equals(type)) {
            // Credit card processing logic
            System.out.println("Processing credit card payment of $" + amount);
        } else if ("paypal".equals(type)) {
            // PayPal processing logic
            System.out.println("Processing PayPal payment of $" + amount);
        } else if ("crypto".equals(type)) {
            // Cryptocurrency processing logic
            System.out.println("Processing crypto payment of $" + amount);
        }
    }
}

After:

// Strategy interface
public interface PaymentStrategy {
    void process(double amount);
}

// Concrete strategies
public class CreditCardStrategy implements PaymentStrategy {
    @Override
    public void process(double amount) {
        System.out.println("Processing credit card payment of $" + amount);
    }
}

public class PayPalStrategy implements PaymentStrategy {
    @Override
    public void process(double amount) {
        System.out.println("Processing PayPal payment of $" + amount);
    }
}

public class CryptoStrategy implements PaymentStrategy {
    @Override
    public void process(double amount) {
        System.out.println("Processing crypto payment of $" + amount);
    }
}

// Context
public class PaymentProcessor {
    private PaymentStrategy strategy;

    public void setPaymentStrategy(PaymentStrategy strategy) {
        this.strategy = strategy;
    }

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

Testing Pattern Implementations

Test-Driven Development

Apply TDD when implementing patterns:

  1. Write tests that define expected behavior
  2. Implement pattern to satisfy tests
  3. Refactor while keeping tests passing

Testing Strategies by Pattern Type

Creational Patterns: - Test that objects are created with correct initial state - Test that singletons maintain a single instance - Test that factories create the right type of object

Structural Patterns: - Test that composed objects work correctly together - Test that adapters correctly translate between interfaces - Test that proxies correctly control access to objects

Behavioral Patterns: - Test that the flow of control works as expected - Test that objects communicate correctly - Test that state changes happen appropriately

Mocking and Isolation

  • Use mocks to isolate the pattern being tested
  • Test integration with real dependencies in separate tests
  • Consider testing frameworks like Mockito for Java

Performance Considerations

Pattern Performance Impact

Design patterns can impact performance in various ways:

  • Indirection Cost: Patterns often add layers of indirection
  • Object Creation: Some patterns increase the number of objects created
  • Method Calls: Some patterns increase the number of method calls
  • Memory Usage: Some patterns increase memory consumption

Optimization Guidelines

  • Measure First: Use profiling to identify actual bottlenecks
  • Optimize Hot Spots: Focus optimization on frequently used code paths
  • Consider Alternatives: Use simpler patterns when performance is critical
  • Caching: Add caching to expensive pattern operations
  • Lazy Initialization: Initialize components only when needed

Common Performance Issues

Factory Method vs. Direct Creation: - Factory Method adds method call overhead - Factory Method allows for optimization through caching or object pooling

Proxy Overhead: - Dynamic proxies (like in Spring) can be slower than direct calls - Consider static proxies for performance-critical code

Observer Pattern: - Large numbers of observers can cause notification bottlenecks - Consider batch notifications or update throttling

Documentation Guidelines

Pattern Documentation

When documenting pattern usage in code:

  1. Identify the Pattern: Clearly state which pattern is being used
  2. Explain Intent: Document why the pattern was chosen
  3. Describe Structure: Outline how classes participate in the pattern
  4. Highlight Variations: Note any customizations or variations
  5. List Alternatives: Mention alternatives considered and why they were rejected

Example Documentation

/**
 * OrderProcessor uses the Strategy pattern to support different processing algorithms.
 * 
 * Intent: Allow the processing algorithm to vary independently from clients that use it.
 * 
 * Structure:
 * - OrderProcessor: Context class that uses a processing strategy
 * - OrderProcessingStrategy: Strategy interface
 * - BatchOrderStrategy, RealTimeOrderStrategy: Concrete strategies
 * 
 * This implementation allows for easy addition of new processing strategies
 * without modifying the OrderProcessor class.
 * 
 * Alternative considered: Template Method pattern, rejected because we needed
 * to switch strategies at runtime.
 */
public class OrderProcessor {
    // Implementation
}

Modern Alternatives

Functional Approaches

Modern Java's functional features can replace some traditional patterns:

Strategy Pattern with Lambdas:

// Traditional
interface Validator {
    boolean validate(String input);
}

class EmailValidator implements Validator {
    @Override
    public boolean validate(String input) {
        return input.matches("^[A-Za-z0-9+_.-]+@(.+)$");
    }
}

// Functional
Function<String, Boolean> emailValidator = 
    input -> input.matches("^[A-Za-z0-9+_.-]+@(.+)$");

Command Pattern with Method References:

// Traditional
interface Command {
    void execute();
}

class SaveCommand implements Command {
    private Document doc;
    public SaveCommand(Document doc) {
        this.doc = doc;
    }
    @Override
    public void execute() {
        doc.save();
    }
}

// Functional
Document doc = new Document();
Runnable saveCommand = doc::save;

Reactive Programming

Reactive programming can replace some traditional patterns:

  • Observer PatternReactive Streams (RxJava, Project Reactor)
  • Iterator PatternObservable/Flowable Sequences
  • Chain of ResponsibilityOperators Chain

Dependency Injection Frameworks

Modern DI frameworks reduce the need for certain patterns:

  • Factory PatternContainer-managed instantiation
  • Singleton PatternScoped beans
  • Service LocatorInjection points

Anti-Patterns to Avoid

Common Design Anti-patterns

  1. God Object: Class that knows or does too much
  2. Golden Hammer: Overusing a familiar pattern for every problem
  3. Object Orgy: Insufficient encapsulation and excessive object coupling
  4. Spaghetti Code: Tangled, unstructured code with unclear flow
  5. Premature Optimization: Optimizing before profiling real bottlenecks

Pattern-Specific Anti-patterns

Singleton Abuse: - Using singletons for simple global state - Creating too many singletons in an application - Using singletons to avoid proper dependency management

Factory Overuse: - Creating factories for simple object creation - Implementing factory hierarchies that are more complex than the objects they create - Using abstract factories when simple factories would suffice

Observer Overload: - Creating circular observer relationships - Adding too many observers without consideration for performance - Using observers where simple method calls would work

Evolving Patterns

Adapting Classic Patterns

Classic patterns often need adaptation for modern contexts:

  • Cloud-Native Environments: Consider elasticity, resilience, and distributed state
  • Microservices Architecture: Adapt patterns for service boundaries and eventual consistency
  • Container-Based Deployment: Consider immutability and infrastructure-as-code implications

Emerging Patterns

New patterns continue to emerge to address modern challenges:

  1. Circuit Breaker: Prevent cascade failures in distributed systems
  2. Saga: Manage distributed transactions across microservices
  3. CQRS: Separate command and query responsibilities
  4. Event Sourcing: Store state as a sequence of events
  5. Sidecar: Deploy supporting services alongside primary services

Staying Current

To stay current with evolving patterns:

  • Follow Industry Thought Leaders: Read blogs and books from pattern experts
  • Participate in Communities: Join discussions in programming communities
  • Review Open Source Projects: Study pattern usage in successful projects
  • Practice Continuous Learning: Regularly update your pattern knowledge
  • Apply Critical Thinking: Evaluate patterns based on their actual benefits