PluginsPrisma

Prisma without a plugin

Back GraphQL objects with Prisma models using plain objectRef, and tame the resulting N+1 queries.

You don't need the Prisma plugin to put Prisma models behind a GraphQL schema. builder.objectRef takes any TypeScript shape as its backing model, and a Prisma row is just a shape, so you can point a ref at Team or Player from @prisma/client and resolve relations with ordinary client calls. You give up the plugin's automatic query-planning, but the code stays plain Pothos.

import { Player, PrismaClient, Team } from '@prisma/client';

const db = new PrismaClient();

const TeamObject = builder.objectRef<Team>('Team');
const PlayerObject = builder.objectRef<Player>('Player');

TeamObject.implement({
  fields: (t) => ({
    id: t.exposeID('id'),
    name: t.exposeString('name'),
    players: t.field({
      type: [PlayerObject],
      resolve: (team) => db.player.findMany({ where: { teamId: team.id } }),
    }),
  }),
});

PlayerObject.implement({
  fields: (t) => ({
    id: t.exposeID('id'),
    name: t.exposeString('name'),
    team: t.field({
      type: TeamObject,
      resolve: (player) => db.team.findUniqueOrThrow({ where: { id: player.teamId } }),
    }),
  }),
});

builder.queryType({
  fields: (t) => ({
    myTeam: t.field({
      type: TeamObject,
      resolve: (_root, _args, ctx) => db.team.findUniqueOrThrow({ where: { id: ctx.teamId } }),
    }),
  }),
});

This defines Team and Player objects with a relation each, plus a myTeam query for the viewer's team. Three details make it work:

  • Split the ref from implement. Declaring TeamObject/PlayerObject up front and calling implement afterwards, rather than builder.objectRef(...).implement(...) in one expression, keeps TypeScript from choking on the circular reference between teams and players.
  • findUniqueOrThrow for non-null fields. team and myTeam are non-nullable, so they must never resolve to null. findUnique returns null when nothing matches; findUniqueOrThrow throws instead. Use findUnique only when the field is marked nullable.
  • Ref names vs. type names. The refs are TeamObject/PlayerObject because Team and Player are already taken by the imports from @prisma/client. Alias the imports instead (import { Team as TeamModel }) if you'd rather name the refs after the GraphQL types.

Cutting down N+1 queries

The schema above issues one query per relation edge. Fetch a team, then its players, then each player's team, and the round-trips multiply. Prisma batches some of this for you, but you can also shape the backing model to avoid the round-trip entirely.

If you almost always load a player's team alongside the player, fold the team into the backing shape and have the parent resolver include it:

const TeamObject = builder.objectRef<Team>('Team');
// Widen the backing model so a Player always carries its Team.
const PlayerObject = builder.objectRef<Player & { team: Team }>('Player');

TeamObject.implement({
  fields: (t) => ({
    id: t.exposeID('id'),
    name: t.exposeString('name'),
    players: t.field({
      type: [PlayerObject],
      resolve: (team) =>
        db.player.findMany({
          // Include the team so the child resolver has it already.
          include: { team: true },
          where: { teamId: team.id },
        }),
    }),
  }),
});

PlayerObject.implement({
  fields: (t) => ({
    id: t.exposeID('id'),
    name: t.exposeString('name'),
    team: t.field({
      type: TeamObject,
      // No query — the team came along with the player.
      resolve: (player) => player.team,
    }),
  }),
});

Requiring team on every Player is a strong claim: every resolver that produces a player now owes you the include. When only some paths can supply it, make the field optional and fall back to a query:

const PlayerObject = builder.objectRef<Player & { team?: Team }>('Player');

PlayerObject.implement({
  fields: (t) => ({
    id: t.exposeID('id'),
    name: t.exposeString('name'),
    team: t.field({
      type: TeamObject,
      resolve: (player) =>
        player.team ?? db.team.findUniqueOrThrow({ where: { id: player.teamId } }),
    }),
  }),
});

Now a parent resolver may pre-load the team, and the field still resolves correctly when it doesn't.

A dataloader is the other lever for N+1: batch the per-player team lookups into one query. Or let the Prisma plugin plan these selections for you: t.relation and t.prismaField read the GraphQL selection set and build a single nested query.

On this page

Edit on GitHub