SmartEnum for better domain modelling

An enum is a simple value that has no logic on it’s own. It is usually used to note a type of something.

However, because they can not contain any logic, we end up poluting our domain with many if-else and switch-case statements.

Real world example

Consider the following real world example: N26 bank allows users to open savings accounts. They offer different plans, and each of the plans offers different interest rate. The plans are:

  • Standard – 1.26% interest
  • Smart – 2.26% interest
  • You – 2.26% interest
  • Metal – 4.00% interest

Note: I am not in any way affiliated, or work for N26 bank, and have no idea how their systems internally work. I do use it for personal finances.

Here is how the implementation might look like when using enums:

enum CustomerPlan
{
    Standard,
    Smart,
    You,
    Metal
}

class Customer
{
    public CustomerPlan Plan { get; set; }

    public double Balance { get; private set; }

    public void AddSavingsInterest()
    {
        var interestRate = GetSavingsInterestRate();
        var interest = Balance / 100 * interestRate;
        Balance = Balance + interest;
    }

    private double GetSavingsInterestRate()
    {
        return Plan switch
        {
            CustomerPlan.Standard => 1.26,
            CustomerPlan.Smart => 2.26,
            CustomerPlan.You => 2.26,
            CustomerPlan.Metal => 4.00,
            _ => throw new NotSupportedException("Unknown customer plan")
        };
    }
}

Why do I find this to be a misuse of enums?

Take a closer look to the GetSavingsInterestRate method. If the switch statement has no match, it will throw exception. But, all the code paths are covered, why do we need throw? That line is not even testable – there is no plan that will execute this line of code.

Another point is, the code is now riddled with if-else and switch statements. At this moment it does not look like much, but as soon as we start adding more code, this will happen.

How do we solve these issues, systematically?

SmartEnums were created just for this purpose. We can move the logic from the Customer class to the CustomerPlan smart enum. This will remove all the switch statements, and introduce compile time errors if we have not covered a case. And compile time error is way better than throwing exception when the code executes.

A SmartEnum is a better way of representing logic related to an enum. It is a class that has it’s own behavior.

I generally use SmartEnum library by Ardalis.

Here is the same CustomerPlan enum, this time as a smart enum:

class CustomerPlan : SmartEnum<CustomerPlan, string>
{
    public readonly CustomerPlan Standard = new CustomerPlan(nameof(Standard), 1.26);
    public readonly CustomerPlan Smart = new CustomerPlan(nameof(Smart), 2.26);
    public readonly CustomerPlan You = new CustomerPlan(nameof(You), 2.26);
    public readonly CustomerPlan Metal = new CustomerPlan(nameof(Metal), 4.00);

    private CustomerPlan(string name, double interestRate) :
        base(name, name)
    {
        InterestRate = interestRate;
    }

    public double InterestRate { get; }
}

This helps us get rid of the switch, if-else statements, and exceptions. Calculating the balance now is as simple as this:

public void AddSavingsInterest()
{
    var interest = Balance / 100 * Plan.InterestRate;
    Balance = Balance + interest;
}

So far, we have only created different instances of the same class, but the logic is exactly the same for each. That is okay, as it gets the job done for now.

Adding a new feature

A customer can withdraw money using an ATM machine a few times per month, depending on the plan. The function to withdraw takes the amount, and returns a boolean if the transaction has occurred.

Without SmartEnum

public int NumberOfWithdrawals { get; private set; }

public bool TryWithdraw(double amount)
{
    if (Balance < amount)
    {
        return false;
    }

    if (HasReachedWithdrawalLimit(NumberOfWithdrawals))
    {
        return false;
    }

    NumberOfWithdrawals++;
    Balance -= amount;
    return true;
}

private bool HasReachedWithdrawalLimit(int numberOfWithdrawals)
{
    var allowedNumberOfWithdrawals = GetWithdrawalLimit();
    return numberOfWithdrawals < allowedNumberOfWithdrawals;
}

private int GetWithdrawalLimit()
{
    return Plan switch
    {
        CustomerPlan.Standard => 3,
        CustomerPlan.Smart => 5,
        CustomerPlan.You => 5,
        CustomerPlan.Metal => 10,
        _ => throw new NotSupportedException()
    };
}

The switch statement is there again, along with a new throw, which should never occur.

With SmartEnum

The implementation with SmartEnums is simple. We add a method HasReachedWithdrawalLimit to check if we have reached the number of withdrawals to the CustomerPlan class. Notice how the logic is moved closer to the CustomerPlan, instead of residing in the Customer class.

class CustomerPlan : SmartEnum<CustomerPlan, string>
{
    private readonly int _numberOfAllowedWithdrawals;
    public readonly CustomerPlan Standard = new CustomerPlan(nameof(Standard), 1.26, 3);
    public readonly CustomerPlan Smart = n3ew CustomerPlan(nameof(Smart), 2.26, 5);
    public readonly CustomerPlan You = new CustomerPlan(nameof(You), 2.26, 5);
    public readonly CustomerPlan Metal = new CustomerPlan(nameof(Metal), 4.00, 10);

