DEV Community

loading...

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

canro91 profile image Cesar Aguirre Originally published at canro91.github.io ・3 min read

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. For example, plain strings for usernames or decimals for currencies. To solve this code smell, create classes to model the business entities. And, use those classes 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, you 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 method were something like ToUnits and ToAmount. But, besides variable names, there wasn't anything preventing 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

An alias

As an alternative to encode units of measure on names, we can use a type alias. Let's declare using Unit = System.Decimal and change the correct parameters to use Unit. But, the compiler won't warn 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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. Let's try a better alternative.

A class

Now, let's create a Unit class and pass it around instead of decimal. In the constructor, we can check if the input amount is inside the 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;
}
Enter fullscreen mode Exit fullscreen mode

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.

If needed, we can overload the + and - operators to make sure we're not adding oranges and apples.

[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'
}
Enter fullscreen mode Exit fullscreen mode

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

Happy coding!

Discussion (0)

pic
Editor guide