Understanding Structural Design Patterns: Concepts, Examples, and Use Cases

Satyendra KumarSatyendra Kumar
12 min read

In the last article, we talked about Creational Design Patterns, which are all about how objects are created in code. If you haven't read it yet, you can check it out Here

Now, we're going to look at Structural Design Patterns. These patterns help us organize and structure our code by showing us how to put objects together in the best way. Let's dive in!

Structural Design Patterns

Structural design patterns are about how classes and objects fit together to create bigger, more organized structures. These patterns make sure that if one part of a system changes, we don't have to change everything else. They help build complex systems in a simple and flexible way, making it easier to manage and connect different parts of the system.

There are seven main types of Structural Design Patterns:

  1. Adapter

  2. Bridge

  3. Composite

  4. Decorator

  5. Facade

  6. Flyweight

  7. Proxy

1. Adapter Design Pattern

Definition:
The Adapter pattern allows different systems to work together, even if they have incompatible interfaces. It acts like a bridge, converting one interface into another that the client or system expects.

Example: Bank Transaction System

Imagine a bank that has an old system for processing customer transactions. Now, the bank wants to integrate a modern API to offer new features like mobile payments or online banking. The Adapter pattern can help by allowing the old system to work with the new API without needing to rewrite everything.

Code Example

// Legacy Bank Transaction System Interface
interface LegacyBankSystem {
    Transaction getTransaction(int transactionId);
}

// Modern Transaction API Interface
interface ModernTransactionAPI {
    TransactionDTO fetchTransaction(int transactionId);
}

//TransactionDTO
public class TransactionDTO {
    private int transactionId;
    private int accountId;
    private String transactionType;
    private double transactionAmount;
    private LocalDateTime dateTime;

    public TransactionDTO(int transactionId, int accountId, String transactionType, double transactionAmount, LocalDateTime dateTime) {
        this.transactionId = transactionId;
        this.accountId = accountId;
        this.transactionType = transactionType;
        this.transactionAmount = transactionAmount;
        this.dateTime = dateTime;
    }

    public int getTransactionId() {
        return transactionId;
    }

    public int getAccountId() {
        return accountId;
    }

    public String getTransactionType() {
        return transactionType;
    }

    public double getTransactionAmount() {
        return transactionAmount;
    }

    public LocalDateTime getDateTime() {
        return dateTime;
    }
}
//Transaction
public class Transaction {
    private int transactionId;
    private int accountId;
    private String transactionType;
    private double transactionAmount;
    private LocalDateTime dateTime;

    public Transaction(int transactionId, int accountId, String transactionType, double transactionAmount, LocalDateTime dateTime) {
        this.transactionId = transactionId;
        this.accountId = accountId;
        this.transactionType = transactionType;
        this.transactionAmount = transactionAmount;
        this.dateTime = dateTime;
    }

    public double getAmount() {
        return transactionAmount;
    }

    public int getTransactionId() {
        return transactionId;
    }

    public void setTransactionId(int transactionId) {
        this.transactionId = transactionId;
    }

    public int getAccountId() {
        return accountId;
    }

    public void setAccountId(int accountId) {
        this.accountId = accountId;
    }

    public String getTransactionType() {
        return transactionType;
    }

    public void setTransactionType(String transactionType) {
        this.transactionType = transactionType;
    }

    public double getTransactionAmount() {
        return transactionAmount;
    }

    public void setTransactionAmount(double transactionAmount) {
        this.transactionAmount = transactionAmount;
    }

    public LocalDateTime getDateTime() {
        return dateTime;
    }

    public void setDateTime(LocalDateTime dateTime) {
        this.dateTime = dateTime;
    }
}
// Adapter
class TransactionAdapter implements LegacyBankSystem {
    private ModernTransactionAPI modernTransactionAPI;

    public TransactionAdapter(ModernTransactionAPI modernTransactionAPI) {
        this.modernTransactionAPI = modernTransactionAPI;
    }

