Skip to main content

Schema Design Patterns for Large Spring GraphQL Applications

· 7 min read
GraphQL Guy

Schema Design

A well-designed schema is the foundation of a great GraphQL API. Learn patterns for organizing, evolving, and maintaining schemas in enterprise Spring applications.

Schema Design Philosophy​

Your GraphQL schema is a contract with your clients. Unlike REST, where you can version endpoints, GraphQL schemas should evolve gracefully. Good design decisions early save pain later.

Guiding Principles​

  1. Design for use cases, not database tables
  2. Make impossible states unrepresentable
  3. Prefer explicit over implicit
  4. Plan for evolution

Organizing Large Schemas​

File Structure​

Split your schema across multiple files:

src/main/resources/graphql/
├── schema.graphqls # Root types: Query, Mutation, Subscription
├── types/
│ ├── book.graphqls # Book type and related
│ ├── author.graphqls # Author type and related
│ ├── user.graphqls # User type and related
│ └── common.graphqls # Shared types (PageInfo, DateTime, etc.)
├── inputs/
│ ├── book-inputs.graphqls
│ └── filters.graphqls
└── directives/
└── auth.graphqls

Spring GraphQL automatically merges all .graphqls files.

Domain-Driven Organization​

Group by business domain:

graphql/
├── catalog/
│ ├── book.graphqls
│ ├── author.graphqls
│ └── publisher.graphqls
├── orders/
│ ├── order.graphqls
│ ├── cart.graphqls
│ └── payment.graphqls
└── users/
├── user.graphqls
└── profile.graphqls

Type Design Patterns​

Pattern 1: Nullable vs Non-Null​

Be intentional about nullability:

type Book {
# Always present - use non-null
id: ID!
title: String!
createdAt: DateTime!

# May not exist - nullable
description: String
coverImageUrl: String

# Empty list is valid, null is not - non-null list
tags: [String!]!

# Could be null, items never null
reviews: [Review!]
}

Rule of thumb:

  • IDs, timestamps, required business fields → Non-null
  • Optional fields, computed fields that can fail → Nullable
  • Lists → Non-null (empty list instead of null)

Pattern 2: Semantic Types​

Don't use primitives for everything:

# Bad - primitives everywhere
type Book {
id: String!
price: Float!
isbn: String!
publishedDate: String!
}

# Good - semantic types
type Book {
id: ID!
price: Money!
isbn: ISBN!
publishedDate: Date!
}

scalar ISBN
scalar Date

type Money {
amount: Float!
currency: Currency!
}

enum Currency {
USD
EUR
GBP
}

Pattern 3: Union Types for Polymorphism​

When a field can return different types:

type Query {
search(query: String!): [SearchResult!]!
feed: [FeedItem!]!
}

union SearchResult = Book | Author | Publisher

union FeedItem = BookReview | AuthorPost | SystemNotification

type BookReview {
id: ID!
book: Book!
reviewer: User!
rating: Int!
content: String!
}

type AuthorPost {
id: ID!
author: Author!
content: String!
publishedAt: DateTime!
}

type SystemNotification {
id: ID!
message: String!
level: NotificationLevel!
}

Client usage:

query Feed {
feed {
... on BookReview {
book { title }
rating
}
... on AuthorPost {
author { name }
content
}
... on SystemNotification {
message
level
}
}
}

Pattern 4: Interfaces for Shared Behavior​

interface Node {
id: ID!
}

interface Timestamped {
createdAt: DateTime!
updatedAt: DateTime!
}

interface Authored {
author: User!
}

type Book implements Node & Timestamped {
id: ID!
title: String!
createdAt: DateTime!
updatedAt: DateTime!
}

type Review implements Node & Timestamped & Authored {
id: ID!
content: String!
author: User!
createdAt: DateTime!
updatedAt: DateTime!
}

Input Type Patterns​

Pattern 5: Separate Create and Update Inputs​

# Create - all required fields are non-null
input CreateBookInput {
title: String!
authorId: ID!
isbn: String!
publishedYear: Int!
}

# Update - all fields optional for partial updates
input UpdateBookInput {
title: String
description: String
coverImageUrl: String
publishedYear: Int
}

type Mutation {
createBook(input: CreateBookInput!): Book!
updateBook(id: ID!, input: UpdateBookInput!): Book!
}

Pattern 6: Input Validation in Schema​

Use custom scalars for validation. Note that GraphQL's built-in scalars are only Int, Float, String, Boolean, and ID; URL, PositiveInt, DateTime, and friends come from the graphql-java-extended-scalars library and must be registered explicitly. That library does not ship an Email scalar - email validation is notoriously error-prone, so no one-size-fits-all coercion exists - which means an Email type has to be hand-written as a custom scalar, exactly like the ISBN example below:

scalar Email
scalar URL
scalar PositiveInt
scalar ISBN

input CreateUserInput {
email: Email! # Validates email format
profileUrl: URL # Validates URL format
age: PositiveInt! # Must be > 0
}

Implementation:

@Configuration
public class ScalarConfig {

@Bean
public RuntimeWiringConfigurer runtimeWiringConfigurer() {
return builder -> builder
.scalar(emailScalar())
.scalar(ExtendedScalars.Url)
.scalar(ExtendedScalars.PositiveInt)
.scalar(isbnScalar());
}

private GraphQLScalarType emailScalar() {
return GraphQLScalarType.newScalar()
.name("Email")
.description("A valid email address")
.coercing(new Coercing<String, String>() {
private static final String EMAIL_REGEX = "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$";

@Override
public String serialize(Object input) {
return input.toString();
}

@Override
public String parseValue(Object input) {
String email = input.toString();
if (!email.matches(EMAIL_REGEX)) {
throw new CoercingParseValueException("Invalid email format");
}
return email;
}

@Override
public String parseLiteral(Object input) {
return parseValue(((StringValue) input).getValue());
}
})
.build();
}

private GraphQLScalarType isbnScalar() {
return GraphQLScalarType.newScalar()
.name("ISBN")
.description("ISBN-13 format")
.coercing(new Coercing<String, String>() {
@Override
public String serialize(Object input) {
return input.toString();
}

@Override
public String parseValue(Object input) {
String isbn = input.toString().replaceAll("-", "");
if (!isbn.matches("\\d{13}")) {
throw new CoercingParseValueException("Invalid ISBN format");
}
return isbn;
}

@Override
public String parseLiteral(Object input) {
return parseValue(((StringValue) input).getValue());
}
})
.build();
}
}

