Schema Design Patterns for Large Spring GraphQL Applications

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
- Design for use cases, not database tables
- Make impossible states unrepresentable
- Prefer explicit over implicit
- 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 rather than 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 {
... 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:
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(ExtendedScalars.Email)
.scalar(ExtendedScalars.Url)
.scalar(ExtendedScalars.PositiveInt)
.scalar(isbnScalar());
}
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 {
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!): CreateBookPayload!
}
type CreateBookPayload {
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(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
| Pattern | Use Case |
|---|---|
| Nullable vs Non-Null | Communicate field requirements |
| Semantic Types | Add meaning to primitives |
| Union Types | Polymorphic return types |
| Interfaces | Shared behavior across types |
| Separate Inputs | Create vs Update operations |
| Viewer Pattern | User-centric queries |
| Mutation Payloads | Rich error handling |
| Deprecation | Graceful 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.