Schema-First vs Code-First in Java: Pick Your Poison

Two camps. One schema. Endless arguments on Twitter. Every Java GraphQL project, within the first week, has the same meeting: should we write the schema first in SDL, or should we write Java classes and let the schema fall out of them? Both camps are convinced the other is doing it wrong. Both are partially right. Here's the honest breakdown, the actual tradeoffs, and what the 2026 Java ecosystem looks like when you stop arguing and pick one.
The Two Philosophies
Schema-first starts with .graphqls files.
# schema.graphqls
type Movie {
id: ID!
title: String!
releaseYear: Int
director: Director
}
type Director {
id: ID!
name: String!
movies: [Movie!]!
}
type Query {
movie(id: ID!): Movie
movies(limit: Int = 10): [Movie!]!
}
Then you write Java code that implements whatever the SDL promised. The schema is the source of truth. Your classes are the implementation detail.
Code-first starts with Java classes. Annotation specifics depend on the library; SPQR (the dominant Java code-first option) infers most of the schema from your types and uses @GraphQLApi, @GraphQLQuery, @GraphQLArgument, @GraphQLNonNull, and @GraphQLIgnore for the cases inference can't cover:
@GraphQLApi
public class MovieService {
private final MovieRepository repo;
@GraphQLQuery(name = "movie")
public Movie movie(@GraphQLArgument(name = "id") @GraphQLNonNull String id) {
return repo.findById(id);
}
}
public record Movie(
@GraphQLNonNull String id,
@GraphQLNonNull String title,
Integer releaseYear,
Director director
) {}
The framework introspects your classes and generates the schema at startup. Your classes are the source of truth. The SDL is the byproduct.
Both approaches produce the same running API. The difference is where the contract lives.
The Java Landscape (April 2026)
Which approach do the major Java frameworks actually support?
| Framework | Default | Code-First Option |
|---|---|---|
| Spring GraphQL | Schema-first | None (programmatic API only) |
| Netflix DGS | Schema-first | No |
| graphql-java (raw) | Either | Via builder API |
| SPQR | Code-first | N/A |
| graphql-kotlin | Code-first | N/A |
| Micronaut GraphQL | Schema-first | Via SPQR extension |
The Java ecosystem leans schema-first. Kotlin slightly favors code-first, largely because graphql-kotlin has been very good at it. Python and JavaScript are mixed. Go is mostly code-first via gqlgen.
Most of the tools built specifically for Spring Boot teams, including both Spring GraphQL and DGS, default to SDL-driven schemas. That's not a coincidence: Java's verbosity makes code-first annotations feel bloated, and schema-first gives you a readable contract without needing to read Java to understand it.
The Case For Schema-First
Let's start with the dominant approach and its genuine strengths.
Your Schema Is Readable By Non-Java Devs
The frontend team does not want to read your Java classes to understand what fields they can query. Your product manager does not want to parse @GraphQLNonNull to know if a field is required. Your partner team integrating with your API does not run a Java compiler before their meeting.
SDL is the lingua franca. Everyone who touches GraphQL reads SDL. Nobody is forced to read your annotation dialect.
Contract Before Code
Schema-first forces design before implementation. You can't scaffold a type without thinking about it because the SDL is right there, staring at you. In code-first, you can accidentally expose a whole internal model because your User class has a passwordHash field and you forgot to annotate it with @GraphQLIgnore.
// Code-first danger zone
public class User {
private String username;
// Oops: meant to be hidden, but SPQR's default visibility maps
// every public/getter-exposed field. Without @GraphQLIgnore here
// (and with a public getter), this leaks into the schema.
private String passwordHash;
}
Different code-first libraries differ on default behavior. Some treat every public getter as a field, others require an opt-in annotation. Get it wrong, and you've just published your password hashes over GraphQL. Schema-first doesn't have this failure mode because the SDL is explicit about what's in the API.
Tooling Bonanza
Schema-first gets the entire GraphQL tooling ecosystem for free. IDE plugins, linters, schema diff tools, codegen for every client language, and mock servers all expect SDL as input. If your source of truth is Java annotations, you have to emit SDL first before the tools work, which means your build pipeline gets a "generate SDL" step that's always slightly out of sync with reality.
The Federation Story Is Cleaner
Apollo Federation directives (@key, @external, @requires, @provides) were designed for SDL. You can express them in code-first frameworks, but it's awkward. In schema-first, they're just part of the schema.
type Movie @key(fields: "id") {
id: ID!
title: String!
}
That's it. No annotation gymnastics.
The Case For Code-First
Now the defense of the minority position. Code-first has real advantages that schema-first fans tend to dismiss.
The Single Source of Truth Problem
In schema-first, you have two things that must stay aligned: your SDL and your Java code. If they drift, the compiler won't save you.
# schema.graphqls
type Movie {
id: ID!
title: String!
rating: Float
}
public record Movie(Long id, String title /* where's rating? */) {}
This compiles. It also runs. It just returns null for rating because Spring GraphQL defaults unknown fields to null when no resolver matches. You find out in QA. Or in prod. In code-first, adding rating to the schema requires adding it to the class. The compiler enforces it.
Refactoring Actually Works
Rename releaseYear to releasedAt in schema-first, and your IDE refactor tool doesn't know the SDL exists. You get a broken app at runtime. You grep the SDL, fix it, then grep again for resolver method names, fix them, run the tests, fix the mocks, fix the tests that mocked the old field, and so on. In code-first, you right-click, rename, and IntelliJ handles it.
Custom Scalars Are Less Painful
In schema-first, custom scalars live in your SDL as scalar Date, and you register them at runtime. If you forget to register one, you get an "Unknown type 'Date'" error at startup. In code-first, the framework sees your LocalDate return type and maps it automatically (given proper configuration). One less thing to synchronize.
Polymorphism Is Easier When The Source Is Code
If your domain model has a sealed hierarchy, code-first can sometimes reflect it directly into a GraphQL union or interface. Schema-first requires you to hand-translate the hierarchy into SDL, then register TypeResolver implementations that decide at runtime which concrete type a value is.
// Code-first (graphql-kotlin / SPQR style)
sealed interface PaymentMethod permits CreditCard, BankTransfer, Crypto {}
record CreditCard(String last4, String brand) implements PaymentMethod {}
record BankTransfer(String iban, String swift) implements PaymentMethod {}
record Crypto(String chain, String address) implements PaymentMethod {}
The framework can emit:
union PaymentMethod = CreditCard | BankTransfer | Crypto
And automatically wire the TypeResolver. In schema-first, you write the union in SDL, then write a TypeResolver manually, and if you add a fourth payment method, you touch three files instead of one.
The Middle Path: Schema-First With Codegen
Here's the approach most pragmatic Java teams in 2026 have settled on:
- Write SDL. It's the contract. Frontend teams read it. Federation works. Tooling works.
- Generate Java types from the SDL. Now the compiler knows about your schema.
- Hand-write the resolver logic. This is where your business code lives.
DGS codegen does this natively. Spring GraphQL users reach for graphql-codegen or graphql-java-codegen. The generated types sit in a generated/ directory, never hand-edited, always in sync with the SDL.
schema.graphqls ← source of truth
↓ codegen
generated/Movie.java ← auto-generated type
generated/MovieResolver.java ← auto-generated interface
↓ you implement
MovieResolverImpl.java ← your business logic
This gives you:
- SDL as the contract (tooling, federation, readability)
- Compile-time safety when schema and resolvers drift
- Ability to refactor (generated types update on codegen run)
- Clear separation between "framework glue" and "your logic"
This is close to what code-first frameworks produce, except the direction of the arrow is reversed: SDL generates code, not the other way around. For most Java teams, this is the right answer.
The DGS Perspective
DGS is schema-first and strongly opinionated about it. Their codegen is tight, the developer loop is fast, and the generated types look like records you'd have written yourself. Netflix runs their entire federated graph this way, and it clearly works.
# schema.graphqls
type Movie {
id: ID!
title: String!
releaseYear: Int
}
// Generated by DGS codegen
public class Movie {
private String id;
private String title;
private Integer releaseYear;
// ... getters, builders, etc.
}
// You write this
@DgsComponent
public class MovieFetcher {
@DgsQuery
public Movie movie(@InputArgument String id) {
return movieService.findById(id);
}
}
The generated types mean your resolver is strongly typed against the schema. Add a field in SDL, regenerate, and the compiler tells you which resolvers need an update.
The Spring GraphQL Perspective
Spring GraphQL is also schema-first, but less opinionated about codegen. You can use generated types if you want. You can also return plain Java records, and Spring GraphQL will map fields by reflection.
// No codegen
public record Movie(Long id, String title, Integer releaseYear) {}
@Controller
public class MovieController {
@QueryMapping
public Movie movie(@Argument Long id) {
return movieService.findById(id);
}
}
Spring GraphQL looks at the query, asks "does this record have a title field?", finds the accessor, and returns it. Simple and flexible. The tradeoff: if your schema has a field your record doesn't, Spring GraphQL resolves to null without complaint.
This is the most "just Java, just Spring" way to build a GraphQL API, and many teams ship it this way for years. It's also the approach most likely to let schema drift sneak into production, because nothing checks that your records match the SDL. The answer is test coverage, not more annotations.
The Code-First Corner: SPQR and graphql-kotlin
If you want to go full code-first in Java land, the tool to know is SPQR. It introspects your Java classes, handles generics via type literals, and emits SDL at startup.
@GraphQLApi
public class MovieService {
@GraphQLQuery(name = "movie")
public Movie findMovie(@GraphQLArgument(name = "id") Long id) {
return movieRepository.findById(id);
}
}
// Startup:
GraphQLSchema schema = new GraphQLSchemaGenerator()
.withOperationsFromSingleton(new MovieService())
.generate();
SPQR has a loyal following and is legitimately good. But the Spring GraphQL and DGS momentum has mostly pulled the Java ecosystem toward schema-first, and SPQR fills a niche instead of the mainstream.
In Kotlin-land, graphql-kotlin is the bigger story. Kotlin's sealed classes, data classes, and coroutines map cleanly onto GraphQL concepts, and code-first in Kotlin feels more natural than in Java.
How To Actually Decide
Honest advice, based on what actually works in practice:
- You're using Spring GraphQL or DGS (they're schema-first by default)
- Your frontend team reads your schema
- You're doing federation
- You want standard GraphQL tooling to just work
- Your team is more than 3 people
- You're a small Kotlin team that values type-system tightness above all
- Your schema is a small, mostly internal API
- You don't do federation
- You don't have frontend consumers reading your SDL
- You want schema-first's contract benefits
- You want code-first's refactoring safety
- You're willing to accept a
generated/directory in your repo - You're using DGS (native) or Spring GraphQL (with graphql-codegen)
The Meta-Point
Here's the part nobody says at the end of the argument: both approaches are fine. Teams that pick schema-first ship successful products. Teams that pick code-first ship successful products. Teams that pick hybrid ship successful products.
The failures that actually happen in production are not caused by the choice between SDL-first and Java-first. They're caused by:
- Unbounded list fields that return 50,000 rows
- Non-null fields that get set to null when a service goes down
- Missing DataLoaders causing N+1
- No query cost analysis letting clients DoS the server
These issues are equally possible in both worlds. The SDL-vs-code debate is architecture theater. The real question is whether your schema, however you authored it, is resilient, bounded, and documented.
Pick an approach. Stick with it. Spend the saved argument energy on field nullability.
This post was written in neither schema-first nor code-first style. It was written in Markdown. The author considers himself beyond such squabbles.