    @Override
    public Transaction getTransaction(int transactionId) {
        TransactionDTO dto = modernTransactionAPI.fetchTransaction(transactionId);
        return new Transaction(dto.getTransactionId(), dto.getAccountId(), dto.getTransactionType(), dto.getTransactionAmount(), dto.getDateTime());
    }
}
// ModernTransactionAPIImpl
public class ModernTransactionAPIImpl implements ModernTransactionAPI {

    @Override
    public TransactionDTO fetchTransaction(int transactionId) {
        // Simulated data fetching from the modern system
        return new TransactionDTO(transactionId, 98765, "DEBIT", 1500.00, LocalDateTime.now());
    }
}
// Usage
public class Main {
    public static void main(String[] args) {
        ModernTransactionAPI api = new ModernTransactionAPIImpl();
        LegacyBankSystem legacySystem = new TransactionAdapter(api);
        Transaction transaction = legacySystem.getTransaction(12345);
        System.out.println("Transaction Amount: " + transaction.getAmount());
    }
}

Use Case: Integrating Legacy and Modern Banking Systems

Scenario: A bank needs to connect its old transaction processing system with a new API that supports online and mobile payments.

Why Adapter?

The bank wants to enhance its services by using the new API, but the old system wasn't designed to work with modern technologies. The Adapter pattern allows the bank to keep using the old system while seamlessly integrating the new API, ensuring that everything works together smoothly. This avoids the need to completely overhaul the existing infrastructure, saving time and resources.

2. Bridge Design Pattern

Definition:

The Bridge pattern separates the abstraction (what you want to do) from the implementation (how you do it), so they can change independently. This makes it easier to modify or extend both parts without affecting each other.

Example: Bank Account Management

Consider a bank that manages different types of accounts, such as savings, checking, and loan accounts. The Bridge pattern helps separate the logic for managing accounts from the specific types of accounts. This allows the bank to easily introduce new account types or change how accounts are managed without disrupting the entire system.

Code Example:

// Implementor interface
interface Account {
    void assignToCustomer(String customerName);
}

// Concrete implementation for Savings Account
class SavingsAccount implements Account {
    private String accountType;

    public SavingsAccount(String accountType) {
        this.accountType = accountType;
    }

    @Override
    public void assignToCustomer(String customerName) {
        System.out.println("Assigning " + accountType + " to " + customerName);
    }
}

// Concrete implementation for Checking Account
class CheckingAccount implements Account {
    private String accountType;

    public CheckingAccount(String accountType) {
        this.accountType = accountType;
    }

    @Override
    public void assignToCustomer(String customerName) {
        System.out.println("Assigning " + accountType + " to " + customerName);
    }
}

// Abstraction
abstract class AccountManager {
    protected Account account;

    public AccountManager(Account account) {
        this.account = account;
    }

    abstract void manageAccount(String customerName);
}

// Refined Abstraction for managing customer accounts
class CustomerAccountManager extends AccountManager {

    public CustomerAccountManager(Account account) {
        super(account);
    }

    @Override
    void manageAccount(String customerName) {
        account.assignToCustomer(customerName);
    }
}

// Usage
public class Main {
    public static void main(String[] args) {
        Account savings = new SavingsAccount("Savings Account");
        Account checking = new CheckingAccount("Checking Account");

        AccountManager savingsManager = new CustomerAccountManager(savings);
        AccountManager checkingManager = new CustomerAccountManager(checking);

        savingsManager.manageAccount("Jane Doe");
        checkingManager.manageAccount("John Smith");
    }
}

Use Case: Enhancing the Bank System

Scenario: A bank wants to manage both savings and checking accounts. They also plan to add new types of accounts, like investment or loan accounts, in the future.

Why Bridge?

With the Bridge pattern, the bank can easily add new types of accounts without changing the core account management logic.

Example of Enhancement:

  1. Adding a New Account Type (e.g. Investment Account):

    • New Implementation: Create a new class InvestmentAccount implementing the Account interface.

    • Usage: Create an instance of InvestmentAccount and use it with CustomerAccountManager.

