A case of primitive obsession. A real example in C#

These days I was working with Stripe API to take payments. And I found a case of primitive obsession. Keep reading to learn how to get rid of it.

Primitive obsession is when developers choose primitive types (strings, integers, decimals) to represent entities of the business domain. To solve this code smell, create classes to model the business entities and to enforce the appropriate business rules.

Using Stripe API

Stripe API uses units to represent amounts. All amounts are multiplied by 100. This is 1USD = 100 units. Also, we can only use amounts between $0.50 USD and $999,999.99 USD. This isn’t the case for all currencies, but let’s keep it simple. For more information, check Stripe documentation for currencies.

The codebase I was working with used two extension methods on the decimal type to convert between amounts and units. Those two methods were something like ToUnits() and ToAmount().

But, besides variable names, there wasn’t anything preventing me to use a decimal instead of Stripe units. It was the same decimal type for both concepts. Anyone could forget to convert things and charge someone’s credit card more than expected. Arggg!

A case of primitive obsession
Photo by rupixen.com on Unsplash

Getting rid of primitive obsession

1. Create a type alias

As an alternative to encode units of measure on variable names, we can use a type alias.

Let’s declare a new type alias with using Unit = System.Decimal and change the correct parameters to use Unit. But, the compiler won’t warn us if we pass decimal instead of Unit. See the snippet below.

using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace GettingRidOfPrimitiveObsession
{
    using Unit = System.Decimal;

    [TestClass]
    public class ConvertBetweenAmountAndUnits
    {
        [TestMethod]
        public void UseTypeAlias()
        {
            decimal amount = 100;
            Unit chargeAmount = amount.ToUnit();

            var paymentService = new PaymentService();
            paymentService.Pay(chargeAmount);

            paymentService.Pay(amount);
            // ^^^^ It compiles
        }
    }

    public class PaymentService
    {
        public void Pay(Unit amountToCharge)
        {
            // Magic goes here
        }
    }

    public static class DecimalExtensions
    {
        public static Unit ToUnits(this decimal d)
            => d * 100;
    }
}

Using a type alias is more expressive than encoding the unit of measure in variable names and parameters. But, it doesn’t force us to use one type instead of the other.

With the Unit type alias, we can still, by mistake, pass regular decimals when we meant units.

Let’s try a better alternative!

2. Create a new type

Now, let’s create a Unit class and pass it around instead of decimal.

In the constructor of the new Unit class, let’s check if the input amount is inside Stripe bounds. Also, let’s use a method to convert units back to normal amounts.

public class Unit
{
    internal decimal Value { get; }

    private Unit(decimal d)
    {
        if (d < 0.5m || d > 999_999.99m)
        {
            throw new ArgumentException("Amount outside of bounds");
        }

        Value = d * 100m;
    }

    public static Unit FromAmount(decimal d)
      => new Unit(d);

    public decimal ToAmount()
        => Value / 100m;
}

Notice, we made the constructor private and added a FromAmount() factory method for more readability.

After using a class instead of an alias, the compiler will warn us if we switch the two types by mistake. And, it’s clear from a method signature if it works with amounts or units.

[TestMethod]
public void UseAType()
{
    decimal amount = 100;
    Unit chargeAmount = Unit.FromAmount(amount);

    var paymentService = new PaymentService();
    paymentService.Pay(chargeAmount);

    // paymentService.Pay(amount);
    // ^^^^ cannot convert from 'decimal' to 'GettingRidOfPrimitiveObsession.Unit'
}

If needed, we can overload the + and - operators to make sure we’re not adding oranges and apples. Decimals and units, I mean.

Records from C# 9.0 offer a shorter notation for classes to replace primitive values. Records have built-in memberwise comparison, ToString() methods and copy constructors, among other features.

We can use custom classes, like the Unit class we wrote, to encode restrictions, constraints, and business rules in our business domain. That’s the main takeaway from Domain Modeling Made Functional.

Voilà! That’s how we can get rid of primitive obsession. A type alias was more expressive than encoding units of measure on names. But, a class was a better alternative. By the way, F# supports unit of measures to variables. And, the compiler will warn you if you forget to use the right unit of measure.

Looking for more content on C#? Check my post series on C# idioms and my C# definitive guide. Working with Stripe, too? Check my post on how to use the Decorator pattern to implement retry logic.

Happy coding!