Skip to content
intermediate Phase 5 · Nested Selections and Object Relationships

Bidirectional Object Relationships

Modeling two-way references like posts to authors and authors back to posts.

45m
1 problems
Topic Progress 0%

Drawing Both Arrows Between Posts and Authors

Drawing Both Arrows Between Posts and Authors

An author page lists their posts; each post shows its author. In SQL you join one way and issue a second query the other way. In a schema you declare both arrows and let one document walk them:

type Post {
  id: ID!
  title: String!
  author: User!
}

type User {
  id: ID!
  name: String!
  posts(limit: Int = 10, orderBy: Order = NEWEST): [Post!]!
}
query AuthorWithFootprint {
  user(id: "7") {
    name
    posts(limit: 5) {
      title
      author {   # back to the type we started from — legal, and occasionally useful
        name
      }
    }
  }
}

The schema is a directed graph. Bidirectionality simply means each type holds an edge to the other:

  Post -----author----> User
    ^                      |
    |------- posts---------|

Notice what the SDL does not contain: foreign keys. Post.author describes a relationship; whether it is satisfied by a join, a lookup, or a cached edge belongs to the resolver layer. That split is why the same schema can be served by a relational database, a document store, or a REST aggregate without changing a single client query.

Cycles at the type level do not imply cycles at execution time — execution follows the document, and documents are finite. The real costs live in how each edge is resolved, which the next two chapters take apart.

What the two-way declaration buys, compared with the same read elsewhere:

Read Typical REST This schema
user + their 5 posts + each post's author 3 endpoint calls plus a client-side join 1 document
start showing a different author field new endpoint, or over-fetch for everyone edit the selection set
cardinality of each edge inferred from a foreign key constraint visible: posts is a list, author is single

Because both edges sit next to each other in the SDL, a reviewer sees the whole relationship at once — and a client can walk from a post to its author and back to that author's other posts without the server ever issuing a second request.

Why Circular Types Validate and Circular Spreads Do Not

Why Circular Types Validate and Circular Spreads Do Not

Schema validation runs once over the whole type graph, not type by type in declaration order. There is no "first type" to resolve, so Post.author: User alongside User.posts: [Post!]! is fully legal — the validator walks edges and checks field types, and revisiting a type means the graph is consistent, not broken.

Documents are held to a stricter rule, because a document must terminate. A fragment that reaches itself is rejected:

query Loop {
  user(id: "1") {
    name
    posts { author { ...AuthorLoop } }
  }
}

fragment AuthorLoop on User {
  name
  posts { author { ...AuthorLoop } }
}
Fragment "AuthorLoop" cannot be spread here as it would create a cycle.

The rule is called No Fragment Cycles. It is the only thing that stops an infinite query from being written; with it, depth can grow only by spelling each level out literally, which is exactly what a depth limit counts.

Practical consequences:

  • Circular types are a modeling feature. Users follow users, categories nest categories, comments reply to comments, org units report to org units. Rejecting them means rejecting the domain.
  • Every recursive edge needs a bound. A field like friends: [User!]! with no limit accepts a finite document that triggers unbounded work — paginate it or give it a default cap.
  • Flag the right thing in review. "This type references itself" is a false positive; "this recursive list has no limiting argument" is the finding that matters.

See circular queries and cycle guards for the depth, cost, and resolver-side controls that bound what clients can write through a cycle.

Keeping Both Directions Symmetric and Terminating

Keeping Both Directions Symmetric and Terminating

Asymmetry is the drift bug of relational schemas. Post.author accepts nothing while User.posts(limit:) paginates, so clients discover two dialects for one edge and every consumer writes its own workaround.

Match arguments across the edge. Where one side filters, the other filters too:

type Post {
  author(includePostCount: Boolean = false): User!
}
type User {
  posts(limit: Int = 10, orderBy: Order = NEWEST): [Post!]!
}

Both resolvers terminate by construction — each reads an id from the parent and loads one bounded page:

const resolvers = {
  Post: {
    author: (post, _args, { loaders }) => loaders.user.load(post.authorId),
  },
  User: {
    // parent is the user; never reach back through Post.author from here
    posts: (user, { limit, orderBy }, { db }) =>
      db.post.findMany({ where: { authorId: user.id }, take: limit, orderBy }),
  },
};

The loop that actually bites is not in the schema but in resolver code that walks both edges eagerly: load a user, expand posts, load each post's author, expand their posts, and keep going. Bound any such traversal with a visited set and a depth counter — never with an unbounded loop over relations.

