Skip to main content

Single-direction serializers

Associations should only flow in a single direction.

The "starting/entry-point" is the main model the endpoint is returning.

From there, the direction of associations defined by the serializer should only move in a single direction.

Example

To refresh your memory, we have the following models and their associations:

What's the problem with the following response for GET /identities/users?

{
"id": 11,
"first_name": "ali",
"last_name": "aljunied",
"email": "ali+dev.sg.3@jodapp.com",
"phone_code": "62",
"mobile": "82124555262",
"country_id": 102,
"is_email_verified": true,
"email_verified_at": "2025-10-27T06:46:26.000Z",
"otp_expired_at": null,
"address_geo_area": {
"id": 213,
"name": "Pasir Ris Drive",
"country_code": "SG",
"admin_level": 3,
"admin_kind": "subzone",
"code": "PRSZ05",
"slug": "pasir_ris_drive",
"parent_id": 32,
"path": "sg.east_region.pasir_ris.pasir_ris_drive",
"is_leaf": true,
"timezone": "Asia/Singapore",
"full_name": null
},
"careers_user_profile": {
"id": 4,
"is_visible_to_employers": false,
"is_ready_to_work": false,
"is_profile_setup_completed": false,
"preferred_employment_type": "full_time",
"salary_expectations": 0,
"salary_expectations_pay_type": "monthly",
"created_at": "2025-10-27T06:46:51.505Z",
"updated_at": "2025-10-27T06:46:51.505Z",
"user": {
"id": 11,
"first_name": "ali",
"last_name": "aljunied",
"email": "ali+dev.sg.3@jodapp.com",
"phone_code": "62",
"mobile": "82124555262",
"country_id": 102,
"is_email_verified": true,
"email_verified_at": "2025-10-27T06:46:26.000Z",
"otp_expired_at": null,
"address_geo_area": {
"id": 213,
"name": "Pasir Ris Drive",
"country_code": "SG",
"admin_level": 3,
"admin_kind": "subzone",
"code": "PRSZ05",
"slug": "pasir_ris_drive",
"parent_id": 32,
"path": "sg.east_region.pasir_ris.pasir_ris_drive",
"is_leaf": true,
"timezone": "Asia/Singapore",
"full_name": null
}
},
"cv_file": null,
"experiences": [],
"certificates": [],
"skills": [],
"educations": []
},
"org_user_profile": null
}

Identify the context of the endpoint.

We will use GET /identities/user as an example

What serializer is GET /identities/user using?

  • Identities::UserSerializer

What's the associations defined in Identities::UserSerializer

    has_one(
:address_geo_area,
serializer: Geo::AreaSerializer
)

has_one(
:careers_user_profile,
serializer: Careers::UserProfileSerializer
)

has_one(
:org_user_profile,
serializer: Org::UserProfileSerializer
)

How do we imagine it in a diagram?

Go through each serializer that Identities::UserSerializer is using to render the associations.

Notice that Career::UserProfileSerializer is pointing back to Identities::UserSerializer?

This is because we telling Careers::UserProfileSerializer that Careers::UserProfile has an association to Identities::User

# app/domains/careers/user_profile_serializer.rb

has_one(
:user,
serializer: Identities::UserSerializer,
)

What's the process for designing serializers?

Serializers are views for our data. The way our data is used depends on the context/use-case.

Serializers answer one critical question

  • "What data does THIS endpoint need to return for THIS specific use case?"

What we are thinking "What associations does this model have?"

Instead, you should ask: "What does the client actually need in this response?"

  • Does the consumer of the data (i.e. frontend) require to render the model's associations or just the attributes?
  • Is it necessary to render the deeply nested associations for that specific use-case?
  1. Define the use-cases/context for returning a model (e.g Identities::User)

Endpoints:

  • GET /identities/users: fetch a single user based on the jwt token
  • GET /identities/login: fetch a single user based on the email/pass
  • GET /identities/refresh: fetch a single user based on the refresh jwt token
  • PATCH /identities/users: update a single user attributes (non-associations)
  • POST /identities/users/org_user_profile
  1. For each endpoint (i.e. use-case) determine what attributes are needed
  • In this example, we most definitely need all the attributes (non-associations)
  • Associations would be needed on a specific use-cases, depending on how the endpoint is used in the frontend.

General kind of serializers

serializer typedescriptionexample
Base serializerminimal attributes, no associationsIdentities::UserBaseSerializer
Detail serializerincludes related data the user ownsIdentities::UserDetailSerializer
Admin serializerincludes everything including sensitive dataIdentities::UserAdminSerializer
Embedded serializerlightweight version for nestingIdentities::UserEmbeddedSerializer

Naming Format {Some::Model}{Context}Serializer

Naming Format Example Using Identities::UserBaseSerializer as an example:

  • Some::Model: Identities::User
  • Context: Base

Why do we have different serializers for the same model?

  • It's much easier to have different serializer classes than to use run time filtering with panko
  • If a model has deeply nested associations (just like Identities::User), your endpoint will end up being slow
    • Bullet gem will complain about your query either not eager-loading or including associations
    • You will waste time figuring out the difference between includes and eager-loads