Matthew

Deserializing legacy ActiveModel::Serializer backed APIs

September 8, 2025

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.

Setup

# 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:

  • We use an ActiveModel::Serializer configuraion that ember-data requires to sideload associations
  • That configuration normalizes included associations
    • e.g. having has_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 payload
async 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

Debugging

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:

  • it executes posts.find(id => id === 1)
    • Array.prototype.find() returns the first element that satisfies the predicate
  • posts.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

association tree

The first post in the array was serialized by CommentSerializer::PostSerializer and the second was serialized by UserSerializer::PostSerializer.

How does de-duplication work?

Before looking at the source code, I played around with the serializers and found:

  • Adding :description to CommentSerializer::PostSerializer de-dupes the post in the payload
  • Adding :description to CommentSerializer::PostSerializer but overriding the value to something different, e.g. def description = nil, did not de-dupe the post in the payload

Seemed like de-duping was based on exact value matching, not just by comparing the id or keys.

Makes sense.

Quick fix

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.

Conclusion

I recommended applying the fix above to all of our ActiveModel::Serializer frontend deserializers, irrespective of a root key having multiple serializers, because:

  • We use ember-data in our Ember app, which essentially does the same thing (context: we're currently converting pages from Ember + JS to React + TS)
  • Applying it to all association deserialization avoids having to investigate the association tree to determine which root keys have multple serializers
  • Quick and easy implementation

And that fix was fine with the team.

Alternative solutions considered

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.

Following up: Determining impact (before the fix)

Reactive

Collect frontend errors and user reports in production.

Proactive

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:

  • Iterate through all frontend API calls that use ActiveModel::Serializer backed endpoints
  • Collect the locations of API calls that use serializers with root keys with multiple serializers

Manually 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:

  • manual code investigation
  • monkeypatch associations and collect all the class names of the objects that are serialized under that association at runtime
    • then running the test suite or interacting with APIs locally and streaming that output to a file

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.