Concern One direction only Both directions
Reading the graph second round trip or client-side stitch one document covers the walk
Pagination only where declared every list edge needs it
Authorization checked at the entry point re-checked at each entry point

That last row is the production trap: if an author lookup is permitted through Post.author, the same person's data reached via User.posts must be authorized again. Access rules belong to each edge's resolver, not to one choke point — see role-based field access.

Interview question to expect: "Can a schema have circular references?" Answer yes at the type level, explain that validation runs over the whole graph, then add that fragment cycles are rejected and depth limits bound the rest — that sequence shows you understand where each rule applies.

Practice Problems

0 / 1 solved
Model a two-way Post and Author relation
Bidirectional schema design

Extend the SDL so a Post can reach its author and a User can reach their posts, then write one document that reads a user, five of their posts, and each post author name. Both list edges must be bounded.

Example:

Input: type Query { user(id: ID!): User } type Post { id: ID! title: String! }

Output: type Post { id: ID! title: String! author: User! } type User { id: ID! name: String! posts(limit: Int = 10): [Post!]! } query AuthorFootprint($id: ID!) { user(id: $id) { name posts(limit: 5) { title author { name } } } }

Each type now holds an edge to the other, so one document walks the circle. No foreign key appears in the SDL: Post.author describes a relationship, and the resolver decides how to satisfy it.

Optimal Solution — O(p) time, O(p) space

Declare one edge per direction, mirror a limiting argument on the list side, and resolve each edge from the parent id with a bounded query.

type Post {
  id: ID!
  title: String!
  author: User!
}

type User {
  id: ID!
  name: String!
  posts(limit: Int = 10): [Post!]!
}

const resolvers = {
  Post: {
    author: (post, _args, { loaders }) => loaders.user.load(post.authorId),
  },
  User: {
    posts: (user, { limit }, { db }) =>
      db.post.findMany({ where: { authorId: user.id }, take: limit }),
  },
};

Edge Cases:

  • A user with no posts returns posts: [] — the reverse edge is nullable only if you declare it that way
  • Circular SDL validates; a fragment that spreads itself through this edge does not
  • Both edges are independent authorization points and must each check access

Quiz

1. Post declares author: User! and User declares posts: [Post!]!. Why does the schema still validate?

Question 1 options

2. Which of these documents is rejected before execution?

Question 2 options

3. Both edges of a relation are queryable entry points into the same data. Where must authorization be enforced?

Question 3 options

4. Why should a recursive field like User.posts accept a limiting argument?

Question 4 options

Flashcards

Question

Are circular type references valid SDL?

Answer

Yes. Validation runs over the whole type graph, so Post.author and User.posts coexist legally.

Question

Are circular fragment spreads valid?

Answer

No. No Fragment Cycles rejects a fragment that reaches itself; depth must be written out literally.

Question

Symmetry rule for two-way relations

Answer

If one edge accepts filter/pagination arguments, the matching edge should accept the same ones.

Question

Where authorization lives in a bidirectional relation

Answer

In each edge resolver. Both directions are independent entry points into the same data.

Question

Schema cycle vs resolver loop

Answer

Schema cycles are declarations (harmless); loops appear when resolver code eagerly walks both edges — bound with visited set + depth cap.

Revision Notes

Key Takeaways

  • 1. Two types can hold edges to each other; the schema is a graph, and one document can walk both directions
  • 2. Whole-schema validation makes circular types legal while the no-cycles rule keeps circular fragments out
  • 3. Recursive edges need limits or connections, and resolver-side traversals need visited sets and depth caps
  • 4. Authorization and pagination must be implemented per edge, not once per type

Interview Tips

  • • Answer the circular-reference question in three moves: types yes, fragment spreads no, execution bounded by depth and cost limits
  • • Show the SDL for a two-way relation and immediately point out where the foreign key deliberately does not appear
  • • Be ready to explain why checking access on Post.author does not cover the same data read through User.posts

Cheat Sheet

Bidirectional Relations Cheat Sheet

type Post  { id: ID!  author: User! }
type User  { id: ID!  posts(limit: Int = 10): [Post!]! }
  • Schema is a directed graph; two edges make a relation traversable both ways
  • Foreign keys live in resolvers, not SDL — the schema describes relationships only
  • Whole-schema validation ⇒ circular types legal
  • No Fragment Cycles ⇒ fragment F on User { friends { ...F } } rejected at validation
  • Bound every recursive edge: limit/first, a connection, or a server-side default cap
  • Authorize and paginate per edge — each direction is its own entry point

Continue Learning