11/24/2025 9:33 AM

Closing the Gap Between Schema-First and Code-First GraphQL Development

4 minutes

At InnoPeak, we use a schema-first approach for Finly’s GraphQL API, relying on GQLGen to generate models, enums, and resolver stubs while keeping the focus on a clean, expressive graph. To cut down on duplication, we introduced a complementary code-first layer that generates model and enum definitions automatically—allowing us to concentrate on shaping the rest of the graph.

Ravi

Ravi

CTO

GraphQL teams often pick sides between schema-first and code-first development, but in practice the most effective approach usually sits somewhere in the middle.

Code-First GraphQL

In a code-first setup, libraries like TypeGraphQL generate your GraphQL schema directly from annotated classes. You decorate your existing models, and the library infers the corresponding schema. For example, a simple Recipe class becomes a full GraphQL type just by adding decorators.

recipe.ts
1@ObjectType()
2class Recipe {
3  @Field()
4  title: string;
5
6  @Field(type => [Rate])
7  ratings: Rate[];
8
9  @Field({ nullable: true })
10  averageRating?: number;
11}

Resolvers follow the same pattern. You implement classes, and the schema generator determines the return types from your TypeScript definitions. This makes CRUD workflows fast and convenient, especially when your domain model aligns closely with your API surface.

recipe.resolver.ts
1@Resolver()
2class RecipeResolver {}

The trade-off becomes clearer once you start modeling fields that aren’t part of your backend entities. An unreadEmailCount on an Inbox object, for example, has no place in your persistence layer but fits naturally when you're thinking in graphs. You can still implement it with field resolvers, but you're ultimately shaping the graph through TypeScript code instead of expressing it directly in the schema—making it harder to reason about the graph as a graph.

recipe.resolver.ts
1@Resolver(of => Recipe)
2class RecipeResolver {
3  // Queries and mutations
4
5  @FieldResolver()
6  averageRating(@Root() recipe: Recipe) {
7    // ...
8  }
9}

The same friction appears with scalars and directives. Instead of defining them directly in SDL—where you immediately see their usage and impact—you implement them in code and rely on runtime or build-time generation to reveal the final schema shape. That disconnect becomes more noticeable as your graph grows and you need a clear, authoritative source for how everything fits together.

object-id.scalar.ts
1import { GraphQLScalarType, Kind } from "graphql";
2import { ObjectId } from "mongodb";
3
4export const ObjectIdScalar = new GraphQLScalarType({
5  name: "ObjectId",
6  description: "Mongo object id scalar type",
7  serialize(value: unknown): string {
8    // Check type of value
9    if (!(value instanceof ObjectId)) {
10      throw new Error("ObjectIdScalar can only serialize ObjectId values");
11    }
12    return value.toHexString(); // Value sent to client
13  },
14  parseValue(value: unknown): ObjectId {
15    // Check type of value
16    if (typeof value !== "string") {
17      throw new Error("ObjectIdScalar can only parse string values");
18    }
19    return new ObjectId(value); // Value from client input variables
20  },
21  parseLiteral(ast): ObjectId {
22    // Check type of value
23    if (ast.kind !== Kind.STRING) {
24      throw new Error("ObjectIdScalar can only parse string values");
25    }
26    return new ObjectId(ast.value); // Value from client query
27  },
28});

Sample directive in TypeGraphQL:

1import { mergeSchemas } from "@graphql-tools/schema";
2import { renameDirective } from "fake-rename-directive-package";
3
4// Build TypeGraphQL executable schema
5const schemaSimple = await buildSchema({
6  resolvers: [SampleResolver],
7});
8
9// Merge schema with sample directive type definitions
10const schemaMerged = mergeSchemas({
11  schemas: [schemaSimple],
12  // Register the directives definitions
13  typeDefs: [renameDirective.typeDefs],
14});
15
16// Transform and obtain the final schema
17const schema = renameDirective.transformer(schemaMerged);

While this works, it introduces a noticeable amount of annotation overhead—extra decorators whose only purpose is to tell TypeGraphQL how to construct the final schema.

Schema-First GraphQL

The earlier examples highlight how code-first GraphQL works and where its trade-offs can show up. That doesn’t mean it’s the wrong choice. Tools like TypeGraphQL and NestJS’s code-first module are excellent if you prefer keeping everything in code. They shine in smaller monolithic projects or admin dashboards where most entity fields map cleanly to what the frontend needs and speed of delivery is the priority.

Schema-first takes a different stance. It lets you design the graph with intent—treating the schema as the authoritative source of truth. Using GQLGen as an example, we begin by writing the schema directly:

schema.graphql
1type Todo {
2  id: ID!
3  text: String!
4  done: Boolean!
5  user: User!
6}
7
8type User {
9  id: ID!
10  name: String!
11}
12
13type Query {
14  todos: [Todo!]!
15}
16
17input NewTodo {
18  text: String!
19  userId: String!
20}
21
22type Mutation {
23  createTodo(input: NewTodo!): Todo!
24}

