Object Constellations
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 a method call, like user.access_allowed?
, could be handled by any object that happens to respond to the method and is assigned to the variable user
.
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:
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 specifyUser
orNullUser
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
andfalse
, meaning thatnil
is logically false, butUser.maybe(nil)
returning aNilUser
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 anillish?
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 classUnauthentcatedUser
lets you easily manage logic for users that haven’t created accounts. You can even tie a cookie to a specific instance ofUnauthenticatedUser
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).
- Find a conditional logic that you use more than once in your app. Maybe it's a
nil
check, maybe it's a bigcase
statement. - Copy that conditional to a factory method that returns a different class for each branch of the conditional.
- Create the new classes. They may need to be delegators or have constructors that connect back to the original data class.
- Move the logic from each branch into a method of the corresponding class, give the methods the same name.
- Replace the original conditional with a call to the factory method and a call to the new method name.
- 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.
Dynamic Ruby is brought to you by Noel Rappin.
Comments and archive at noelrappin.com, or contact me at noelrap@ruby.social on Mastodon or @noelrappin.com on Bluesky.
To support this newsletter, subscribe by following one of these two links: