Noel Rappin Writes Here

Object Constellations

Posted on November 20, 2024


Last time, I talked about ways to use dynamic typing to manage objects and business logic in your code. Doing so involves leaning into the object system, going beyond just “one class for each noun” and creating objects to model different states within the business logic directly.

In a basic Object-Oriented design, you might have an object called User. This object, by itself, represents the entire concept of a user within the system. In this design, specific states of a user — admin, unauthorized, deleted, subscriber, what have you — are all represented by the single class User.

That’s one way to model users. But you could also have the User class be a home for the underlying data and manage some or all of the state of a user by creating wrapper classes like AdminUser, UnauthorizedUser, DeletedUser… even NullUser. At this point, the idea of a “user” is now spread among multiple classes. I’ve started calling this an “object constellation”, feel free to call it whatever you want.

Why the Constellation Works

Ruby, like many object oriented languages, is “polymorphic”. In Ruby terms, “polymorphic” means that result of a method call, like user.access_allowed?, is resolved by the class of user at run-time, and that user could be of any class that implements access_allowed?.

As a result, you can kind of model Ruby’s message passing as a huge if or case statement:

In pseudo-code, when you call receiver.foo, you can model what Ruby does like this:

case receiver
when Integer then call Integer#foo
when String then call String#foo
... and so on

(Ruby’s internals look different, it’s closer to a hash look up than a case statement, the point is that you can model message passing as the result of a case statement.)

This model of method passing is why you typically are advised not to have case statements where the clauses are classes – you’re advised to just dispatch the call polymorphically to the correct method… because the case statement and the dispatch are essentially the same thing. (In Ruby, you might sometimes use the case statement to avoid monkey patching core classes…)

The flip side is that you can also model conditional logic in the case system, and dispatch calls the same way.

case receiver
when nil then call NillUser#foo
when admin? then call AdminUser#foo
... and so on..

This suggests that if we can map the clauses of this case statement to classes that have identically named methods, we can map the entire conditional to a polymorphic method call. When we do this, we use the object system to hold onto conditional state.

Null Objects

A concrete example involves calls to nil — in this case your constellation consists of two classes: the “real” class and the null class.

You may have a lot of code that looks like this:

if company.nil?
  do_something # error related
else
  do_something # real object related
end

If we think about each branch of that if statement mapping to a different class, that would mean we have two classes. Our regular Company, and then a… well, a NullCompany. These types are related, in that they should have the same API, but one of them has real business logic, and the other is basically an empty shell.

There are a bunch of ways to accomplish having these two classes in Ruby. One way is to have a factory class that replicates the conditional logic and returns one class or the other. Then put a method in each class – with the same name – with the corresponding logic:

class Company
  def self.maybe(company_or_nil)
    company_or_nil.nil? ? NullCompany.new : company_or_nil
  end

  def do_something
    # doing something
  end
end

class NullCompany
  def do_something = log("error")
end

We’ve ow replaced the conditional:

if company.nil?
  log("error")
else
  do_something
end

with a polymorphic method call…

Company.maybe(company).do_something

Okay, I’ve replaced a simple conditional with some object-oriented hand-waving. Why?

Reasons in favor:

  • The through line of the logic in original method is clearer, (I mean, not in this trivial example, but in a real problem) this is especially true if there are a lot of different branches to the conditional.
  • It’s easier to test, especially easier to test the null object behavior in isolation.
  • If this check is done repeatedly, it ensures consistency in how the check is handled. This is especially valuable for complex status checks. (I find this to be an underrated benefit — there’s a strong tendency to make the checks more thorough if you are only typing them once…)
  • These small classes have a way of accumulating behavior in useful ways.

Reasons against:

  • The logic is now more distributed, if you want to see what happens on the error condition, you have to seek out the NullUser class. You have to get used to it. (I will say that as editor support has improved, this gets easier).
  • This works interestingly with static typing tools, since you now have a lot of variables that are, say, the union type of User || NullUser. I guess, though, you can then specify User or NullUser to get interesting type safety.
  • There’s some Ruby-specific sort of weirdness with null objects and logical truth and falsity. In Ruby, the only things that are logically false are nil and false, meaning that nil is logically false, but User.maybe(nil) returning a NilUser instance isn’t logically false, which can lead to subtle bugs. This is a manageable problem, but you have to pick a way to manage it. (For instance, you can define a nillish? method and use that for your checks.)