    private CustomerPlan(string name, double interestRate, int numberOfAllowedWithdrawals) :
        base(name, name)
    {
        _numberOfAllowedWithdrawals = numberOfAllowedWithdrawals;
        InterestRate = interestRate;
    }

    public double InterestRate { get; }

    public bool HasReachedWithdrawalLimit(int numberOfWithdrawals)
        => numberOfWithdrawals < _numberOfAllowedWithdrawals;
}

Then use the method in the Customer class, and we are done:

public int NumberOfWithdrawals { get; private set; }

public bool TryWithdraw(double amount)
{
    if (Balance < amount)
    {
        return false;
    }

    if (Plan.HasReachedWithdrawalLimit(NumberOfWithdrawals))
    {
        return false;
    }

    NumberOfWithdrawals++;
    Balance -= amount;
    return true;
}

Adding another feature

The bank decided to introduce a new Premium plan, which has unlimited number of withdrawals. What is the best way to implement this?

Without SmartEnum

The usual way would be to change the Customer.TryWithdraw method to check for plan, before checking if withdrawal limit is reached:

if (Plan != CustomerPlan.Premium && 
    HasReachedWithdrawalLimit(NumberOfWithdrawals))
{
    return false;
}   

Although simple, this is not a very good code. It takes more cognitive load to reason with it. Why is that first condition here? The domain expert says that customers with premium plans can withdraw as many times as they want. That change should be contained within the CustomerPlan itself. Otherwise, we are spilling the concerns of the CustomerPlan enum to the Customer class.

With SmartEnum

With SmartEnums, we have the full power of inheritance and polymorhpism!
For this, we will change the implementation of the enum quite a lot, and introduce new classes that model the domain. What we dont do is infest the code base with if-elses. The Customer class remains unchanged. The complexity is contained to the CustomerPlan smart enum.

To correctly model the behavior, 2 private classes are introduced to the CustomerPlan enum:

  • The first is the PremiumPlan, which always returns false whenever we ask if the withdrawal limit has been reached.
  • The second one is the previous implementation of CustomerPlan behavior. It is moved to another class so that both inherit the CustomerPlan base class.
abstract class CustomerPlan : SmartEnum<CustomerPlan, string>
{
    public readonly CustomerPlan Standard = new LimitedWithdrawalCustomerPlan(nameof(Standard), 1.26, 3);
    public readonly CustomerPlan Smart = new LimitedWithdrawalCustomerPlan(nameof(Smart), 2.26, 5);
    public readonly CustomerPlan You = new LimitedWithdrawalCustomerPlan(nameof(You), 2.26, 5);
    public readonly CustomerPlan Metal = new LimitedWithdrawalCustomerPlan(nameof(Metal), 4.00, 10);
    public readonly CustomerPlan Premium = new PremiumPlan();

    private CustomerPlan(string name, double interestRate) :
        base(name, name)
    {
        InterestRate = interestRate;
    }

    public double InterestRate { get; }

    public abstract bool HasReachedWithdrawalLimit(int numberOfWithdrawals);

    private sealed class LimitedWithdrawalCustomerPlan(string name, double interestRate, int numberOfAllowedWithdrawals)
        : CustomerPlan(name, interestRate)
    {
        public override bool HasReachedWithdrawalLimit(int numberOfWithdrawals)
            => numberOfWithdrawals < numberOfAllowedWithdrawals;
    }

    private sealed class PremiumPlan() : 
        CustomerPlan(nameof(PremiumPlan), 10.00)
    {
        public override bool HasReachedWithdrawalLimit(int numberOfWithdrawals)
            => false;
    }
}

The important thing to see here is the power SmartEnums unlock. We can put the complexity of an enum inside a class, or even different classes for each enum value. We can use OOP, polymorhpism and inheritance.

Solving the issues, systematically

At the beginning, we mentioned that we want to solve the issues with a system, so they are solved once and for all. I would argue that is exactly what was done here.

In the new approach, there are no if-else and switch statements in the code – we simply do not need them. In case of introducing new enum values, there is also no possibility of us forgetting to add specific option to existing switch-cases. And, when we introducde a new option, we have to do so in a single place.

Practical details

Configuring SmartEnum to work with EntityFrameworkCore

To work with EFCore, a conversion has to be configured for the SmartEnum property.
In the script below, we use a static method FromName on CustomerPlan, which is provided by SmartEnum library to find specific instance of CustomerPlan based on the string value.

See the documentation of the library for more information.

public class AppDbContext : DbContext
{
    public DbSet<Customer> Customers { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Customer>()
            .Property(customer => customer.Plan)
            .HasConversion(
                customer => customer.Name,
                name => CustomerPlan.FromName(name, false));
    }
}

Notes:

In the code samples above, some details are simplified. For example, representing money is never done with a double. Instead, a value object is used that can work with specific precision.