Implementation Patterns
This guide covers best practices and patterns for implementing the code generated by the GraphQL Code Generator.
Pattern Selection Guide
Choose the right pattern based on your use case:
| Scenario | Pattern | Priority |
|---|---|---|
| Existing domain model matches GraphQL type | Type Mappings with @javaType | CRITICAL - Use this first |
| Type has >5 fields or complex logic | Separate Implementation Class | HIGH |
| Creating multiple instances of same complex type | Static Helper Method | HIGH |
| Transforming a collection to GraphQL types | Stream API | HIGH |
| Simple type (≤3 fields, used once) | Inline Anonymous Class | MEDIUM |
| Union or interface type resolution | Always add defensive error handling | REQUIRED |
Pattern 1: Type Mappings
Use when: You have existing domain models that map well to your GraphQL schema.
Why: Least code, single source of truth, automatic conversion by the framework.
Schema with @javaType directive:
1"""Root query type for the blog API."""2type Query {3"""Get a post by ID."""4post(id: ID!): Post56"""Get all posts."""7allPosts: [Post!]!89"""Get a user by ID."""10user(id: ID!): User11}1213"""Root mutation type."""14type Mutation {15"""Create a new post."""16createPost(input: CreatePostInput!): Post!1718"""Update an existing post."""19updatePost(id: ID!, input: UpdatePostInput!): Post!2021"""Delete a post."""22deletePost(id: ID!): Boolean!23}2425"""A blog post."""26type Post @javaType(class: "com.example.domain.Post") {27id: ID!28title: String!29content: String!30author: User!31publishedAt: Instant32}3334"""A user."""35type User @javaType(class: "com.example.domain.User") {36id: ID!37username: String!38email: String!39posts: [Post!]!40}4142"""Input for creating a post."""43input CreatePostInput {44title: String!45content: String!46authorId: ID!47}4849"""Input for updating a post."""50input UpdatePostInput {51title: String52content: String53}5455"""ISO 8601 instant scalar."""56scalar Instant5758"""Directive for mapping GraphQL types to Java classes."""59directive @javaType(60"""Fully qualified Java class name."""61class: String!62) on OBJECT | INTERFACE
Implementation:
1package com.example.patterns;23import com.example.generated.*;4import com.example.domain.User;5import com.example.service.UserService;67public class TypeMappingExample extends MySchemaContext {89private final UserService userService;1011public TypeMappingExample(UserService userService) {12this.userService = userService;13}1415@Override16public MyQuery query() {17return new MyQuery() {18@Override19public User user(String id) {20return userService.findById(id);21}22};23}2425@Override26protected MyUser toUser(User input) {27return new MyUser() {28@Override29public String id() {30return input.getId();31}3233@Override34public String username() {35return input.getUsername();36}3738@Override39public String email() {40return input.getEmail();41}42};43}44}
How It Works
- The
@javaType(class: "...")directive (or anaddTypeMappingbuilder call) tells the generator to use your domain class wherever that GraphQL type appears, so generated method signatures accept and return your domain objects directly - For each mapped type, the generated schema context declares an abstract converter—
protected abstract MyUser toUser(User input)—which you implement once - At resolution time, the framework calls your converter to turn the domain object into the generated GraphQL model
- Conversion logic lives in exactly one place, instead of at every query and mutation site
Benefits
- Query and mutation methods return domain objects directly (no conversion at the call site)
- A single, generated-and-enforced conversion method per mapped type
- Cleaner separation between domain model and GraphQL API
- Reduces boilerplate significantly
When NOT to Use
- When your domain model structure differs significantly from your GraphQL schema
- When you need complex field-level transformations
- When the domain model is mutable but you want immutable GraphQL types
Pattern 2: Separate Implementation Classes
Use when: Type has >5 fields OR contains complex field logic OR needs testing.
Why: Easier to test, maintain, and debug. Avoids deeply nested anonymous classes.
1package com.example.patterns;23import com.example.generated.*;4import com.example.domain.DomainProduct;5import java.util.List;67// Separate implementation class for complex types8class ProductImpl extends MyProduct {910private final DomainProduct domain;1112ProductImpl(DomainProduct domain) {13this.domain = domain;14}1516@Override17public String id() {18return domain.getId();19}2021@Override22public String name() {23return domain.getName();24}2526@Override27public Double price() {28return domain.getPrice();29}3031@Override32public String description() {33return domain.getDescription();34}3536@Override37public MyCategory category() {38return new CategoryImpl(domain.getCategory());39}4041@Override42public List<MyReview> reviews() {43return domain.getReviews().stream()44.map(ReviewImpl::new)45.toList();46}4748@Override49public Integer stockQuantity() {50return domain.getInventory().getQuantity();51}5253@Override54public Boolean inStock() {55return domain.getInventory().isInStock();56}57}
Benefits
- Clean, testable implementation
- Easy to debug and maintain
- Avoids deep nesting
- Can add helper methods
- Easier to mock for testing
Example Usage in Schema Context
1@Override2protected MyProduct toProduct(DomainProduct domain) {3return new ProductImpl(domain);4}
Pattern 3: Static Helper Methods
Use when: Creating multiple instances of the same complex type (e.g., in loops, collections).
Why: DRY principle, reusable, testable, keeps code clean.
1package com.example.patterns;23import com.example.generated.*;4import com.example.domain.DomainStep;5import java.util.ArrayList;6import java.util.List;78public class StaticHelperExample extends MySchemaContext {910@Override11public MyQuery query() {12return new MyQuery() {13@Override14public MyWorkflow workflow(String id) {15DomainWorkflow domainWorkflow = workflowService.findById(id);16return toWorkflow(domainWorkflow);17}18};19}2021private MyWorkflow toWorkflow(DomainWorkflow domain) {22return new MyWorkflow() {23@Override24public String id() {25return domain.getId();26}2728@Override29public String name() {30return domain.getName();31}3233@Override34public List<MyStep> steps() {35List<MyStep> result = new ArrayList<>();36int index = 0;3738// Use helper method in loop39for (DomainStep domainStep : domain.getSteps()) {40result.add(createStep(String.valueOf(index++), domainStep));41}4243return result;44}45};46}4748// Static helper method for reusable type creation49private static MyStep createStep(String id, DomainStep domainStep) {50return new MyStep() {51@Override52public String id() {53return id;54}5556@Override57public String name() {58return domainStep.getName();59}6061@Override62public String description() {63return domainStep.getDescription();64}6566@Override67public Integer order() {68return domainStep.getOrder();69}7071@Override72public Boolean required() {73return domainStep.isRequired();74}75};76}77}
Benefits
- Avoids code duplication
- Easy to test in isolation
- Can be reused across multiple methods
- Keeps the main implementation clean
- Easier to maintain
When to Use
- In loops where you create the same type multiple times
- When the same conversion logic is needed in multiple places
- For complex types that would clutter inline code
Pattern 4: Stream API for Collections
Use when: Transforming a collection to GraphQL types.
Why: Clean, functional, readable code.
1package com.example.patterns;23import com.example.generated.*;4import com.example.domain.DomainProduct;5import java.util.List;6import java.util.stream.Collectors;78public class StreamAPIExample extends MySchemaContext {910@Override11public MyQuery query() {12return new MyQuery() {13@Override14public List<MyProduct> products() {15List<DomainProduct> domainProducts = productService.findAll();1617// Use Stream API for clean collection transformations18return domainProducts.stream()19.map(StreamAPIExample.this::toProduct)20.collect(Collectors.toList());21}2223@Override24public List<MyProduct> searchProducts(String query) {25return productService.search(query).stream()26.filter(p -> p.isActive())27.map(StreamAPIExample.this::toProduct)28.collect(Collectors.toList());29}30};31}3233private MyProduct toProduct(DomainProduct domain) {34return new MyProduct() {35@Override36public String id() {37return domain.getId();38}3940@Override41public String name() {42return domain.getName();43}4445@Override46public Double price() {47return domain.getPrice();48}49};50}51}
Benefits
- Readable and concise
- Familiar to Java developers
- Easy to chain with filters, maps, etc.
- Encourages immutability
- Better performance with parallel streams (when appropriate)
Common Stream Operations
1// Filtering2domainProducts.stream()3.filter(p -> p.isActive())4.map(this::toProduct)5.collect(Collectors.toList());67// Mapping with index8IntStream.range(0, items.size())9.mapToObj(i -> createItem(String.valueOf(i), items.get(i)))10.collect(Collectors.toList());1112// Grouping13domainProducts.stream()14.collect(Collectors.groupingBy(15p -> p.getCategory(),16Collectors.mapping(this::toProduct, Collectors.toList())17));
Pattern 5: Inline Anonymous Classes
Use when: Simple type (≤3 fields, used once).
Why: Quick and straightforward for simple cases.
1package com.example.patterns;23import com.example.generated.*;45public class InlineAnonymousExample extends MySchemaContext {67@Override8public MyQuery query() {9return new MyQuery() {10@Override11public MyMetadata metadata() {12// Inline anonymous class for simple types13return new MyMetadata() {14@Override15public String version() {16return "1.0.0";17}1819@Override20public String environment() {21return System.getenv("APP_ENV");22}2324@Override25public Boolean maintenanceMode() {26return false;27}28};29}30};31}32}
When to Use
- Simple types with 3 or fewer fields
- Types used only once in your codebase
- Static or configuration data
- When the logic is trivial
When NOT to Use
- Complex types with many fields
- Types with complex field resolution logic
- When you need to test the implementation in isolation
- When the same type appears in multiple places
Complete Example: Blog API
This example demonstrates all recommended patterns working together in a realistic scenario.
Schema
1"""Root query type for the blog API."""2type Query {3"""Get a post by ID."""4post(id: ID!): Post56"""Get all posts."""7allPosts: [Post!]!89"""Get a user by ID."""10user(id: ID!): User11}1213"""Root mutation type."""14type Mutation {15"""Create a new post."""16createPost(input: CreatePostInput!): Post!1718"""Update an existing post."""19updatePost(id: ID!, input: UpdatePostInput!): Post!2021"""Delete a post."""22deletePost(id: ID!): Boolean!23}2425"""A blog post."""26type Post @javaType(class: "com.example.domain.Post") {27id: ID!28title: String!29content: String!30author: User!31publishedAt: Instant32}3334"""A user."""35type User @javaType(class: "com.example.domain.User") {36id: ID!37username: String!38email: String!39posts: [Post!]!40}4142"""Input for creating a post."""43input CreatePostInput {44title: String!45content: String!46authorId: ID!47}4849"""Input for updating a post."""50input UpdatePostInput {51title: String52content: String53}5455"""ISO 8601 instant scalar."""56scalar Instant5758"""Directive for mapping GraphQL types to Java classes."""59directive @javaType(60"""Fully qualified Java class name."""61class: String!62) on OBJECT | INTERFACE
Code Generator
1package com.example.blog.codegen;23import com.psddev.graphql.schema.codegen.GraphQLCodeGenerator;4import com.psddev.graphql.schema.types.time.InstantScalar;5import java.nio.file.Path;67public class BlogCodeGenerator {8public static void main(String[] args) {9Path projectRoot = Path.of("").toAbsolutePath();1011new GraphQLCodeGenerator.Builder()12.sdlPath(projectRoot.resolve("src/main/resources/graphql/blog.graphql"))13.generatedSourcesOutputDir(projectRoot.resolve("build/generated/graphql"))14.generatedSourcesPackageName("com.example.blog.generated")15.classNamePrefix("Blog")16.addCustomScalar("Instant", InstantScalar.class)17.build()18.generate();19}20}
Implementation
1package com.example.blog;23import com.example.blog.generated.*;4import com.example.domain.Post;5import com.example.domain.User;6import com.example.service.PostService;7import com.example.service.UserService;8import javax.inject.Inject;9import java.util.List;1011public class BlogSchemaContextImpl extends BlogSchemaContext {1213private final PostService postService;14private final UserService userService;1516@Inject17public BlogSchemaContextImpl(PostService postService, UserService userService) {18this.postService = postService;19this.userService = userService;20}2122@Override23public BlogQuery query() {24return new BlogQuery() {25@Override26public Post post(String id) {27return postService.findById(id);28}2930@Override31public List<Post> allPosts() {32return postService.findAll();33}3435@Override36public User user(String id) {37return userService.findById(id);38}39};40}4142@Override43public BlogMutation mutation() {44return new BlogMutation() {45@Override46public Post createPost(BlogCreatePostInput input) {47return postService.create(48input.title(),49input.content(),50input.authorId()51);52}5354@Override55public Post updatePost(String id, BlogUpdatePostInput input) {56return postService.update(id, input.title(), input.content());57}5859@Override60public Boolean deletePost(String id) {61return postService.delete(id);62}63};64}6566@Override67protected BlogPost toPost(Post input) {68return new BlogPost() {69@Override70public String id() {71return input.getId();72}7374@Override75public String title() {76return input.getTitle();77}7879@Override80public String content() {81return input.getContent();82}8384@Override85public User author() {86return input.getAuthor();87}8889@Override90public java.time.Instant publishedAt() {91return input.getPublishedAt();92}93};94}9596@Override97protected BlogUser toUser(User input) {98return new BlogUser() {99@Override100public String id() {101return input.getId();102}103104@Override105public String username() {106return input.getUsername();107}108109@Override110public String email() {111return input.getEmail();112}113114@Override115public List<Post> posts() {116return postService.findByAuthor(input.getId());117}118};119}120}
Key Patterns Used
- Pattern 1:
@javaTypedirective for type mappings (Post, User) - Pattern 4: Stream API would be used for collection transformations if needed
- Pattern 5: Inline anonymous classes for query/mutation implementations
- Pattern 1: Query methods return domain objects directly
Union and Interface Resolution
A GraphQL union becomes a generated Java interface (e.g. MySearchResult), and every member type's generated model class implements it (MyProduct, MyArticle). Resolution is automatic: the generated union type generator inspects which model class your returned object is an instance of—you never write a type resolver. Your job is simply to return instances of the generated models:
1@Override2public MySearchResult search(String query) {3Object result = searchService.search(query);45if (result instanceof DomainProduct) {6return toProduct((DomainProduct) result); // a MyProduct, which implements MySearchResult7} else if (result instanceof DomainArticle) {8return toArticle((DomainArticle) result); // a MyArticle, which implements MySearchResult9} else if (result == null) {10return null;11} else {12throw new IllegalStateException(13"Unexpected search result type: " + result.getClass().getName());14}15}
Union Type Best Practices
- Always handle all possible types: Don't leave cases unhandled
- Add a defensive else clause: Throw an exception for unexpected types, with the type name in the message
- Return the generated models: Only instances of generated member classes resolve; arbitrary objects fail at runtime
Error Handling Patterns
Query Methods
1@Override2public MyPost post(String id) {3DomainPost post = postService.findById(id);45// Return null for not found (GraphQL semantics)6if (post == null) {7return null;8}910return toPost(post);11}
Mutation Methods
1@Override2public MyPost createPost(MyCreatePostInput input) {3try {4DomainPost post = postService.create(5input.title(),6input.content(),7input.authorId()8);9return toPost(post);10} catch (ValidationException e) {11// Let validation exceptions propagate to GraphQL errors12throw e;13} catch (Exception e) {14// Wrap unexpected exceptions with context15throw new RuntimeException(16"Failed to create post: " + e.getMessage(),17e18);19}20}
Performance Optimization
Lazy Loading
For expensive fields that might not be queried:
1package com.example.performance;23import java.util.function.Supplier;4import java.util.List;5import java.util.stream.Collectors;67public class LazyLoadingSchemaContext extends MySchemaContext {89/**10* Use Suppliers for expensive fields that might not always be accessed.11*/12private MyProduct toProductWithLazyLoading(DomainProduct domain) {13return new MyProduct() {14// Eager fields - always loaded15@Override16public String id() {17return domain.getId();18}1920@Override21public String name() {22return domain.getName();23}2425// Lazy field - only loaded if accessed26private Supplier<List<MyReview>> reviewsSupplier;2728@Override29public List<MyReview> reviews() {30if (reviewsSupplier == null) {31reviewsSupplier = () -> {32List<DomainReview> domainReviews = reviewService.getReviews(domain.getId());33return domainReviews.stream()34.map(this::toReview)35.collect(Collectors.toList());36};37}38return reviewsSupplier.get();39}4041// Lazy field with caching - loaded once if accessed42private List<MyRelatedProduct> relatedProducts;4344@Override45public List<MyRelatedProduct> relatedProducts() {46if (relatedProducts == null) {47List<DomainProduct> related = productService.findRelated(domain.getId());48relatedProducts = related.stream()49.map(LazyLoadingSchemaContext.this::toProduct)50.collect(Collectors.toList());51}52return relatedProducts;53}54};55}56}
Caching
For frequently accessed data:
1package com.example.performance;23import com.google.common.cache.Cache;4import com.google.common.cache.CacheBuilder;5import java.util.concurrent.TimeUnit;67public class CachingSchemaContext extends MySchemaContext {89private final Cache<String, MyProduct> productCache;10private final Cache<String, MyUser> userCache;1112public CachingSchemaContext(ProductService productService, UserService userService) {13super(productService, userService);1415// Configure caches with appropriate TTL and size limits16this.productCache = CacheBuilder.newBuilder()17.maximumSize(1000)18.expireAfterWrite(5, TimeUnit.MINUTES)19.build();2021this.userCache = CacheBuilder.newBuilder()22.maximumSize(500)23.expireAfterWrite(10, TimeUnit.MINUTES)24.build();25}2627@Override28protected MyProduct toProduct(DomainProduct domain) {29return productCache.get(domain.getId(), () -> super.toProduct(domain));30}3132@Override33protected MyUser toUser(DomainUser domain) {34return userCache.get(domain.getId(), () -> super.toUser(domain));35}3637public void invalidateProduct(String id) {38productCache.invalidate(id);39}4041public void invalidateUser(String id) {42userCache.invalidate(id);43}44}
Pagination
For large result sets:
1package com.example.performance;23import java.util.Base64;4import java.nio.charset.StandardCharsets;5import java.util.List;6import java.util.stream.Collectors;7import java.util.stream.IntStream;89public class PaginationSchemaContext extends MySchemaContext {1011@Override12public MyQuery query() {13return new MyQuery() {14@Override15public MyProductConnection products(Integer first, String after, Integer last, String before) {16// Decode cursors17Integer afterOffset = after != null ? decodeCursor(after) : null;18Integer beforeOffset = before != null ? decodeCursor(before) : null;1920// Calculate offset and limit21int offset = afterOffset != null ? afterOffset + 1 : 0;22int limit = first != null ? first : (last != null ? last : 20);2324// Fetch from database with offset and limit25PaginatedResult<DomainProduct> result = productService.findPaginated(offset, limit);2627return new MyProductConnection() {28@Override29public List<MyProductEdge> edges() {30List<DomainProduct> products = result.getItems();31return IntStream.range(0, products.size())32.mapToObj(index -> {33DomainProduct product = products.get(index);34return new MyProductEdge() {35@Override36public MyProduct node() {37return toProduct(product);38}3940@Override41public String cursor() {42return encodeCursor(offset + index);43}44};45})46.collect(Collectors.toList());47}4849@Override50public MyPageInfo pageInfo() {51return new MyPageInfo() {52@Override53public Boolean hasNextPage() {54return result.hasMore();55}5657@Override58public Boolean hasPreviousPage() {59return offset > 0;60}6162@Override63public String startCursor() {64return result.isEmpty() ? null : encodeCursor(offset);65}6667@Override68public String endCursor() {69return result.isEmpty() ? null70: encodeCursor(offset + result.getItems().size() - 1);71}72};73}7475@Override76public Integer totalCount() {77// Optional: Cache this or make it optional in schema78return productService.count();79}80};81}82};83}8485private String encodeCursor(int offset) {86return Base64.getEncoder().encodeToString(87String.valueOf(offset).getBytes(StandardCharsets.UTF_8)88);89}9091private Integer decodeCursor(String cursor) {92try {93byte[] decoded = Base64.getDecoder().decode(cursor);94return Integer.parseInt(new String(decoded, StandardCharsets.UTF_8));95} catch (Exception e) {96throw new IllegalArgumentException("Invalid cursor: " + cursor);97}98}99}
Testing Patterns
Unit Testing Schema Context
1class MySchemaContextImplTest {2@Mock3private ProductService productService;45private MySchemaContextImpl context;67@BeforeEach8void setUp() {9MockitoAnnotations.openMocks(this);10context = new MySchemaContextImpl(productService);11}1213@Test14void testGetProduct() {15// Given16DomainProduct domainProduct = new DomainProduct("1", "Test", 9.99);17when(productService.findById("1")).thenReturn(domainProduct);1819// When20MyProduct result = context.query().product("1");2122// Then23assertNotNull(result);24assertEquals("1", result.id());25assertEquals("Test", result.name());26verify(productService).findById("1");27}28}
Testing Separate Implementation Classes
1class ProductImplTest {2@Test3void testProductImplementation() {4// Given5DomainProduct domain = new DomainProduct("1", "Test", 9.99);67// When8ProductImpl product = new ProductImpl(domain);910// Then11assertEquals("1", product.id());12assertEquals("Test", product.name());13assertEquals(9.99, product.price());14}15}
Best Practices Summary
- Type mappings with
@javaType- Use this first for the biggest wins - Separate implementation classes - Use for complex types
- Static helper methods - Use for reusable type creation
- Stream API - Use for collection transformations
- Inline anonymous classes - Only for simple, one-off types
- Defensive error handling - Always for union/interface resolution
- Lazy loading - For expensive fields
- Caching - For frequently accessed data
- Proper testing - Unit test your implementations
Next Steps
- Advanced Examples - See these patterns in complex scenarios
- Troubleshooting - Common issues and solutions