DEV Community

mohamed Tayel
mohamed Tayel

Posted on

Flexible C# with OOP Principles:Simplifying Complex Code with the State Design Pattern

Introduction

As we develop software, requirements tend to evolve, and what starts as a simple class often grows into a complex, hard-to-maintain structure. Managing additional features with multiple conditions can quickly turn a class into a confusing tangle of logic. In this article, we’ll explore how to use the State Design Pattern to manage complexity, using a bank account example.


The Problem: Complex Code with Conditional Logic

Imagine we start with a straightforward bank account class that allows deposits and withdrawals. Initially, we might only have to check a few conditions, such as verifying the user for withdrawals or preventing transactions if the account is closed. However, as additional requirements come in, we soon find ourselves handling multiple conditions for each action. Here’s how the situation might unfold.

Example Requirements

  1. Deposits: Money can be added to an account anytime.
  2. Withdrawals: Money can only be withdrawn if the account holder’s identity is verified.
  3. Account Closure: When an account is closed, all transactions should be blocked.
  4. Frozen State: Inactive accounts can be marked as “frozen,” which requires special handling for deposits and withdrawals.

Each new requirement introduces a condition we must check, leading to if statements scattered across methods. Below is a simplified version of what this complex branching code might look like.

Before: Code with Multiple Conditions

In the initial approach, the Account class uses flags like IsClosed, IsVerified, and IsFrozen to handle different conditions:

public class Account
{
    public decimal Balance { get; private set; }
    public bool IsClosed { get; private set; }
    public bool IsVerified { get; private set; }
    public bool IsFrozen { get; private set; }
    private readonly Action _onUnfreeze;

    public Account(Action onUnfreezeCallback)
    {
        _onUnfreeze = onUnfreezeCallback;
    }

    public void Deposit(decimal amount)
    {
        if (IsClosed)
            throw new Exception("Account is closed.");

        if (IsFrozen)
        {
            IsFrozen = false;
            _onUnfreeze?.Invoke();
        }

        Balance += amount;
    }

    public void Withdraw(decimal amount)
    {
        if (IsClosed)
            throw new Exception("Account is closed.");

        if (!IsVerified)
            throw new Exception("Account not verified for withdrawal.");

        if (IsFrozen)
        {
            IsFrozen = false;
            _onUnfreeze?.Invoke();
        }

        Balance -= amount;
    }

    public void CloseAccount() => IsClosed = true;

    public void VerifyHolder() => IsVerified = true;

    public void FreezeAccount() => IsFrozen = true;
}
Enter fullscreen mode Exit fullscreen mode

Problems with This Approach

  • Cluttered Logic: Each method contains multiple if conditions, making the code harder to read and maintain.
  • Duplication: Similar checks for each state (closed, frozen, verified) are repeated across methods.
  • Difficult to Extend: Adding more requirements, such as an “inactive” state, would increase complexity and make the code even harder to understand.

Solution: The State Design Pattern

Instead of manually checking each condition, we can simplify the Account class by introducing the State Design Pattern. This approach allows us to encapsulate state-specific behaviors in separate classes, reducing the need for branching logic.

What is the State Design Pattern?

The State Pattern allows an object’s behavior to change based on its state. In our example, we can create state classes for Open, Closed, and Frozen, each handling its own rules for deposits and withdrawals. This way, each state manages its behavior, and the Account class simply delegates actions to its current state.

Refactoring the Code

  1. Define an Interface for Account States: This interface declares actions that each state should handle, like Deposit and Withdraw.
  2. Create Separate State Classes: Each class implements the state interface and handles its specific conditions.
  3. Delegate to State Classes: The Account class keeps track of its current state and delegates actions to it, eliminating the need for if statements.

After: Refactored Code with the State Pattern

1. Define the State Interface

The IAccountState interface defines the actions that each state class should implement:

public interface IAccountState
{
    void Deposit(Account account, decimal amount);
    void Withdraw(Account account, decimal amount);
}
Enter fullscreen mode Exit fullscreen mode

2. Implement State Classes

Each state class implements the IAccountState interface, defining the behavior for that specific state. Below are the Open, Closed, and Frozen state classes.

public class OpenState : IAccountState
{
    public void Deposit(Account account, decimal amount)
    {
        account.Balance += amount;
    }

    public void Withdraw(Account account, decimal amount)
    {
        if (account.IsVerified)
            account.Balance -= amount;
        else
            throw new Exception("Account not verified for withdrawal.");
    }
}

public class ClosedState : IAccountState
{
    public void Deposit(Account account, decimal amount)
    {
        throw new Exception("Account is closed.");
    }

    public void Withdraw(Account account, decimal amount)
    {
        throw new Exception("Account is closed.");
    }
}

public class FrozenState : IAccountState
{
    private readonly Action _onUnfreeze;

    public FrozenState(Action onUnfreeze)
    {
        _onUnfreeze = onUnfreeze;
    }

    public void Deposit(Account account, decimal amount)
    {
        _onUnfreeze?.Invoke(); // Call unfreeze callback
        account.State = new OpenState(); // Transition to Open state after unfreezing
        account.State.Deposit(account, amount); // Delegate deposit to Open state
    }

    public void Withdraw(Account account, decimal amount)
    {
        throw new Exception("Account is frozen.");
    }
}
Enter fullscreen mode Exit fullscreen mode

3. Refactor the Account Class

The Account class now delegates actions to its current state. It doesn’t need to know the details of each state; it only interacts with Deposit and Withdraw methods from the current state.

public class Account
{
    public decimal Balance { get; set; }
    public bool IsVerified { get; set; }
    public IAccountState State { get; set; }

    public Account(IAccountState initialState)
    {
        State = initialState;
    }

    public void Deposit(decimal amount) => State.Deposit(this, amount);
    public void Withdraw(decimal amount) => State.Withdraw(this, amount);

    public void CloseAccount() => State = new ClosedState();
    public void VerifyHolder() => IsVerified = true;
    public void FreezeAccount(Action onUnfreeze) => State = new FrozenState(onUnfreeze);
}
Enter fullscreen mode Exit fullscreen mode

Benefits of Using the State Pattern

  • Reduced Complexity: The Account class no longer contains complex conditional logic, making it much easier to read and maintain.
  • Encapsulated Logic: Each state class manages its own rules, so all state-specific logic is separated and modular.
  • Easy to Extend: Adding new states, such as an “inactive” state, is simple; we only need to create a new state class without modifying existing code.

Testing the Refactored Code

With state-based design, testing becomes simpler. Each state class’s behavior is isolated, making it easy to write tests for specific actions:

  1. Open State: Test that deposits and withdrawals work as expected.
  2. Closed State: Ensure deposits and withdrawals are blocked.
  3. Frozen State: Verify that a deposit unfreezes the account and invokes the callback.

Each test can now focus on specific behavior without needing complex branching conditions.

Final Thoughts

The State Design Pattern is a powerful tool for managing complex, condition-heavy classes. By creating state classes, we encapsulate specific behaviors, making the code easier to understand, extend, and test. Using this pattern helps maintain a clean and organized design, which becomes even more valuable as new requirements arise.

This pattern enables us to keep complexity under control and make our code scalable, maintainable, and truly object-oriented.

Top comments (0)