How to use delegated types in Rails 6.1
As I’ve previously mentioned, I’m building a brand-new business from scratch on top of Rails. Reversing key architectural decisions is like getting a bad tattoo lasered off—it takes time, it’s expensive, and your bad decision will never truly fade into oblivion. With that in mind, I’ve been overcautious in doing my research when putting new architectural patterns into place, which led me to a cool solve while researching solutions to my most recent problem.
My app has a common concept of Users
which can take on many different Roles
. Each user can have a profile, but the information contained in the profile differs based on the user’s role. In object-oriented programming, the design is clear: we would have a BaseProfile
with common fields and methods and subclasses for our specific types. Unfortunately, there’s no perfect way to translate this schema onto a database. As of now, Rails has three main ways to solve this problem:
- Single-table inheritance (STI)—all fields for all profile types are stored in one database table. If a field is not needed for a specific profile type, its value will be
nil
. This can be an issue if each type of profile has lots of different fields, because the table will be sparsely filled and a lot of space (and therefore, efficiency) is wasted. - Multi-table inheritance (MTI)—in this strategy, each type of profile is stored in its own table. The problem here is that I wanted to connect a
User
with theirProfile
directly so I could do things likeuser.profile
. To set up this type of relationship, I would have to include an association for each subclass to connect theUser
model with eachProfile
table. - Polymorphism—the most “Rails-y” way of solving the problem. Essentially, you have one table, but that table has a
type
field that tells the record how to act.
All of these solutions have benefits and drawbacks, all of which have already been covered by folks much smarter than me. But I do have one advantage—I’m starting from scratch, so I can use the latest tech.
And lo and behold, just last month, DHH (the creator and BDFL of Rails) put up a PR for a new solution called “delegated type”. It’s definitely worth clicking through to read DHH’s description of the problem he’s trying to solve. I won’t go into them here, but I did want to outline some of the problems I had getting this to work as there’s not a lot of documentation out there.
Note that delegated types are coming in Rails 6.1. As DHH, the PR is just syntactic sugar on top of polymorphic associations, but I’m too lazy (okay, fine, I’m not smart enough) to do this on my own, so I just pointed Rails at master
in my Gemfile
.
Delegated types with simple_form and form_for
Profiles are only useful if you can fill them out! And I picked Rails because it’s dead simple to create a model and generate a corresponding form. This is made slightly more difficult by our use of delegated types. Rails includes a method called accepts_nested_attributes_for
that allows a model to accept attributes on behalf of another model type that it is associated with. The name of this helper method led me to believe that Profile
should accepts_nested_attributes_for
SubProfile
. But users are never filling out forms for BaseProfile
. They only care about the specific type of Profile
for their User
type. Therefore, counterintuitively, SubProfile
accepts_nested_attributes_for
Profile
.
module Profileable
extend ActiveSupport::Concern
included do
has_one :profile, as: :profileable, touch: true, dependent: :destroy
accepts_nested_attributes_for :profile
end
end
class Profile < ApplicationRecord
belongs_to :user
delegated_type :profileable, types: %w[ author editor reader]
end
class Author < ApplicationRecord
include Profileable
end
Integrating Pundit is dead simple
Since delegated types actually gives a defined superclass backed by its own table, we can tie permissions and associations to the superclass and the rest of the subclasses will inherit the appropriate information. You can see this in the example above, Profile
belongs_to :user
, not Author
.
Since in our case, we want to treat all user profiles the same in terms of visibility and permissions, we don’t need to write Pundit
policies for each type of Profile
. Instead, we can write a single ProfilePolicy
and tell our different types of profiles to use that as their policy:
class Author < ApplicationRecord
include Profileable
def policy_class
ProfilePolicy
end
end
The documentation is currently wrong for creating new delegated types
DHH says that in his example, a new record can be created via the following:
Entry.create! message: Comment.new(content: "Hello!"), creator: Current.user
Trying this format with my own implementation-specific code yields an error similar to:
ActiveModel::UnknownAttributeError (unknown attribute 'message' for Profile.)
But further down the thread, he says the correct incantation is:
Entry.create! entryable: Message.new(subject: "hello!"), creator: Current.user
And indeed, using entryable
instead worked for me. This issue has already been fixed in the code documentation, robbing me of a golden opportunity to contribute to Rails core and, by extension, legitimizing my rockstar programming status.
Let me know if you have further questions about delegated types! I’m sure I missed a few gotchas that I’ve since forgotten and will update this blog post as they trickle back in.