Object and Shadow Object

In the Null Object pattern you have a constellation of two classes: the “full-featured” class, like our Company, and then a parallel “shadow” classes. The shadow class, like NullCompany, represents a genuine logical state in the system, but one in which you are representing the absence of something or, more generally, an incomplete form of something. Specifically, the shadow class typically does not need to access the data that the full-featured class does.

The shadow object has the same API as the original object but has custom business logic that manages the lack of data.

Once you start looking for this pattern, it’s all over…

  • UnauthenticatedUser — if you have logic that allows logged in users and non-logged in users, having the default current user be of class UnauthentcatedUser lets you easily manage logic for users that haven’t created accounts. You can even tie a cookie to a specific instance of UnauthenticatedUser and allow that user to have at least some of the features of your app.
  • NonexistentFile — perhaps this one auto-creates the file before attempting to write to it, or maybe it just returns an empty string before you try to read from it.
  • DeletedUser — my user was “friends” with another user who has been archived, this class lets me treat all those users together in one loop. There could even be multiple shadow objects here, BlockedUser, RenamedUser, etc, all of which contain the edge case logic needed.

Almost any Rails belongs_to relationship has a plausible case for a shadow object to model the case where the other side of the object doesn’t exist or doesn’t exist yet. (The null_associations gem does this automatically, but I’m not sure if it’s current.)

And the shadow object can have real logic — I’m doing a project that describes gem dependencies across multiple apps and gems by parsing Gemfile.lock. Not every gem in our ecosystem puts a lockfile in the repository, so I have ExplicitLockfile and ImplicitLockfile. The ImplicitLockfile is a shadow object which attempts to fake the dependency tree information by inferring it from the .gemspec. It makes the code much clearer.

Superset Objects

There are other constellation patterns that don’t depend on shadow objects.

For example there could be a data class that is the center of the constellation with multiple classes in the constellation that wrap the data class and therefore have access to the underlying data. Typically, this pattern happens when each wrapper class represents a different state or condition of the underlying data.

For example, you might have a User class that might have some role within the system. You could set that up as a constellation of classes:

class User
  def as_role
    return AdminUser.new(self) if user.admin?
    return TeamLeadUser.new(self) if user.team_lead?
    OrdinaryUser.new(self)
  end
end

class UserInRole
  def edit(item, key, new_value)
    return unless can_edit?(item)
    item.update(key, new_value)
  end

  def edit_button(item)
    return unless can_edit?(item)
    # HTML to draw a button
  end
end

class AdminUser < UserInRole
  def initialize(user)
    @user = user
  end

  def can_edit?(item) = true
end

class TeamLeadUser < UserInRole
  def initialize(user)
    @user = user
  end

  def can_edit?(item) = (item.team == user.team)
end

class OrdinaryUser < UserInRole
  def initialize(user)
    @user = user
  end

  def can_edit?(item) = false
end

Then any time you need to do something with a user that depends on their role, rather than repeating the cascade of ifs or whatever, you can just do something like this:

user.as_role.edit

The edit method calls can_edit? meaning that the actual behavior of edit is dependent on the role of the user.

This code prevents repeating that if/case logic to determine the type of user by allowing downstream logic to call user.as_role to determine the user role, and then edit to actually do the edit if it’s authorized.

The critical point here is that while the role objects have access to the user data, methods like edit are only available on the role object, so you must use as_role to check the role logic before trying

So…

In the name of keeping this one under 2500 words, I’m going to stop here. You should try this technique (granted that this is over simplifying, and, oh, I’m probably going to need to post a more complete example).

  1. Find a conditional logic that you use more than once in your app. Maybe it’s a nil check, maybe it’s a big case statement.
  2. Copy that conditional to a factory method that returns a different class for each branch of the conditional.
  3. Create the new classes. They may need to be delegators or have constructors that connect back to the original data class.
  4. Move the logic from each branch into a method of the corresponding class, give the methods the same name.
  5. Replace the original conditional with a call to the factory method and a call to the new method name.
  6. Find other examples of the same conditional logic and repeat steps 4 and 5.

Be careful with nil? and implicit boolean checks.

Try it once, see if you like it.



Comments

comments powered by Disqus



Copyright 2024 Noel Rappin

All opinions and thoughts expressed or shared in this article or post are my own and are independent of and should not be attributed to my current employer, Chime Financial, Inc., or its subsidiaries.