As I’ve previously mentioned, I’m building a brand-new business from scratch on top of Rails. Making 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
Profiledirectly so I could do things like
user.profile. To set up this type of relationship, I would have to include an association for each subclass to connect the
Usermodel with each
- Polymorphism—the most “Rails-y” way of solving the problem. Essentially, you have one table, but that table has a
typefield 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
Delegated types with simple_form and form_for
Profiles are only useful if you can fill them out! And I picked Rails becuase 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
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,
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,
belongs_to :user, not
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.