Replace Domainy Primitives With Info Objects

Understand your problem space and live in it

All useful software exists within some problem “domain.”

“Domain” has a lot of meanings. At least in the Merriam-Webster dictionary it does. I like the 3rd and 4th definitions found there:

  1. a region distinctively marked by some physical feature
  2. a sphere of knowledge, influence, or activity

If you think of a codebase as a “region,” the “distinctive marks” would be various abstractions. Most apps have a User. In e-commerce, we often see Cart and Promotion. You can’t have a blog without a Post.

“A sphere of knowledge, influence, or activity” is also very apt. It’s more self-explanatory: The codebase represents the sum of knowledge about the application’s purpose in the world, and how that purpose is achieved.

Domain-ness

I just made this up. 😄 “Domain-ness” is the degree to which a bit of code pertains directly to your problem domain.

Let’s say domain-ness is a binary scale. A 0 indicates that something is not at all bound to the particular problem domain. A domain-ness of 1 means that the code directly reflects the real-world business problem.

As you add code to your codebase, attempt to maximize domain-ness in almost every place you can

It’s easy to not to this. And a lot of conventional (armchair) wisdom suggests keeping domain-ness OUT of code as much as possible for “separation of concerns.” But I’ll attempt to demonstrate how that can be confusing and counterproductive.

A domain, just for you and me 💕

Let’s say our business domain is… boxes. Boxes of baked goods ™️.

Our box of baked goods can be described as a data structure with dataclasses.

from dataclasses import dataclass


@dataclass
class TastyTreat:
    name: str
    price: float
    est_volume: float  # in cubic cm


@dataclass
class BoxOfBakedGoods:
    # all numbers assumed to be in cm
    height: float
    width: float
    depth: float
    contents: list[TastyTreat]
    ordered_by_customer: int  # a unique identifier

BoxOfBakedGoods and TastyTreat have domain-ness of 1. The name gives it away. You might go so far as to say they are domain-y.

You can, however, calculate the volume of a box with no hint of the business domain:

def box_volume(*, length: float, width: float, depth: float) -> float:
    return lenght * width * depth

This implementation has a domain-ness of 0. Sure it’s box-related, but I don’t know anything about whether or not there may or may not be baked goods in there. It not domainy.

Primitives

“Primitives” in programming languages are the simplest elements available to coders. In Python, we generally refer to builtins like int, float, and set as “primitives.”

box_volume deals exclusively with primitives: Every part of the signature is a float. That’s part of what makes it non-domain-y.

Becoming domain-y

In practice, almost everything we do is in service of the problem domain.

In practice, non-domain-y constructs become effectively domain-y over time. For example, imagine if box_volume is only used in API routes.

@api.route("/box-fits")
def box_fits(request):
    box = BoxOfBakedGoods(**request.body["box"])
    space_available = box_volume(height=box.height, width=box.width, depth=box.depth)
    space_required = sum(t.est_volume for t in box.contents)

    return {"treatsDoFitInBox": space_available >= space_required}

Recall that “domain” is loosely synonymous with “region.”

In the little region of the universe occupied by this boxes-of-baked-goods app, it’s clear that volume is a box-of-baked-goods-thing. 🍞📦

Domainy primitives

The passage of time seeps the problem domain into everything.

Now, Box Of Baked Goods Inc ™️ offers centimeter-thick boxes to posh customers.

That will change the volume calculation, as those thick-walled boxes have less space in them. Since it’s the volume calculation that’s changing, it seems smart to change the code that concerns itself with that:

def box_volume(
    *,
    length: float,
    width: float,
    depth: float,
    wall_thickness: float = 0.0,
) -> float:
    length, width, depth = (x - wall_thickness for x in (length, width, depth))
    return length * width * depth

First off, we need to get the customer’s box preference. Thankfully, customer ID is already on BoxOfBakedGoods. It’s in scope, so we can simply get their preference by their ID!

def get_customer_prefers_thick(customer_id: int) -> bool:
    (preference,) = db.get("customer_preference.posh_box").filter(id=customer_id)
    return preference

The route changes as well:

@api.route("/box-fits")
def box_fits(request):
    box = BoxOfBakedGoods(**request.body["box"])
    use_thick_box = get_customer_prefers_thick(box.ordered_by_customer)
    wall_thickness = 2.0 if use_thick_box else 0.0
    space_available = box_volume(
        height=box.height,
        width=box.width,
        depth=box.depth,
        wall_thickness=wall_thickness,
    )
    space_required = sum(t.est_volume for t in box.contents)

    return {"treatsDoFitInBox": space_available >= space_required}

🚨 Misallocated Domainness Detected 🚨

Do you see it?

Before, explicit domain-ness was locked up in BoxOfBakedGoods and TastyTreat. This is fine––good, even––since objects, by definition, extend easily. As things about the problem-space change, we extend in a logical place.

