Delegated Types don’t work the way I thought they did
Scenario
We’re planning out some modelling where Users can create different types of Posts.
We have a regular Post, an Article and a Video.
Each of these Posts should be displayed in some sort of timeline.
Planning
We iterate through the planning process.
Iteration 1
Keeping everything separate is a good place to start.
erDiagram
Post {
uuid user_id FK
text content
}
Article {
uuid user_id FK
string title
text content
}
Video {
uuid user_id FK
string title
text content
string source_path
}
Displaying each Post on a Timeline starts to look like
user = current_user
timeline_items = []
timeline_items << user.posts
timeline_items << user.articles
timeline_items << user.videos
This is not a good way to get all of the posts. We’d probably move this into the User model, but what if we want to sort these posts from newest to oldest? Now we’re asking Ruby to do the sorting and at the medium1 scale we’re going to have performance issues.
Iteration 2
This one is tricky - we’d rather have a single Post table and have types of Posts.
As soon as a developer mentions “type of…” we default to Single Table Inheritance (STI).
With that in mind, we plan the following structure…
classDiagram
direction RL
class Post {
uuid user_id FK
string type "Rails-way of handling STI"
string title "nullable"
text content
string source_path "nullable"
}
Post <|-- Article
Post <|-- Video
This set-up helps with our Ruby code for sorting User posts for the Timeline.
user = current_user
timeline_items = user.posts
There are drawbacks to this implementation.
When we create a regular Post, the database row will have empty columns.
user_id | type | title | content | source_path |
---|---|---|---|---|
1234-122 | “Post” | null | “This is a post” | null |
Not a huge issue for now but again as the post types expand, we could have a row in the database with very little data.
Iteration 3
Delegated types - this allows us to have one table for all the shared data and individual tables for the types of Posts.
That looks more like
erDiagram
Post {
uuid user_id FK
text content
string postable_type
uuid postable_id
}
Article {
string title
}
Video {
string title
string source_path
}
In Rails the classes are set up like
class Post < ActiveRecord
delegated_type :postable, types: %w[Article Video]
end
class Article < ActiveRecord
has_one :post, as: :postable
end
class Video < ActiveRecord
has_one :post, as: :postable
end
And again our Timeline code looks sensible
user = current_user
timeline_items = user.posts
But now creating a new Article or Video is more complicated than it used to be.
It used to be simple like
article = Article.create(user: current_user, layout: post.liquid
title: "Some Title", content: "More content")
This won’t work with delegated types in place. We must create the Post, and an Article and then link them together.
article = Article.create(layout: post.liquid
title: "Some Title")
post = Post.create(user: current_user, content: "More content", postable: article)
Summary
This is where delegated types let us down. Creating a record becomes difficult, with the developer having to know which attributes live on each model.
Before, with STI, we could create a new Article easily by passing in all the arguments to one create method.
Now we have to consider wrapping this functionality in another model (PORO) and we have to consider failures in either create method - this is usually solved with transactions.
We would expect delegated types to behave the same as an STI implementation, but with the additional separation of data.
Possible solutions
Adding a method on the Post
class Post < ActiveRecord
delegated_type :postable, types: %w[Article Video]
def self.post!(user:, content:, postable:)
transaction do
postable.save!
Post.create!(user:, content:, postable:)
end
end
end
article = Article.create(layout: post.liquid
title: "Some Title")
post = Post.post!(user: current_user, content: "Some content", postable: article)
Accepts Nested Attributes
class Article < ActiveRecord
has_one :post, as: :postable
accepts_attributes_for :post
end
article = Article.create(
layout: post.liquid
title: "Some Title",
post_attributes: {
user: current_user,
content: "Some content"
}
)
Unfortunately, this approach does not work.
The create method on the Post is called first,
which causes a validation error as the postable
(Article) is not set and has not been saved.
-
I have no idea what medium scales is - 100+ posts 🤷 ↩