// Concrete implementation for Investment Account
class InvestmentAccount implements Account {
    private String accountType;

    public InvestmentAccount(String accountType) {
        this.accountType = accountType;
    }

    @Override
    public void assignToCustomer(String customerName) {
        System.out.println("Assigning " + accountType + " to " + customerName);
    }
}

// Usage
public class Main {
    public static void main(String[] args) {
        Account investment = new InvestmentAccount("Investment Account");
        AccountManager investmentManager = new CustomerAccountManager(investment);

        investmentManager.manageAccount("Alice Johnson");
    }
}

Result: The bank can introduce new account types like investment accounts easily. The core logic for managing accounts remains the same, and new types of accounts can be added with minimal changes. This ensures that the system remains flexible and easy to extend.

3. Composite Design Pattern

Concept

The Composite pattern allows individual objects and compositions of objects to be treated uniformly. It is beneficial in scenarios where you need to manage part-whole hierarchies.

Example

Consider a banking system where you need to manage bank accounts along with their associated details (like transactions, account types). The Composite pattern helps treat a combination of an account and its details as a single entity.

Code Explanation

// Component interface
interface AccountComponent {
    void showDetails();
}

// Leaf class
class BasicAccount implements AccountComponent {
    private String accountNumber;
    private String accountType;

    public BasicAccount(String accountNumber, String accountType) {
        this.accountNumber = accountNumber;
        this.accountType = accountType;
    }

    @Override
    public void showDetails() {
        System.out.println("Account Number: " + accountNumber + ", Type: " + accountType);
    }
}

// Composite class
class AccountWithDetails implements AccountComponent {
    private AccountComponent account;
    private String branch;
    private String manager;

    public AccountWithDetails(AccountComponent account, String branch, String manager) {
        this.account = account;
        this.branch = branch;
        this.manager = manager;
    }

    @Override
    public void showDetails() {
        account.showDetails();
        System.out.println("Branch: " + branch + ", Manager: " + manager);
    }
}

// Usage
public class Main {
    public static void main(String[] args) {
        AccountComponent account = new BasicAccount("123456", "Savings");
        AccountComponent accountWithDetails = new AccountWithDetails(account, "Main Branch", "Alice Johnson");

        accountWithDetails.showDetails();
    }
}

Result

The Composite pattern enables the client to treat both simple and complex objects uniformly. The output would be:

Account Number: 123456, Type: Savings
Branch: Main Branch, Manager: Alice Johnson

Use Case

The Composite pattern is particularly useful when dealing with hierarchical structures, such as managing complex bank accounts that include branch and manager details.

4. Decorator Design Pattern

Concept

The Decorator pattern allows additional behavior to be dynamically added to individual objects without affecting the behavior of other objects from the same class.

Example

Suppose you want to enhance basic account details with additional features like overdraft protection and premium services. The Decorator pattern can dynamically add these features.

Code Explanation

// Component interface
interface BankAccount {
    String getDetails();
}

// Concrete component
class BasicBankAccount implements BankAccount {
    private String accountNumber;

    public BasicBankAccount(String accountNumber) {
        this.accountNumber = accountNumber;
    }

    @Override
    public String getDetails() {
        return "Account Number: " + accountNumber;
    }
}

// Decorator abstract class
abstract class AccountDecorator implements BankAccount {
    protected BankAccount account;

    public AccountDecorator(BankAccount account) {
        this.account = account;
    }

    @Override
    public String getDetails() {
        return account.getDetails();
    }
}

// Concrete Decorators
class OverdraftProtectionDecorator extends AccountDecorator {
    private double limit;

    public OverdraftProtectionDecorator(BankAccount account, double limit) {
        super(account);
        this.limit = limit;
    }

    @Override
    public String getDetails() {
        return super.getDetails() + ", Overdraft Protection Limit: " + limit;
    }
}

