Skip to main content

Schema Design Patterns for Large Spring GraphQL Applications

· 6 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 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

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 PayloadsRich 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.