Modeling Many-to-Many Relationships Across Subgraphs

A many-to-many relationship has no natural home. When both sides live in different subgraphs, the join has to be owned by somebody, and the choice you make determines how many fetches every query costs and which team gets paged when the relationship is wrong. This page covers the three viable models, how each performs, and how to pick without regret. It sits under Designing Cross-Service Type References.

When to use this pattern

  • Use it when both sides of a relationship are entities owned by different subgraphs — products and collections, users and teams, articles and tags.
  • Use it when the relationship itself has attributes, such as a role, a position, or an added-at timestamp, because that is a strong signal it needs its own type.
  • Skip the analysis when one side is clearly subordinate. A one-to-many where the many side has no independent existence is a field, not a relationship.

Prerequisites

Three models, three owners

Model A — the join lives with one side. The subgraph that owns products declares collections: [Collection!]!, and the collections subgraph declares nothing. Simple, one owner, and asymmetric: the reverse direction either does not exist or requires the same subgraph to answer it.

Model B — both sides declare their direction. Products owns Product.collections, collections owns Collection.products. Each subgraph answers the direction it is asked about, and the join data has to exist in both — usually because both read the same store, or because one publishes changes to the other.

Model C — the relationship is its own entity. A membership subgraph owns CollectionMembership with references to both sides and any attributes of the relationship itself. Both product and collection expose a field returning memberships rather than the far side directly.

The choice looks like a data question and is really an ownership question: whoever owns the join is who gets asked when a product is in the wrong collection, and who must deploy when the rules change.

Three homes for a many-to-many join The join can live with one side, be duplicated on both sides, or become its own entity in a third subgraph. Each choice puts the ownership of the relationship in a different place. Who is asked when the relationship is wrong? A — one side owns it catalog owns Product.collections; the reverse direction is unavailable or expensive. Simplest to build. Right when queries are overwhelmingly one-directional. B — both sides declare their direction Fast in both directions and requires the join data in two places, which means it can drift. Acceptable when one side is a read replica of the other, dangerous when both accept writes. C — the relationship is an entity A membership subgraph owns the join and any attributes it carries: position, addedAt, role. One owner, both directions, one extra hop. The default once the relationship has attributes.

Implementation walkthrough

Model C is the one worth showing in full, because it is the model that scales and the one whose SDL is least obvious.

# membership subgraph — owns the relationship itself
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.9",
        import: ["@key", "@external"])

type CollectionMembership @key(fields: "id") {
  id: ID!
  # Stubs: this subgraph references both sides but resolves neither.
  product: Product!
  collection: Collection!
  # Attributes of the relationship — the reason it deserves a type at all.
  position: Int!
  addedAt: DateTime!
}

type Product @key(fields: "id", resolvable: false) { id: ID! }
type Collection @key(fields: "id", resolvable: false) { id: ID! }

Both sides then expose the relationship in their own direction. Neither owns the join; both point at it.

# catalog subgraph
type Product @key(fields: "id") {
  id: ID!
  name: String!
  # Paginated, because a product can belong to many collections.
  memberships(first: Int = 20, after: String): MembershipConnection!
}
// membership subgraph — batching is not optional here
export const resolvers = {
  Product: {
    // Called once per product in a list; the loader collapses N into one query.
    memberships: (product: { id: string }, args: PageArgs, ctx: Context) =>
      ctx.loaders.membershipsByProduct.load({ id: product.id, ...args }),
  },
  CollectionMembership: {
    __resolveReference: (ref: { id: string }, ctx: Context) =>
      ctx.loaders.membership.load(ref.id),
  },
};

The pagination arguments in that snippet are not decoration. A many-to-many relationship is the most common place an unbounded list sneaks into a schema, and it stays invisible until one product turns out to be in four hundred collections. Paginate both directions from the start; adding pagination to a field clients already use is a breaking change.

Verification steps

Check the plan shape first, because the failure mode here is fetch count rather than incorrectness.

query {
  collection(id: "C-1") {
    memberships(first: 20) {
      nodes { position product { name price { amount } } }
    }
  }
}