Query Design Patterns​

Pattern 7: Entry Points by Use Case​

Design queries for how clients will use them:

type Query {
# Specific entry points - preferred
bookById(id: ID!): Book
bookByIsbn(isbn: ISBN!): Book
booksByAuthor(authorId: ID!): [Book!]!
featuredBooks: [Book!]!
newReleases(limit: Int = 10): [Book!]!

# Generic search for complex queries
searchBooks(
query: String
filter: BookFilter
first: Int
after: String
): BookConnection!
}

Avoid:

# Too generic - hard to optimize
type Query {
books(where: BookWhereInput): [Book!]!
}

Pattern 8: Viewer Pattern​

Model the authenticated user's perspective:

type Query {
viewer: Viewer
}

type Viewer {
id: ID!
user: User!

# User-specific data
library: [Book!]!
readingList: [Book!]!
recommendations: [Book!]!
notifications(unreadOnly: Boolean): [Notification!]!

# Permissions
canPublish: Boolean!
isAdmin: Boolean!
}

Client query:

query Viewer {
viewer {
user { name avatar }
notifications(unreadOnly: true) { message }
readingList { title author { name } }
}
}

Mutation Design Patterns​

Pattern 9: Descriptive Mutation Names​

Use verbs that describe the action:

type Mutation {
# Good - clear actions
createBook(input: CreateBookInput!): Book!
publishBook(id: ID!): Book!
archiveBook(id: ID!): Book!
addBookToReadingList(bookId: ID!): ReadingList!
removeBookFromReadingList(bookId: ID!): ReadingList!

# Bad - vague
book(input: BookInput!): Book!
updateBookStatus(id: ID!, status: String!): Book!
}

Pattern 10: Mutation Responses​

Return rich responses:

type Mutation {
createBook(input: CreateBookInput!): CreateBookResponse!
}

type CreateBookResponse {
book: Book
errors: [CreateBookError!]!
}

type CreateBookError {
field: [String!]
message: String!
code: CreateBookErrorCode!
}

enum CreateBookErrorCode {
DUPLICATE_ISBN
AUTHOR_NOT_FOUND
INVALID_PUBLICATION_DATE
PERMISSION_DENIED
}

Client handling:

mutation CreateBook {
createBook(input: { ... }) {
book {
id
title
}
errors {
field
message
code
}
}
}

Evolution Patterns​

Pattern 11: Deprecation​

Never remove fields suddenly:

type Book {
id: ID!
title: String!

# Deprecated field - will be removed
imageUrl: String @deprecated(reason: "Use coverImage.url instead")

# New structure
coverImage: Image
}

type Image {
url: String!
width: Int
height: Int
altText: String
}

Pattern 12: Feature Flags in Schema​

Gradually roll out features:

type Query {
# Experimental - may change
experimentalSearch(query: String!): SearchResult! @experimental

# Beta features
aiRecommendations: [Book!]! @beta
}

directive @experimental on FIELD_DEFINITION
directive @beta on FIELD_DEFINITION

Anti-Patterns to Avoid​

1. Exposing Database Structure​

# Bad - mirrors database tables
type Book {
book_id: Int!
author_fk: Int!
created_timestamp: String!
}

# Good - domain-focused
type Book {
id: ID!
author: Author!
createdAt: DateTime!
}

2. Giant Input Types​

# Bad - one input for everything
input BookInput {
title: String
authorId: ID
# ... 50 more fields
shouldPublish: Boolean
notifyFollowers: Boolean
}

# Good - specific inputs per operation
input CreateBookInput { ... }
input UpdateBookInput { ... }
input PublishBookInput { notifyFollowers: Boolean }

3. Stringly Typed Enums​

# Bad
type Book {
status: String! # "draft", "published", "archived"
}

# Good
type Book {
status: BookStatus!
}

enum BookStatus {
DRAFT
PUBLISHED
ARCHIVED
}

Schema Documentation​

Document everything:

"""
A book in the library catalog.

Books can be in various states (draft, published, archived) and
belong to exactly one author.
"""
type Book {
"Unique identifier for the book"
id: ID!

"The book's title as it appears on the cover"
title: String!

"""
The International Standard Book Number.
Must be in ISBN-13 format.
"""
isbn: ISBN!

"""
Publication year.
For upcoming books, this is the expected publication year.
"""
publishedYear: Int
}

Summary​

PatternUse Case
Nullable vs Non-NullCommunicate field requirements
Semantic TypesAdd meaning to primitives
Union TypesPolymorphic return types
InterfacesShared behavior across types
Separate InputsCreate vs Update operations
Viewer PatternUser-centric queries
Mutation responsesRich error handling
DeprecationGraceful schema evolution

A well-designed schema is self-documenting, hard to misuse, and easy to evolve. Invest time in design - your future self and your API consumers will thank you.

Next: Integrating Spring GraphQL with JPA - mapping entities to GraphQL types efficiently.


This post is organized the way section two tells you to organize a schema: split into named parts, each one small enough to read in one sitting.