Skip to main content

GraphQL

Refract’s API is GraphQL, built with Apollo Server on the backend and Apollo Client on the frontend. The schema is generated from the backend resolvers, and TypeScript hooks for the frontend are generated from that schema. Once this pipeline is set up, adding a new API operation is a well-defined 5-step process.

How it works

  1. You write a resolver in apps/backend/src/gql/.
  2. Running make gql-codegen dumps the full schema to /shared/template.schema.graphql.
  3. The frontend codegen reads that schema plus any useQuery / useMutation calls in the frontend source, and generates apps/frontend/src/gql/hooks.ts — a fully typed file of React hooks.
  4. Your React component imports and calls the generated hook. TypeScript ensures the component’s types stay in sync with the API.
The CI codegen-verification job enforces this — if you forget to run codegen after a schema change, the job fails and tells you to commit the updated generated file.

Resolver structure

Resolvers in Refract follow a consistent shape. Here’s a real example — a query that fetches countries with Redis caching:
// apps/backend/src/gql/queries/fetchCountries.ts
export const fetchCountries: ResolverType<undefined, FetchCountriesReturn> =
  async (_parent, args, context: ContextWithUser) => {
    return resolveWithMiddlewares(_parent, args, context, [
      async (_parent, _args, context) => {
        const { tools } = context;
        // all external services come from tools — no direct SDK imports
        const cached = await tools.cache.client.get('countries');
        if (cached) return { countries: JSON.parse(cached) };

        const countries = Country.getAllCountries().map(c => ({
          name: c.name,
          iso: c.isoCode,
          flag: c.flag,
          currency: c.currency,
        }));

        await tools.cache.client.set('countries', JSON.stringify(countries), { EX: 86400 });
        return { countries };
      },
    ]);
  };

export const buildFetchCountriesSchema: GQLSchemaBuilder = () => {
  const typeDefs = new GraphQLSchema({
    query: new GraphQLObjectType({
      name: 'Query',
      fields: {
        fetchCountries: {
          type: new GraphQLObjectType({ ... }),
          resolve: fetchCountries,
        },
      },
    }),
  });
  return makeExecutableSchema({ typeDefs, resolvers: { Query: { fetchCountries } } });
};
Each resolver file exports:
  • The resolver function (named after the operation, e.g. fetchCountries)
  • A schema builder function (named build<OperationName>Schema)
  • The parameter type, exported so tests can import it instead of redefining it

Middleware chain

Resolvers use resolveWithMiddlewares to enforce authentication and authorization before running business logic. A typical secured mutation looks like:
return resolveWithMiddlewares(_parent, form, context, [
  isLoggedIn,
  populateUserWithMemberships,
  isAdmin,
  async (_parent, form, context) => {
    // safe to run — user is authenticated and has the admin role
    return myUtilityFunction(form, context.tools);
  },
]);
The middleware array runs in order. If any middleware rejects (e.g. user isn’t logged in), the chain stops and the error is returned to the client. Your business logic lives at the end of the chain.

Mutation return types

Mutations always return a discriminated union:
type MutationReturn = { success: true } | { success: false; message: string };
This makes it straightforward for the frontend to handle success and failure without throwing exceptions. On success, you can add a result field with whatever data the client needs.

Adding a new query or mutation

  1. Create the resolver file For a query: apps/backend/src/gql/queries/fetchMyThing.ts For a mutation: apps/backend/src/gql/mutations/createMyThing.ts Export the resolver function, the schema builder, and the params type:
    export type FetchMyThingParams = { id: string };
    
    export const fetchMyThing: ResolverType<FetchMyThingParams, FetchMyThingReturn> =
      async (_parent, form, context) => {
        return resolveWithMiddlewares(_parent, form, context, [
          isLoggedIn,
          async (_parent, form, context) => {
            return myUtilityFunction(form, context.tools);
          },
        ]);
      };
    
  2. Add business logic to a utility apps/backend/src/utilities/myThing.ts — pure function, no direct SDK calls:
    export const myUtilityFunction = async (
      params: FetchMyThingParams,
      tools: ToolsType,
    ) => {
      // query the DB, call tools.cache, etc.
    };
    
  3. Register the schema builder In apps/backend/src/gql/index.ts, add your builder to the merged schema:
    import { buildFetchMyThingSchema } from './queries/fetchMyThing';
    // add it to the mergeSchemas() call
    
  4. Run codegen
    make gql-codegen
    
    This generates the updated apps/frontend/src/gql/hooks.ts with a new useFetchMyThingQuery hook.
  5. Use the hook in the frontend
    import { useFetchMyThingQuery } from '@/gql/hooks';
    
    const MyComponent = () => {
      const { data, loading } = useFetchMyThingQuery({ variables: { id: '123' } });
      // ...
    };
    
  6. Write a test apps/backend/src/gql/queries/__tests__/fetchMyThing.spec.ts:
    import { fetchMyThing, FetchMyThingParams } from '../fetchMyThing';
    // import the exported param type — never redefine it in the test
    
  7. Verify
    make test module=backend
    

What’s next?