Tony Messias

October 10, 2021

On Delegated Types

After listening to a recently released interview with DHH in the Maintainable podcast where he shared some of his views on legacy software and how they do it at Basecamp, I realized may have gotten polymorphic associations wrong. Kinda.

I've used Polymorphic associations before as a way to apply multiple concerns to a group of entities. For instance, if I had multiple models that can receive reactions in my app, I would probably have the following setup:

class Post extends Model
{
  use HasReactions; 
}

class Comment extends Model
{
  use HasReactions;
}

trait HasReactions
{
  public function reactions()
  {
    return $this->morphMany(Reaction::class, 'record');
  }

  pubic function toggleReaction(User $user): void
  {
    // Toggles reaction...
  }
}

class Reaction extends Model
{
  public function record()
  {
    return $this->morphTo();
  }
}

Where the `Reaction` model would have a record relationship that would point to either a `Post` or a `Comment`, in this example. But it sounds like the Basecamp folks use polymorphic associations differently. Instead of having the `Reaction` being polymorphic, they would make the `Record` concept, which represents both `Post`s and `Comment`s in this example, a first-class citizen, not just a polymorphic relationship.

So, instead of the previous code, it would look something like this:

class Record extends Model
{
  public function record()
  {
    return $this->morphTo();
  }

  public function reactions()
  {
    return $this->hasMany(Reaction::class);
  }
}

class Post extends Model
{
  use Recordable;
}

class Comment extends Model
{
  use Recordable;
}

trait Recordable
{
  public function record()
  {
    return $this->morphOne(Record::class, 'recordable');
  }
}

Instead of having the concept of "having reactions" being spread through out the app, we would have it centralized in the top-level `Record` idea, which abstracts both `Post`s and `Comment`s.

The previous design would require us to define multiple controllers for the reactions behavior, one for each model that "has reactions", essentially. With this new version, we only have 1 controller for the behavior, because `Record`s have reactions, not its polymorphic children (`Post`s or `Comment`s). This reduces the amount of code quite a bit.

I've always struggled with having to add more controllers for concerns/behaviors like that. I'd have routes like:

POST /comments/{comment}/reactions
POST /posts/{post}/reactions

And now there would only be a single route:

POST /records/{record}/reactions

The `Recordable` trait/concern makes it a perfect place to put shared logic that’s designated to the delegated types, since all of them would use that trait (or, at least, should implement its contract).

I'm only speculating here, but they seem to use this idea at many places in both Basecamp and Hey. Basecamp has `Record` and/or `Recording`, which seem to use this pattern. Hey has `Topic` and `Entry` (which is mentioned in the Delegated Types Rails Guide, for instance).

Here are some other resources about this:

  • The PR introducing Delegated Types (link)
  • Rails Guide on Delegated Types (link)
  • "How to use Delegated Types in Rails 6.1" by Steven Buccini (link)

See you next time.

About Tony Messias