Skip to content

How to Design Scalable and Maintainable Serializers in ActiveModel Serializers? #2487

Open
@ukmshi

Description

@ukmshi

Hello! Thank you for creating such an amazing gem!

I’m currently facing an issue that I believe has been discussed in the past, but I couldn’t find any specific topics related to it. I’d like to ask for guidance on how others are addressing this challenge in the following use case.

Context

Model

erDiagram
    User ||--|{ Post : ""
    Post }|--|| Book : ""
Loading

Serializer

For models like the above, the serializers would typically look like this:

class UserSerializer < ActiveModelSerializers::Model
  attributes :id, :name, :post_count, :thumbnail

  has_many :posts, serializer: PostSerializer
end

class PostSerializer < ActiveModelSerializers::Model
  attributes :id, :comment, :rake

  belongs_to :user, serializer: UserSerializer
  belongs_to :book, serializer: BookSerializer
end

class BookSerializer < ActiveModelSerializers::Model
  attributes :id, :name, :avg_rate, :review_count, :book_image

  has_many :posts, serializer: PostSerializer
end

The Issue

The main challenge I’m facing is around the design and usage of serializers. Many articles I’ve seen tend to solve problems using a single, monolithic serializer. This approach often lacks clear separation of concerns and results in overly complex interdependencies. I’d like to know how others tackle the following issues:

Unnecessary Relationships in Different Use Cases

For example, in the /api/post/{:post_id} endpoint, I’d like the response to look like this:

{
    "id": 12345,
    "comment": "dummy text",
    "rate": 4.5,
    "user": {
        "id": 12345,
        "name": "jackson",
        "post_count": 15,
        "thumbnail": "http://xxxxxxxx.xxxxx/yyy"
    },
    "book": {
        "id": 435,
        "name": "The Little Prince",
        "avg_rate": 3.9,
        "review_count": 231,
        "book_image": "http://xxxxxxxx.xxxxx/xxx"
    }
}

However, for /api/book/{:book_id}, the response should look like this:

{
    "id": 435,
    "name": "The Little Prince",
    "avg_rate": 3.9,
    "review_count": 231,
    "book_image": "http://xxxxxxxx.xxxxx/xxx",
    "last_review": {
        "id": 12345,
        "comment": "dummy text",
        "rate": 4.5,
        "user": {
            "id": 12345,
            "name": "jackson",
            "post_count": 15,
            "thumbnail": "http://xxxxxxxx.xxxxx/yyy"
        }
    }
}

The problem becomes more apparent with endpoints like /api/book/{:book_id}/reviews or /api/book/{:book_id}/review/{:post_id}. In these cases, since the book is already identified by the endpoint, including the book details in every review unnecessarily duplicates data and adds noise to the response.

Sudden N+1 Query Issues

AMS often introduces N+1 query problems as new associations are added to models. For example, the /api/book/{:book_id}/reviews endpoint might be optimized like this:

class Api::Book::ReviewController < ActionController::Base
  def index
    book = Book.find_by(id: params[:book_id]).includes()
    raise NotFound if book.nil?

    @reviews = Review.where(book_id: book.id)
                     .includes(:user, :book)

    render json: @reviews, each_serializer: PostSerializer
  end
end

However, as the product grows, adding a new Author model and updating the serializer like this:

class PostSerializer < ActiveModelSerializers::Model
  attributes :id, :comment, :rate

  belongs_to :user, serializer: UserSerializer
  belongs_to :book, serializer: BookSerializer
  belongs_to :author, serializer: AuthorSerializer # new association
end

...suddenly introduces N+1 queries for Author in /api/book/{:book_id}/reviews. Since some legacy endpoints don’t use includes consistently, fixing this across the codebase can be challenging.

My Proposed Solution

One strength of AMS is its model-based approach. To maintain this advantage, I propose the following design:

class Base::PostSerializer < ActiveModelSerializers::Model
  attributes :id, :comment, :rate
end

class PostSerializer < Base::PostSerializer
  belongs_to :user, serializer: UserSerializer
  belongs_to :book, serializer: BookSerializer
end

class PostWithAuthorSerializer < Base::PostSerializer
  belongs_to :user, serializer: UserSerializer
  belongs_to :book, serializer: BookSerializer
  belongs_to :author, serializer: AuthorSerializer # specific to this use case
end

By defining a Base::PostSerializer for attributes and using separate serializers for specific use cases, we can minimize the impact on existing features. One key rule: never inherit from anything other than the base class to avoid complex, hard-to-manage hierarchies.

Final Question

How are you all addressing these kinds of design challenges with AMS? I’d love to hear your approaches and experiences. Thank you in advance!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions