What could go wrong with rolling your own deserializer?
The gist: Why is user.posts[0].description
undefined when we're sure we included description
in our serializer?
This is a simplified example of an issue I tackled at work.
# app/controllers/users_controller.rb
class UsersController < ActionController::Base
def show
@user = User.find(params[:id])
render json: @user, serializer: UserSerializer
end
end
# app/serializers/user_serializer.rb
class UserSerializer < ActiveModel::Serializer
root :user
attributes :id, :name
class PostSerializer < ActiveModel::Serializer
attributes :id, :title, :description
end
has_many :posts, serializer: PostSerializer
has_many :comments, serializer: CommentSerializer
end
// User.first.posts.first.to_json
{
"id": 1,
"title": "Deserializing legacy ActiveModel::Serializer backed APIs",
"description": "What could go wrong with rolling your own deserializer?",
"user_id": 1,
"created_at": "2025-09-08T03:51:00.032Z",
"updated_at": "2025-09-08T03:51:00.032Z"
}
Context for the deserializer code snippet below:
ActiveModel::Serializer
configuraion that ember-data requires to sideload associationshas_many :comments
in UserSerializer
will add a comment_ids
attribute to serialized users and include the associated comments in the comments
top level key in the payloadasync function getUser(id: number) {
const response = await fetch(`http://localhost:3000/users/${id}`);
const payload = await response.json();
const { user, posts, comments } = payload;
return {
...user,
posts: user.post_ids.map((id) => posts.find((post) => post.id === id)),
comments: user.comment_ids.map((id) =>
comments.find((comment) => comment.id === id),
),
};
}
const user = await getUser(1);
const [firstPost] = user.posts;
console.log(firstPost.title); // Deserializing legacy ActiveModel::Serializer backed APIs
console.log(firstPost.description); // undefined
Step one: take a look at the payload
.
{
user: {
id: 1,
name: 'Cyril',
post_ids: [1],
comment_ids: [1]
},
posts: [
{
id: 1,
title: 'Deserializing legacy ActiveModel::Serializer backed APIs',
},
{
id: 1,
title: 'Deserializing legacy ActiveModel::Serializer backed APIs',
description: 'What could go wrong with rolling your own deserializer?',
}
],
comments: [{ id: 1, text: 'tl;dr not much?', post_id: 1 }],
}
Unexpectedly, posts
had two instances of a post with an ID of 1.
Looking back at our deserializer:
posts.find(id => id === 1)
Array.prototype.find()
returns the first element that satisfies the predicateposts.find(id => id === 1)
returns the post found earlier in the array, hence undefined description
Fortunately, the one without description
was first. Unfortunate for the feature I was implementing, but fortunate to find the bug before going to production!
But more concerning, this find
pattern to deserialize associations has been used across several frontend models in production for over a year, why is there an issue now?
Next step: Investigate why were there two instances with the same ID.
I read the docs for the version of ActiveModel::Serializer
we're using. It mentioned the benefits of de-duplication when sideloading associations, but not the mechanism.
When docs aren't enough to get an answer, I'll generally go through GitHub issues, source code, etc. I've also found it useful to clone the library's repo and ask some LLM assistant (e.g. Cursor, Claude Code) where some process is happening. Still hit or miss but more useful than not.
Anyway, I didn't do that this time because my intuition said we likely had another serializer for Post
in the association tree.
Traversing the association tree of UserSerializer
showed that to be the case.
# app/serializers/user_serializer.rb
class UserSerializer < ActiveModel::Serializer
root :user
attributes :id, :name
class PostSerializer < ActiveModel::Serializer
attributes :id, :title, :description
end
has_many :comments, serializer: CommentSerializer
has_many :posts, serializer: PostSerializer
end
# app/serializers/comment_serializer.rb
class CommentSerializer < ActiveModel::Serializer
class PostSerializer < ActiveModel::Serializer
attributes :id, :title
end
attributes :id, :text
has_one :post, serializer: PostSerializer
end
The first post in the array was serialized by CommentSerializer::PostSerializer
and the second was serialized by UserSerializer::PostSerializer
.
Before looking at the source code, I played around with the serializers and found:
:description
to CommentSerializer::PostSerializer
de-dupes the post in the payload:description
to CommentSerializer::PostSerializer
but overriding the value to something different, e.g. def description = nil
, did not de-dupe the post in the payloadSeemed like de-duping was based on exact value matching, not just by comparing the id
or keys.
Makes sense.
In this case, merging the posts with the same ID is a safe operation. The same keys will have the same values.
user.posts = user.post_ids.map((id) =>
posts
.filter((post) => post.id === id)
.reduce((acc, post) => ({ ...acc, ...post })),
);
Now, description
will be present.
The TypeScript type of Post
should also be updated to have an optional description
property because it isn't present in all serializers.
In an ideal world, we have a type for each serializer and use those accordingly for each relationship and endpoint instead of a single type for all serializer variations of a backend resource, but that's the subject of another refactor. We currently have a mix of those two patterns.
I recommended applying the fix above to all of our ActiveModel::Serializer
frontend deserializers, irrespective of a root key having multiple serializers, because:
And that fix was fine with the team.
Backend: Long term
We are in the process of transitioning all of our APIs to use JSONAPI::Resource
.
Ultimately, this means we'll no longer have have a need to deserialize ActiveModel::Serializer
backed APIs.
But we have 100+ ActiveModel
serializers. And for another point of reference, the API I debugged had 35+ distinct included associations.
It would not be reasonable to wait for the full conversion.
Backend: Short term
Could we go through every serializer and align the serializers of all associations in the association tree?
This sounded like way more work than the quick frontend fix, so I didn't explore it.
Frontend: Standardization
Ideally, I think we should have a library that standardizes our deserialization logic and reduces imperative boilerplate.
One option is ember-data, which we know solves the original issue and obviates the imperative boilerplate to parse attributes and associations. We could investigate using it in our React + TS app.
Aside from the developer experience being nicer, a declarative API like ember-data precludes developer error of accidentally using the find
method.
I threw this out as an option to the team, but not the recommendation because it would be a nontrivial refactor compared to the quick fix. Plus, the backend will ultimately stop using ActiveModel
serializers.
Collect frontend errors and user reports in production.
It's important to note that this issue can sneak up on us in production via record level changes in the database.
Meaning, despite zero backend or frontend code changes for N years, zero frontend errors in production in that time span does not imply the page won't have the issue in the future.
For example, using the scenario above, it could be a year before Cyril adds a comment to his own post, and that additional comment causes the duplicate post error to be exhibited for the first time in production.
So how do we find these issues before they occur? One approach:
ActiveModel::Serializer
backed endpointsManually determining which serializers have root keys with multiple serializers in a codebase with 100+ serializers, one of which that has at least 30 associations, would be a real time sink.
So, I wrote a script to collect the distinct serializers for all the root keys in the association tree of a serializer.
# Depth-first search to collect distinct serializers for each root key.
# Cycle detection is unnecessary.
# If there is a cycle, active_model_serializers will crash during serialization
def payload_type_for(serializer)
root_key_type = Hash.new { |h, k| h[k] = Set.new }
root_key_type[serializer.root_name] = [serializer]
set_associations = lambda do |node|
node._associations.values.each do |child|
serializer = serializer_for(child)
root_key_type[child.root_key].add(serializer)
set_associations.call(serializer)
end
end
set_associations.call(serializer)
root_key_type.transform_values(&:to_a)
end
def serializer_for(association)
association.serializer_from_options
end
payload_type_for(UserSerializer)
# =>
{
"user"=>[UserSerializer],
"comments"=>[CommentSerializer],
"posts"=>[CommentSerializer::PostSerializer, UserSerializer::PostSerializer]
}
Disclaimer: This isn't the full script. #serializer_from_options
is only present when the serializer
option is set in the association definitions.
If the serializer
option isn't set, then the serializer is determined at runtime, using the class name of the instance being serialized. Example:
ActiveModel::Serializer.serializer_for(Comment.first)
# =>
CommentSerializer
# useful for, e.g.
class User < ActiveRecord::Base
has_many :active_comments, -> { active }, class_name: 'Comment'
end
class UserSerializer < ActiveModel::Serializer
has_many :active_comments
end
We can gain a lot of mileage out of assuming the root key matches the serializer name:
def serializer_for(association)
serializer = association.serializer_from_options
serializer || "#{association.root_key.classify}Serializer".constantize rescue nil
end
And in cases where this doesn't work, we have options:
Note: if there isn't an existing serializer then an instance will be serialized via object.as_json
.
Now, to find all the concerning serializers:
Rails.application.eager_load!
ActiveModel::Serializer.descendants.filter_map do |serializer|
multi_rkt = payload_type_for(serializer).filter { |key, types| types.count > 1 }
[serializer, multi_rkt] if multi_rkt.present?
end
We're now equipped to go through the frontend usages and determine potential impact.