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. A child serializer must never embed its parent — the response would contain itself.
Example
To refresh your memory, we have the following models and their associations:
(There is no separate gig profile model. Gig worker data lives as gig_* columns on Talent::Profile.)
What's the problem with the following response for the current-user endpoint?
{
"id": 11,
"first_name": "ali",
"email": "ali+dev.sg.3@jodapp.com",
"address_geo_area": { "id": 213, "name": "Pasir Ris Drive", "timezone": "Asia/Singapore" },
"talent_profile": {
"id": 4,
"is_visible_to_employers": false,
"preferred_employment_type": "full_time",
"user": {
"id": 11,
"first_name": "ali",
"email": "ali+dev.sg.3@jodapp.com",
"address_geo_area": { "id": 213, "name": "Pasir Ris Drive", "timezone": "Asia/Singapore" }
},
"experiences": [],
"certificates": [],
"skills": [],
"educations": []
},
"org_membership": null
}
The response contains itself: the user embeds the profile, and the profile embeds the user again. Every byte inside "user" is a duplicate the client already has, and if the profile serializer pointed at the full user serializer, the loop could go deeper.
We shipped exactly this bug in an earlier version. The profile serializer declared has_one :user pointing back at the full user serializer. It has since been fixed — today's chain flows one direction only. The example stays here because it is the clearest picture of what the rule prevents.
Identify the context of the endpoint
We will use GET /identities/users/current as the example.
What serializer does it use? Identities::UserDetailSerializer (app/controllers/identities/users_controller.rb):
# app/domains/identities/user_detail_serializer.rb
has_one(
:address_geo_area,
serializer: Geo::AreaSerializer
)
has_one(
:talent_profile,
serializer: Talent::CandidatesProfileDetailSerializer
)
has_one(
:org_membership,
serializer: Org::MembershipBaseSerializer
)
How do we imagine it in a diagram?
Now go one level deeper — every serializer the profile serializer uses:
Every arrow points away from the entry point. No serializer in this chain points back at Identities::UserDetailSerializer — that back-edge is the bug we removed.
When you DO need a user inside a profile payload (a different entry point), embed a small, association-free serializer. Real example: Talent::EmployersProfileDetailSerializer embeds Identities::EmployersUserSerializer, which adds only address_geo_area and no profile back-reference — the direction stays safe.
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?"
Do not ask "what associations does this model have?" Ask instead: "what does the client actually need in this response?"
- Does the consumer (the frontend) need the model's associations, or just the attributes?
- Is a deeply nested association really needed for this use-case?
- Define the use-cases/context for returning a model (e.g.
Identities::User):
GET /identities/users/current— fetch the current user from the session cookiePOST /identities/user_sessions— login; answers the session document, the same JSON asGET /identities/user_sessions/currentPATCH /identities/users/current— update the user's own attributesPOST /employers/org/memberships— create the employer role for a user
- 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 are needed only for specific use-cases, depending on how the endpoint is used in the frontend.
General kinds of serializers
This table is the convention to follow. Three of the four exist today; the team-portal one is planned but not built yet (there is no team-portal user listing).
| serializer type | description | example |
|---|---|---|
| Base serializer | minimal attributes, no associations | Identities::UserBaseSerializer |
| Detail serializer | includes related data the user owns | Identities::UserDetailSerializer |
| Team serializer | staff-facing; may include sensitive data | Identities::TeamUserDetailSerializer (planned — not built yet) |
| Embedded serializer | lightweight version for nesting | Identities::UserEmbeddedSerializer |
The rule behind the split: sensitive fields (a government ID, a birth date) belong only in audience-restricted serializers — never in a Base or Embedded serializer, because those get reused anywhere.
Naming format
{Domain}::{Audience}{Model}{Context}Serializer — the audience prefix appears when the endpoint is audience-specific:
Identities::UserBaseSerializer— no audience; safe anywhereTalent::CandidatesProfileDetailSerializer— the candidate-facing detail viewOrg::EmployersCompanyEmbeddedSerializer— the employer-facing embedded viewIdentities::TeamUserBaseSerializer— the staff-facing view
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.- The Bullet gem will complain about your query either not eager-loading or including associations.
- You will waste time figuring out the difference between
includesandeager_load.