class PremiumServicesDecorator extends AccountDecorator {
    private String serviceDetails;

    public PremiumServicesDecorator(BankAccount account, String serviceDetails) {
        super(account);
        this.serviceDetails = serviceDetails;
    }

    @Override
    public String getDetails() {
        return super.getDetails() + ", Premium Services: " + serviceDetails;
    }
}

// Usage
public class Main {
    public static void main(String[] args) {
        BankAccount account = new BasicBankAccount("987654");
        account = new OverdraftProtectionDecorator(account, 500.00);
        account = new PremiumServicesDecorator(account, "24/7 Customer Support");

        System.out.println(account.getDetails());
    }
}

Result

The Decorator pattern allows the addition of new behavior without altering the existing class structure. The output would be:

Account Number: 987654, Overdraft Protection Limit: 500.0, Premium Services: 24/7 Customer Support

Use Case

The Decorator pattern is ideal when you want to extend the functionality of individual accounts, such as dynamically adding overdraft protection and premium services to a bank account.


5. Facade Design Pattern

Concept

The Facade pattern provides a simplified interface to a complex subsystem, making it easier to use by abstracting away the complexity.

Example

Consider a scenario where creating a new bank account involves multiple subsystems like account management, address management, and service activation. The Facade pattern can provide a simple interface to these complex operations.

Code Explanation

// Subsystem 1
class AccountService {
    public void createAccount(String accountNumber) {
        System.out.println("Creating account: " + accountNumber);
    }
}

// Subsystem 2
class AddressService {
    public void addAddress(String address) {
        System.out.println("Adding address: " + address);
    }
}

// Subsystem 3
class ServiceActivationService {
    public void activateServices(String accountNumber) {
        System.out.println("Activating services for account: " + accountNumber);
    }
}

// Facade
class AccountFacade {
    private AccountService accountService;
    private AddressService addressService;
    private ServiceActivationService serviceActivationService;

    public AccountFacade() {
        this.accountService = new AccountService();
        this.addressService = new AddressService();
        this.serviceActivationService = new ServiceActivationService();
    }

    public void createCompleteAccount(String accountNumber, String address) {
        accountService.createAccount(accountNumber);
        addressService.addAddress(address);
        serviceActivationService.activateServices(accountNumber);
    }
}

// Usage
public class Main {
    public static void main(String[] args) {
        AccountFacade facade = new AccountFacade();
        facade.createCompleteAccount("111222", "456 Elm St");
    }
}

Result

The Facade pattern simplifies the process of creating a new account by hiding the complexity of interacting with multiple subsystems. The output would be:

yamlCopy codeCreating account: 111222
Adding address: 456 Elm St
Activating services for account: 111222

Use Case

The Facade pattern is particularly useful in systems with complex subsystems, making it easier to use by providing a simplified interface for creating and managing bank accounts.


6. Flyweight Design Pattern

Concept

The Flyweight pattern minimizes memory usage by sharing common data among multiple objects. It is useful when dealing with a large number of objects that share similar data.

Example

Suppose you need to manage a large number of bank account details, where many accounts share the same branch information. The Flyweight pattern can help optimize memory usage by sharing the common branch data.

Code Explanation

// Flyweight
class Branch {
    private String branchName;
    private String branchAddress;

    public Branch(String branchName, String branchAddress) {
        this.branchName = branchName;
        this.branchAddress = branchAddress;
    }

    // Getters
    public String getBranchName() {
        return branchName;
    }

    public String getBranchAddress() {
        return branchAddress;
    }
}

// Flyweight Factory
class BranchFactory {
    private static Map<String, Branch> branchMap = new HashMap<>();

    public static Branch getBranch(String branchName, String branchAddress) {
        String key = branchName + branchAddress;
        if (!branchMap.containsKey(key)) {
            branchMap.put(key, new Branch(branchName, branchAddress));
        }
        return branchMap.get(key);
    }
}