Domain-ness did leak into box_volume() and it was fine. The primitives floating around were all logically agnostic from boxes of baked goods.

But now domain-y primitives have reared multiplied:

All of them are implicitly bound to the domain, but that binding is expressed pretty much exclusively in control flow.

Primitives do not extend.

We had to change a function signature’s arity to support box thickness, which is always spooky. (Yes, optional kwargs are not really the same thing as breaking a signature by adding an arg. But stay with me.)

We’re passing around bool and int to represent what is, in reality, information about our customers.

It was nice initially to have “separation of concerns” by calculating volume in a regular arithmetic-y way, but here’s the hard truth: Every single line of code you’ve written in your application is coupled to your problem domain. Loose coupling has nothing to do with pretending that sections of your codebase aren’t related to your problem.

Assume and embrace domain-ness

Let’s visit every place the domain was implied and make them explicit.

1. Identifiers

BoxOfBakedGoods.ordered_by_customer and customer_id in get_customer_prefers_thick() are primitives representing the same domain construct.

But the abstraction of a customer is missing. We need a Customer. And a PDS will do just fine!

@dataclass
class CustomerInfo:
    id: int

💡Passing around loose identifiers is a code smell and a trap. Don’t do it! You may define this class 200 times under different names, but that’s the good kind of WET long term.

Now put this everywhere it should go:

@dataclass
class BoxOfBakedGoods:
    height: float
    width: float
    depth: float
    contents: list[TastyTreat]
    ordered_by_customer: CustomerInfo
def get_customer_prefers_thick(customer: CustomerInfo) -> bool:
    ...

Now all customer-identifying stuff is wrapped in a domain object rather than a free-floating primitive. Yay!

2. Metadata

I used the word “info” on the new customer object. That’s a hint to its greater purpose: It’s a box to put 🥁 info about a customer in.

The only “info” we’re dealing with currently is their preference or non-preference for posh boxes.

There are many ways to do this, but for this simple example, I’ll just add it as an optional to the customer PDS

# optional property
@dataclass
class CustomerInfo:
    id: int
    prefers_posh_box: bool = False

Now we can take the last bit of domain-ness out of get_customer_prefers_thick:

I’ll change the name since this function now mutates, as a little mutation is good and nice sometimes as long as you obey exactly one rule.

apply_ is much more mutation-y than get_, I think.

def apply_customer_prefers_thick(customer: CustomerInfo) -> None:
    (preference,) = db.get("customer_preference.posh_box").filter(id=customer_id)
    customer.preference = preference

3. Logic

Time for the big pill: box_volume() was written in a tiny microcosm of arithmetic, isolated from the domain of boxes full of tasty treats.

But… wall_thickness is not an arithmetic thing. It’s a customer thing. And if we stop lying to ourselves, calculating box_volume in our domain is about BoxOfBakedGoods, not pure unadulterated numbers.

So, let’s refactor. We could go for a signature like this:

def box_volume(*, box: BoxOfBakedGoods) -> float: ...

But, you’ll notice the only arg is a BoxOfBakedGoods, and now that CustomerInfo is attached, we have all the context we need to put it right on the box! And that leaves the returned float hanging out as … a domain-y primitive, more-or-less unbound from the problem space.

@dataclass
class BoxOfBakedGoods:
    height: float
    width: float
    depth: float
    contents: list[TastyTreat]
    ordered_by_customer: CustomerInfo

    @property
    def volume(self) -> float:
        wall_thickness = 2.0 if box.ordered_by_customer.prefers_posh_box else 0.0
        length, width, depth = (
            x - wall_thickness for x in (box.length, box.width, box.depth)
        )
        return lenght * width * depth

And finally…

Change the route to use all our refactored code:

@api.route("/box-fits")
def box_fits(request):
    box = BoxOfBakedGoods(**request.body["box"])
    apply_customer_prefers_thick(box.ordered_by_customer)
    space_available = box.volume
    space_required = sum(t.est_volume for t in box.contents)

    return {"treatsDoFitInBox": space_available >= space_required}

Is this block of code better now? Meh. Maybe. “Thin views” is a solid axiom, and I do think this one is pretty thin and well-limited to the question of whether or not treats fit in boxes.

So what’s the point?

“Separation of concerns” is conventionally sacrosanct. Any impression that your concerns are not separated is enough to spark debate, contrition, and refactoring.

But it doesn’t always mean what you think at first blush.

If concepts are logically coupled in real life, couple them in your gosh darned code!

In the first cut of the (pseudo) code above, none of these true statements were obviously stated in the code:

With the refactor completed, more of the realities present in our world of boxes and baked goods are present in the code. That is good and nice and easier to think about.

Every example is contrived, of course. And there are many ways to fill a box with tasty treats. But no matter what, I love selling boxes with you, dear reader.