Capture the query plan and count the fetches. The expected shape is three: one for the collection, one to the membership subgraph for the page, and one entity fetch to catalog carrying all twenty product references. Anything proportional to twenty means the membership resolver is not batching, which is the single most common defect in this pattern.

Then verify the reverse direction costs the same. An asymmetric implementation — fast one way, N+1 the other — is easy to ship because the fast direction is the one you tested.

Finally, confirm behaviour when one side is missing. A membership pointing at a deleted product should return a null product with an error rather than failing the whole page, which is the nullability question covered in handling nullability and partial data in federated queries.

Fetches for one collection page, by page size An unbatched membership resolver issues one fetch per member, so cost grows with page size. A per-request loader collapses them into a single batched fetch regardless of page size. Subgraph fetches to load one page of members no loader — one fetch per member per-request loader — one batched fetch 5 page size 100 Both lines look identical at a page size of five, which is why this defect survives code review and appears the first time a real collection is opened.

Choosing between the three models is easier with the questions written out, because each one eliminates options rather than scoring them. Two answers usually settle it.

Four questions, and what each answer eliminates Attributes, query direction, write paths and future participants each rule models in or out. Four questions that pick the model question if yes if no does the relationship have attributes? Model C keep reading is either direction rarely queried? Model A keep reading can both stores accept writes? avoid Model B Model B is viable will a third participant appear? Model C either A or C The first question decides most cases on its own: attributes need somewhere to live, and that is a type. Model B survives only when one store is derived from the other and never written directly.

Common mistakes & gotchas

Declaring both directions without a single source of truth. Model B with two writable stores drifts, and the graph then returns a product in a collection that does not list it. If both directions are needed, one store must be authoritative and the other derived.

Omitting pagination. A many-to-many field without pagination is an unbounded list, and it is unbounded on the axis you did not test. Add arguments before clients depend on the field.

Putting relationship attributes on one of the entities. A position on Product is meaningless without knowing which collection it refers to. Attributes of a relationship belong on the relationship.

Frequently Asked Questions

Which model should we start with?

Model A if the relationship has no attributes and queries are one-directional, Model C otherwise. Model B is worth avoiding as a starting point because it looks like the cheapest option and quietly commits you to keeping two stores consistent forever.

Does Model C add a hop compared with Model A?

Yes, one. The plan gains a fetch to the membership subgraph, which runs in parallel with other root work but does add a level when the far side’s fields are also selected. That cost is real and usually worth paying: it buys a single owner, both directions, and somewhere to put attributes. If the extra level is genuinely intolerable, that is an argument for @provides on the hot path rather than for abandoning the model.

How do we paginate a many-to-many field?

Cursor-based, on the relationship rather than on either entity. Ordering by the relationship’s own attribute — position, added-at — gives a stable cursor that does not shift when either side is edited, which offset pagination cannot promise.

What if the relationship is huge in one direction and tiny in the other?

That asymmetry is normal and worth encoding. Paginate both, but consider exposing the small direction as a plain list with a documented cap and the large one as a full connection. Pretending both are symmetric produces either an over-engineered small side or an under-protected large one.

What about relationships with more than two sides?

Model C generalises to them directly, and it is the only one of the three that does. A membership joining a user, a team and a role is one entity with three references; trying to express the same thing as fields on any of the three sides produces a schema where the meaning depends on which side you started from.

Can the join subgraph own the mutations?

Usually it should. Adding and removing a membership is a change to the relationship, not to either entity, and putting those mutations with the type that models the relationship keeps write and read ownership aligned — which is what makes the “who do I ask” question have one answer.

How do we migrate from Model A to Model C later?

Incrementally, and it is one of the easier federation migrations because the new subgraph can be built alongside the old field. Stand up the membership subgraph reading the same store, expose the new field, move clients over, and only then remove the original field once usage reaches zero. Both fields coexisting for a release is not a problem — they return the same relationship through different shapes, and the schema is perfectly valid with both present.

Does the relationship need its own identifier?

Yes, once it is an entity. A composite of the two sides’ ids works and couples the identifier to both key schemes, so a surrogate id issued by the membership subgraph is usually better — it survives either side changing its key, and it gives clients something stable to reference when they want to edit or remove a specific membership rather than describing it by both endpoints.