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?
- 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 tokenGET /identities/login: fetch a single user based on the email/passGET /identities/refresh: fetch a single user based on the refresh jwt tokenPATCH /identities/users: update a single user attributes (non-associations)POST /identities/users/org_user_profile
- 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 type | description | example |
|---|---|---|
| Base serializer | minimal attributes, no associations | Identities::UserBaseSerializer |
| Detail serializer | includes related data the user owns | Identities::UserDetailSerializer |
| Admin serializer | includes everything including sensitive data | Identities::UserAdminSerializer |
| Embedded serializer | lightweight version for nesting | Identities::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