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
- Deposits: Money can be added to an account anytime.
- Withdrawals: Money can only be withdrawn if the account holder’s identity is verified.
- Account Closure: When an account is closed, all transactions should be blocked.
- 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;
}
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
-
Define an Interface for Account States: This interface declares actions that each state should handle, like
Deposit
andWithdraw
. - Create Separate State Classes: Each class implements the state interface and handles its specific conditions.
-
Delegate to State Classes: The
Account
class keeps track of its current state and delegates actions to it, eliminating the need forif
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);
}
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.");
}
}
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);
}
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:
- Open State: Test that deposits and withdrawals work as expected.
- Closed State: Ensure deposits and withdrawals are blocked.
- 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)