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 nolimitaccepts 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
Quiz
1. Post declares author: User! and User declares posts: [Post!]!. Why does the schema still validate?
2. Which of these documents is rejected before execution?
3. Both edges of a relation are queryable entry points into the same data. Where must authorization be enforced?
4. Why should a recursive field like User.posts accept a limiting argument?
Flashcards
Question
Are circular type references valid SDL?
Click to reveal answer
Answer
Yes. Validation runs over the whole type graph, so Post.author and User.posts coexist legally.
Question
Are circular fragment spreads valid?
Click to reveal answer
Answer
No. No Fragment Cycles rejects a fragment that reaches itself; depth must be written out literally.
Question
Symmetry rule for two-way relations
Click to reveal answer
Answer
If one edge accepts filter/pagination arguments, the matching edge should accept the same ones.
Question
Where authorization lives in a bidirectional relation
Click to reveal answer
Answer
In each edge resolver. Both directions are independent entry points into the same data.
Question
Schema cycle vs resolver loop
Click to reveal answer
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