// Client
class BankAccount {
    private String accountNumber;
    private Branch branch;

    public BankAccount(String accountNumber, Branch branch) {
        this.accountNumber = accountNumber;
        this.branch = branch;
    }

    public void showDetails() {
        System.out.println("Account Number: " + accountNumber + ", Branch: " + branch.getBranchName() + ", Address: " + branch.getBranchAddress());
    }
}

// Usage
public class Main {
    public static void main(String[] args) {
        Branch branch = BranchFactory.getBranch("Main Branch", "789 Pine St");

        BankAccount account1 = new BankAccount("222333", branch);
        BankAccount account2 = new BankAccount("333444", branch);

        account1.showDetails();
        account2.showDetails();
    }
}

Result

The Flyweight pattern reduces memory usage by sharing the common branch data among multiple bank accounts. The output would be:

Account Number: 222333, Branch: Main Branch, Address: 789 Pine St
Account Number: 333444, Branch: Main Branch, Address: 789 Pine St

Use Case

The Flyweight pattern is beneficial when you need to manage a large number of similar objects, such as bank accounts sharing the same branch information.


7. Proxy Design Pattern

Concept

The Proxy pattern provides a surrogate or placeholder for another object to control access to it. This pattern is useful when you need to manage access to resources, such as remote objects, expensive objects, or objects with restricted access.

Example

Consider a scenario where you need to manage access to a bank account's sensitive operations. The Proxy pattern can provide controlled access to these operations, such as only allowing authorized users to perform certain actions.

Code Explanation

// Subject interface
interface BankAccount {
    void performTransaction(String transactionType, double amount);
}

// Real Subject
class RealBankAccount implements BankAccount {
    private String accountNumber;

    public RealBankAccount(String accountNumber) {
        this.accountNumber = accountNumber;
    }

    @Override
    public void performTransaction(String transactionType, double amount) {
        System.out.println("Performing " + transactionType + " of $" + amount + " on account: " + accountNumber);
    }
}

// Proxy
class BankAccountProxy implements BankAccount {
    private RealBankAccount realBankAccount;
    private String userRole;

    public BankAccountProxy(String accountNumber, String userRole) {
        this.realBankAccount = new RealBankAccount(accountNumber);
        this.userRole = userRole;
    }

    @Override
    public void performTransaction(String transactionType, double amount) {
        if ("ADMIN".equals(userRole) || "USER".equals(userRole)) {
            realBankAccount.performTransaction(transactionType, amount);
        } else {
            System.out.println("Access Denied: Unauthorized user role");
        }
    }
}

// Usage
public class Main {
    public static void main(String[] args) {
        BankAccount adminProxy = new BankAccountProxy("444555", "ADMIN");
        BankAccount userProxy = new BankAccountProxy("444555", "USER");
        BankAccount guestProxy = new BankAccountProxy("444555", "GUEST");

        adminProxy.performTransaction("Deposit", 500.00);
        userProxy.performTransaction("Withdrawal", 300.00);
        guestProxy.performTransaction("Deposit", 100.00);
    }
}

Result

The Proxy pattern controls access to the real bank account based on the user's role. The output would be:

Performing Deposit of $500.0 on account: 444555
Performing Withdrawal of $300.0 on account: 444555
Access Denied: Unauthorized user role

Use Case

The Proxy pattern is ideal when you need to control access to resources or perform additional checks, such as managing user roles and permissions in a banking system.

In this article, we covered the seven key Structural Design Patterns: Adapter, Bridge, Composite, Decorator, Facade, Flyweight, and Proxy.

In our next article, we'll explore Behavioral Design Patterns, which are all about how objects interact and communicate with one another. Stay tuned!

Github link to refer Java code : https://github.com/thesatyendrakumar/java-design-patterns/tree/main/src/main/java/org/thesatyendrakumar/structural_patterns

0
Subscribe to my newsletter

Read articles from Satyendra Kumar directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Satyendra Kumar
Satyendra Kumar