On Obsession, Primitive and Otherwise

On Obsession, Primitive and Otherwise

Colin Jones
Colin Jones

June 05, 2012

Primitive Obsession is bad stuff. But it's bad, not because primitives are bad, but because obsession is bad.

From Merriam-Webster:

ob-ses-sion, noun: a persistent disturbing preoccupation with and often unreasonable idea or feeling

Persistent, disturbing, unreasonable? Bad.

But without "obsession" involved, is it so wrong to use primitives?

To be fair, I understand there are problems with using primitives to represent domain ideas in object-oriented programming. One of the key problems is that we'll want methods for our domain objects, and if those objects are primitives, then we're suddenly a little stuck. If we write sales software, and we make our quotePrice an integer, how do we tell when the quote happened? Who gave it? Who did they give it to? So we'll want to ask it questions about its data. No problem there - we can associate multiple pieces of data using a map ("hash" in Ruby-speak) with names and dates and things. [1]

But next we'll want to do things with it, whether it's to perform an action or ask a question that requires some massaging of the data itself. We could pass quotePrice into some method that'll ask it questions about its data and then do those things for us. But where does that method belong? Do we put it on some class? In a module? We could do that, but then we lose the ability to use polymorphism based on the information in our domain object. This seems pretty crippling: it'd be nice to avoid forcing the caller of this method to have to know whether it has a quotePrice or contractPrice in order to perform some action with it.

Making quotePrice an instance of a QuotePrice class avoids these problems: we can tell our quotePrice to do things using its data, as well as ask it questions about itself. We have polymorphism available to us, and the idea of a QuotePrice gives both a name for the domain concept and a bucket where we can put the functionality.

QED: primitives for domain concepts are bad, even without the whole "obsession" thing thrown into the mix.

But what have we really proven here? What are the real problems we've solved by wrapping our primitive data with objects? We needed:

  • functionality that acted on our data
  • a place and name for this functionality
  • polymorphism

I think we can get these wins and more in a functional style, without wrapping our primitives in objects. Let's look at these one at a time.

Functions and data

Plain old functions work fine for implementing functionality. We can consider an instance method on an object with no arguments to be the same as a function with one argument (the receiver). Practically speaking, and depending on language, there may be performance implications, but they're semantically the same idea.

quotePrice.issuer()
issuer(quotePrice)

Similar stuff can be done for methods that take arguments:

quotePrice.amount("USD")
amount(quotePrice, "USD")

So here we've decoupled our data from the operations on it, in some sense. Certainly the functions will still contain assumptions about the underlying primitives, but they no longer need to have a wrapping class instance in between. This turns out to be quite powerful in practice, as we'll see in the section about other wins, but for now: it's possible.

Namespacing

But even if these methods don't live in a class, we'll still need someplace else to put them. After all, we don't want to have to go back to the dark ages of having to use prefixes for namespacing. Plenty of languages have flexible namespaces: modules in Ruby, Haskell and Standard ML, namespaces in Clojure. So there's a place.

An interesting question that comes up here is: what should we name our namespace? Is it a QuotePrice namespace? Are the functions divided across multiple namespaces? Maybe this amount function seems useful for other domain objects, like contractPrice. We might have one namespace that handle both kinds of domain objects, or they might have subtle differences that make us want to split them up. Either way, we now have both a place and a name for this functionality. And we can name the data itself independently, with the names of the references to the data (variables).

Polymorphism

The last thing we missed about using primitives, polymorphism, is the most interesting one to me. If you know Clojure, you're probably aware of some of Clojure's solutions for polymorphism (even before digging into the somewhat class-like defrecord/deftype constructs). But for those who don't, multimethods are an important feature of Common Lisp as well as Clojure, and allow polymorphism on all the parameters, not just the first (aka multiple dispatch). And in Clojure, it's not just based on the types of the arguments: dispatch can happen according to a function that you define - this moves in the direction of predicate dispatch, but without the performance guarantees or the degree of openness. Watch David Nolen's Clojure/conj talk for more. You really want to watch this talk.

Languages like ML, Erlang, and Haskell don't have multimethods, but polymorphism is still available via pattern matching and other mechanisms. Pattern matching is often based on type, but can also be based on value. It's powerful and succinct, and it certainly solves the problem of not having polymorphism at all.

All this about polymorphism is to say: classes aren't the only way to provide it. Clients using our quotePrice domain object don't have to know what kind of price they've got - they just need to call amount, passing the price in. And whether we're pattern matching, using multimethods, or going the traditional class-based route, we can dispatch to different implementations of amount depending on the price we've got ahold of.

Other wins

More generic data types generally have well-known and useful functions associated with them. We could enumerate the keys or values on a map, filter an array by a function, or reduce across it. Alan Perlis [allegedly] said "It is better to have 100 functions operate on one data structure than 10 functions on 10 data structures," and I tend to agree. One benefit of using a more generic data structure instead of hiding that data behind a class is that it removes an extra step in applying well-known functions to that data. In some situations this could be considered a downside. Consider encapsulation, where we might insulate against changes to the underlying data structures. But in many cases when choosing to encapsulate our data behind a class, the tradeoffs in ease of use and reuse are not worth it. If we decide we need encapsulation, of course, most functional languages do provide that ability using closures. [2]

OK

The original problems we saw with what we're calling Primitive Obsession do have solutions aside from wrapping the primitive data in class-based objects, in a number of languages. I think these alternative solutions might help to explain, in part, why dealing directly with primitives in functional languages seems to be more socially acceptable than in object-oriented languages. But I don't expect that I've convinced many that using primitives is preferable to wrapping them with objects, nor is that my intention here. I certainly won't accuse anyone of practicing Object Obsession, at least not here. My point is just that it's possible to solve the same problems without wrapping class-based objects around primitives. Preference is a quite a bit fuzzier, but let's think and talk more about how far these alternative solutions go towards turning Primitive Obsession into Primitive Celebration.

[1] Let's assume for the purposes of this blog post that maps are primitives. I think it's sound, despite being a bit of a stretch: I think of primitives more in terms of genericity than non-classiness.

[2] See Chapter 2 of Let Over Lambda for details.