Making Rails Delegated_type's Clearer

4 months ago 5

This past week, I was having a conversation with a reader and he brought up the example of Rails’ delegated_type. While I’ve never been a fan of the name, because it doesn’t communicate well, this time I finally got what I dislike about its enforced structure.

Wait, what are these? delegated_types are essentially multi-table inheritance inspired by Django, but composed via delegation instead of inheritance.

Sometimes I’ve tried to explain them as an outer “metadata”-type that wraps an inner “content”-type. However, the outer type really carries all data/behavior common between inner types, and then inner types can differ in their data/behavior independently of each other. Bridging the gap is then where delegation and/or polymorphism come in.

Let’s start at the documentation example. Here I’ve added file paths and combined two samples from the docs:

class Entry < ApplicationRecord belongs_to :account belongs_to :creator delegated_type :entryable, types: %w[ Message Comment ] delegate :title, to: :entryable end module Entryable extend ActiveSupport::Concern included do has_one :entry, as: :entryable, touch: true end end class Message < ApplicationRecord include Entryable def title subject end end class Comment < ApplicationRecord include Entryable def title content.truncate(20) end end

Ok, so we’ve got an outer Entry we can query on and then inner content types like Message and Comment that can vary in their data/behavior. We could create some like this:

entries = Entry.create_with account: Account.first, creator: User.first entries.create! entryable: Message.new(subject: "hello", body: "yeah") entries.create! entryable: Comment.new(content: "first")

Domain Modeling issues with this structure

There’s a few things off with this and they’re not obvious from reading the code, particularly if they’re the first time you’re encountering delegated types.

You can never have an inner type without an outer
Message and Comment are not meant to stand alone, but the code doesn’t communicate this. Really, you’re supposed to only go through the Entry to reach the inner types and keeping them as top-level models obscures that.

That’s quite ironic, because that’s the entire fucking point of delegated types.

I’d want these as Entry::Message and Entry::Comment to help communicate our actual domain modeling.

If we try that, though? delegated_type will generate entry_ prefixed methods, like entry_comment and entry_message?, which don’t exactly read great.

One Quick fix: the documentation ought to say has_one :entry, as: :entryable, touch: true, required: true, to help alleviate this.

Enough with the -able naming, it sucks
More and more, I can’t stand -able suffixes and I see them as a sign we got our naming wrong.

Why are we involving a concern in a separate directory, at all?
To understand our Entry and all its delegates we also have to reach into the app/models/concerns directory, but why is that involved? This is only pertinent to Entry so why can’t it own something in its namespace?

And why are we hellbent on doing multiple inheritance via a module concern? We could just admit the inheritance for the inner types and it would be simpler.

These are delegates: so let’s be honest about that

First up, I’m establishing a naming convention that I can reuse across every delegated type: they have a delegate.

class Entry < ApplicationRecord delegated_type :delegate, types: %w[Message Comment], dependent: :destroy def title = delegate.title end

Second, we can be honest about the inner type inheritance and then define the parent Entry::Delegate:

class Entry::Delegate < ApplicationRecord self.abstract_class = true has_one :entry, as: :delegate, touch: true, required: true end class Message < Entry::Delegate def title = subject end class Comment < Entry::Delegate def title = content.truncate(20) end

Here the Entry namespace owns its definition of what its delegates are and we spare the needless ActiveSupport::Concern boilerplate.

Furthermore, if your app has multiple delegated_types they would now each have their own ::Delegate class within their namespace! So we’re naming less and enforcing a little more consistency to the delegated_type architectural pattern.

Oh and another thing! Since delegated_type wrap belongs_to polymorphic: true, which have a <>_type column, guess what that would be named now? delegate_type! I think that helps strengthen the consistency of the pattern and that’s pretty neat.

Baking in namespace support

When delegated_type generates methods for each passed type, like Message and Comment, we’re doing this:

types.each do |type| scope_name = type.tableize.tr("/", "_")

If we did, type.delete_suffix(name).delete_suffix(“::”), we’d automatically trim out an Entry:: namespace, so the generated method names are not prefixed.

With that, we could have:

class Entry::Message < Entry::Delegate def title = subject end class Entry::Comment < Entry::Delegate def title = content.truncate(20) end

And now the Entry helps own its delegates (which again, it already does). You can also look directly at the app/models/entry directory to locate the delegates versus currently needing to do a project search for include Entryable.

Finally: would a rename be clearer?

I keep wanting delegated_type :entryable, types: %w[ Message Comment ], dependent: :destroy to communicate more because whenever I encounter it I have to think about it a bit.

Really, all we’re doing is composing our Entry with that inner varying type, which we happen to do via delegation.

Could we emphasize that? Maybe like this:

class Entry < ApplicationRecord composed_with_delegate only: [Message, Comment], dependent: :destroy def title = delegate.title end

Look, I find it fun to stare at things for a long time and experiment with different words to see what comes out of it. Sometimes that means I have no idea if what I’ve done is clearer. lol lmao

What do you think?

Oh yeah, here’s what I think the implementation would be. Compare and contrast with the current one to your heart’s content.

def self.composed_with_delegate(scope = nil, only:, **options) belongs_to :delegate, scope, **options, polymorphic: true define_compositional_delegate_methods only, **options end def self.define_compositional_delegate_methods(types) define_singleton_method(:delegates) { types } define_method(:delegate_class) { delegate_type.constantize } define_method(:delegate_name) { delegate_class.model_name.singular.inquiry } types.map(&:to_s).each do |type| scope = type.delete_suffix(name).delete_suffix("::").tableize.tr("/", "_") singular = scope.singularize predicate = "#{singular}?" define_singleton_method(scope) { where(delegate_type: type) } define_method(singular) { delegate if public_send(predicate) } define_method("#{singular}_id") { delegate_id if public_send(predicate) } define_method(predicate) { delegate_type == type } end end

Bonus to our namespace support: defining render_in

The documentation mentions doing rendering like this:

# entries/_entry.html.erb <%= render "entries/entryables/#{entry.entryable_name}", entry: entry %> # entries/entryables/_message.html.erb <div class="message"> <div class="subject"><%= entry.message.subject %></div> <p><%= entry.message.body %></p> <i>Posted on <%= entry.created_at %> by <%= entry.creator.name %></i> </div> # entries/entryables/_comment.html.erb <div class="comment"> <%= entry.creator.name %> said: <%= entry.comment.content %> </div>

However, if our Entry defined render_in, we could do render entry and have it automatically render those _message or _comment partials:

class Entry < ApplicationRecord def render_in(view, &) view.render(delegate, &) end end

And relying on Entry::Message#to_partial_path to return entries/message, we’d render:

<%= render entry %><%# Would render one of these depending on the `delegate` type: %> <%# entries/_message.html.erb %> <div class="message"> <div class="subject"><%= message.subject %></div> <p><%= message.body %></p> <i>Posted on <%= message.entry.created_at %> by <%= message.entry.creator.name %></i> </div> <%# entries/_comment.html.erb %> <div class="comment"> <%= message.entry.creator.name %> said: <%= comment.content %> </div>

I’m incapable of writing a short post, enjoy!

Read Entire Article