Once the schema is defined, running go tool gqlgen generate produces the resolver stubs:

schema.resolver.go
1func (r *mutationResolver) CreateTodo(ctx context.Context, input model.NewTodo) (*model.Todo, error) {
2	panic(fmt.Errorf("not implemented"))
3}
4
5func (r *queryResolver) Todos(ctx context.Context) ([]*model.Todo, error) {
6	panic(fmt.Errorf("not implemented"))
7}

From here, implementing the resolvers becomes straightforward. You wire them up to your services or data layer, and the schema continues to guide how the graph should evolve.

Schema-first GraphQL offers several key advantages:

  • Graph-first thinking: You design your graph around the needs of your clients and business logic, not your database or existing code.
  • Clear source of truth: The schema becomes the authoritative definition of your API, making it easier for frontend and backend teams to stay aligned.
  • Strong type safety: Tools like GQLGen generate models, enums, and resolver stubs automatically, ensuring type consistency between your schema and Go code.
  • Ease of extending the schema: Adding custom scalars, enums, and directives is straightforward, keeping the schema expressive without cluttering your business logic.
  • Reduced duplication: By generating boilerplate models and resolvers, you can focus on the actual business logic instead of repetitive scaffolding.

GQLGen also makes defining custom scalars, directives, and enums easy, another reason we chose to use it. I’ll leave the code examples here for those curious, but next it’s time to discuss the drawbacks of schema-first GraphQL before we get to the main focus: generating entity types with GQLSchemaGen.

Automating Type Generation in Schema-First GraphQL

While schema-first gives us control, clarity, and type safety, there’s still some boilerplate involved in defining every type, input, and enum manually. In a project like Finly, where we have dozens of entities with overlapping fields, writing all of these by hand quickly becomes tedious and error-prone.

This is where GQLSchemaGen comes in. It bridges the gap by generating the model and enum definitions directly from your backend code while keeping the schema-first workflow intact. With it, we get the best of both worlds: a well-designed schema that guides development, and automated generation that reduces duplication and speeds up implementation.

For example, annotating a User struct with @GqlType generates a GraphQL type with the same fields:

1/**
2* @GqlType(name:"UserProfile",description:"Represents a user in the system") 
3*/
4type User struct {
5  ID   string
6  Name string
7}

For inputs, you can add @GqlInput directly to the entity, even including extra fields where needed:

user.go
1/**
2* @GqlInput(name:"CreateUserInput")
3* @GqlInput(name:"UpdateUserInput")
4* @GqlInputExtraField(name:"password",type:"String!",description:"User password",on:"CreateUserInput")
5*/
6type User struct {
7  ID       string
8  Username string
9  Email    string
10}

This generates GraphQL input types that match the Go struct while letting you add fields like password only where appropriate:

schema.graphql
1input CreateUserInput {
2  id: ID!
3  username: String!
4  email: String!
5  """User password"""
6  password: String!
7}
8
9input UpdateUserInput {
10  id: ID!
11  username: String!
12  email: String!
13  # password not included
14}
15

Enums work similarly. Annotating a Go enum type with @GqlEnum and its values with @GqlEnumValue produces a fully typed GraphQL enum:

1/**
2* @GqlEnum(name:"Role", description:"User role in the system")
3*/
4type UserRole string
5
6const (
7  UserRoleAdmin  UserRole = "admin"  // @GqlEnumValue(name:"ADMIN", description:"Administrator with full access")
8  UserRoleEditor UserRole = "editor" // @GqlEnumValue(name:"EDITOR", description:"Can edit content")
9  UserRoleViewer UserRole = "viewer" // @GqlEnumValue(name:"VIEWER", description:"Read-only access")
10)
11

At the end of each generated schema file, GQLSchemaGen adds a @GqlKeepBegin / @GqlKeepEnd section where you can insert custom directives, queries, mutations, or type extensions. For example, you could extend User with a computed field like unreadNotifications without touching the generated code, then use GQLGen to generate resolvers as usual.

schema.graphql
1# @GqlKeepBegin
2# Custom schema content preserved by GQLSchemaGen
3extend type UserProfile {
4  """Number of unread notifications for the user"""
5  unreadNotifications: Int!
6}
7
8directive @auth(role: String!) on FIELD_DEFINITION
9# @GqlKeepEnd

The workflow gives us the best of both worlds: we can automatically generate the repetitive parts of the schema while retaining full control over the graph’s design, directives, and custom logic. It drastically reduces duplication, keeps the schema aligned with our Go models, and lets us focus on implementing business logic instead of boilerplate.

To learn more about GQLSchemaGen and how it can streamline a schema-first GraphQL workflow, check out the official documentation.

Finly - Closing the Gap Between Schema-First and Code-First GraphQL Development