Code Smell: Primitive Obsession

Imagine you’re using a television remote that’s been stripped of its buttons. You want to change channels, but now you need to remember where the numbers were on the remote. Additionally, you need to understand how to interact with a buttonless remote to select a number. Your desire to simply change channels is greatly inconvenienced because you’re exposed to unnecessary information for the task at hand. You wanted a remote but instead have something less than a remote. In the software world, this is known as primitive obsession, which is the use of primitive types to represent concepts. Unfortunately, this is commonly seen in many software projects, and as innocent as this practice may seem, it is something developers must be wary of.

The main issue with primitive obsession is the level of detail required to handle the task at hand. Consider this trivial example. Say we are adding different units of measurements such as feet, inches, and centimeters, where these units are represented as primitive types, and we want the result in feet.

let feet: Double = 2
let inches: Double = 4
let centimeters: Double = 5

To do this, we need to convert inches and centimeters to feet and add the three units together.

let feet: Double = 2
let inches: Double = 4
let centimeters: Double = 5

let inchesInFeet = inches / 12

let centimetersInFeet = centimeters * 3.2808399 / 100

let totalFeet = feet + inchesInFeet + centimetersInFeet

Many developers writing this code will see this as a non-issue, and yes, I’ve left out comments and descriptive constants intentionally because this is what you will often see. As the author of this code, I had to deal with unit conversions before I was able to add up the units in feet. Readers of this code will need to understand the same, but unlike myself who has more context into the implementation, they will also need to understand what 12, 3.2808399, and 100 represent. In fact, I too will need to do the same as a future reader of this code since it is likely that I will have forgotten how I came to this solution.

As a reminder, the goal was simply to add the number of feet, inches, and centimeters together, but I had to concern myself with details around unit conversion. Using such a small example, this may seem reasonable. However, it is important to realize that the context in which we are adding these units, no matter the size of the problem, shouldn’t be aware of this extra level of detail.

What we really want is something that reads more naturally, and we can do this by assuming the perfect API based on our notion of good design already exists for our current problem. For example, we can have something like the following:

let feet = Feet(2)
let inches = Inches(4)
let centimeters = Centimeters(5)

let totalFeet = feet + inches + centimeters

This is a good start. We no longer need to worry about unit conversions at this level. Once this code is in place, we can define the missing concepts.

struct Feet {

  private let feet: Double

  init(_ feet: Double) {
    self.feet = feet
  }

  static func + (lhs: Feet, rhs: Inches) -> Feet {
    // The conversion intent below can also be the following:
    //
    // rhs.convert(to: .feet)
    //
    // It's just a matter of representation and
    // incremental refactoring.

    return lhs + rhs.convertToFeet()
  }

  static func + (lhs: Feet, rhs: Feet) -> Feet {
    return Feet(lhs.feet + rhs.feet)
  }

  static func + (lhs: Feet, rhs: Centimeters) -> Feet {
    // See comment for adding feet and inches.
    return lhs + rhs.convertToFeet()
  }

}

struct Inches {

  private let inches: Double

  init(_ inches: Double) {
    self.inches = inches
  }

  func convertToFeet() -> Feet {
    let inchesInAFoot = 12

    return Feet(inches / inchesInAFoot)
  }

}

struct Centimeters {

  private let centimeters: Double

  init(_ centimeters: Double) {
    self.centimeters = centimeters
  }

  func convertToFeet() -> Feet {
    let feetInAMeter = 3.2808399
    let centimetersInAMeter = 100

    return centimeters * feetInAMeter / centimetersInAMeter
  }

}

let feet = Feet(2)
let inches = Inches(4)
let centimeters = Centimeters(5)

let totalFeet = feet + inches + centimeters

This obviously isn’t a perfect API. It is simply a start that can evolve with proper design thinking and refactoring techniques. With time, the API may evolve into something that Apple introduced in their SDK starting in iOS 10.

let feet = Measurement(value: 2, unit: UnitLength.feet)
let inches = Measurement(value: 4, unit: UnitLength.inches)
let centimeters = Measurement(value: 5, unit: UnitLength.centimeters)

let totalMeasurement = feet + inches + centimeters

let totalFeet = totalMeasurement.converted(to: UnitLength.feet)

As you can see, we no longer need to worry about unit conversion. It is true that someone will need implement the conversion details, but that should be done in a suitable location. By wrapping primitives in their conceptual homes, we gain the following benefits:

  • More clarity
    • We no longer need to worry about unit conversion. We just need to add the units together.
  • More flexible
    • Because we don’t need to worry about how those units are implemented, their underlying representations can change as needed.
  • Less error prone
    • As mentioned before, someone will need to implement the conversion logic. However, this only needs to be done once.
    • If the primitives aren’t wrapped, someone will need to duplicate the same logic or work with similar concepts, thus increasing the chances of introducing errors.

The next time you look through primitively obsessed code or are about to implement a solution, slow down and give it some thought. It is easy to use primitives to represent concepts, but it is at a cost that may not be immediately obvious. The drawbacks may show themselves early on, or they may appear at a later stage in development, sometimes with regrettable consequences.

Leave a comment