Custom Scalar Types in GraphQL Micronaut: A Working Guide
If you've ever needed to add custom scalar types to a GraphQL application built on Micronaut, you've probably noticed the official documentation is thin. Most search results lead you to graphql-java directly, or to Spring Boot's GraphQL integration, or to Stack Overflow answers from 2021 that reference APIs which have since moved.
I spent a few hours figuring out what the working pattern actually looks like in 2026. This post is the guide I wish I'd found.
TL;DR
To register a custom scalar in a Micronaut GraphQL application, you need three things:
- A class implementing
graphql.schema.Coercing<I, O>for each scalar - A
GraphQLScalarTypedefinition for each, exposed as a Micronaut@Singletonbean or registered via configuration - The scalar registered with the schema, typically through a
GraphQLDataFetchers-style configuration class that builds theRuntimeWiring
Once registered, you reference the scalar by name in your .graphqls schema file and use it like any built-in type.
Why You'd Need a Custom Scalar
GraphQL ships with a small set of built-in scalars: Int, Float, String, Boolean, and ID. That's it. Anything else — dates, decimals, JSON, UUIDs, currency, email — is a custom scalar.
Three common cases I had to handle:
- DateTime in strict ISO 8601 format. The default
Stringscalar would accept anything; I needed strict validation at the schema boundary. - A numeric string. Identifiers that look like numbers but must preserve leading zeros and exact length (account numbers, reference codes). Storing them as
Intloses information. - A numeric string with a decimal point. Currency values where precision matters and floating-point representation is unacceptable.
If you find yourself thinking "this should really be validated at the schema level, not in the resolver" — that's a custom scalar.
The Anatomy of a Custom Scalar
Every custom scalar in graphql-java is built around the Coercing<I, O> interface, which has three methods:
serialize(Object dataFetcherResult)— converts the value returned by your resolver into a form the GraphQL response can carryparseValue(Object input)— handles values passed in via query variables (already JSON-parsed)parseLiteral(Object input)— handles values written inline in the query string (still as AST nodes)
The most common bug I see in custom scalars is implementing only parseValue and forgetting parseLiteral — or vice versa. Clients may switch between using literals and variables, and a scalar that handles only one will silently break in production.
Setting Up the Project
I'm assuming you have a working Micronaut application with the GraphQL module. If not, the relevant dependency in build.gradle is:
implementation("io.micronaut.graphql:micronaut-graphql")
implementation("com.graphql-java:graphql-java:21.5")Versions will move; check the latest in Maven Central before pinning.
Your project structure for this example:
src/main/
java/com/example/graphql/
scalars/
DateTimeIso8601Scalar.java
NumericStringScalar.java
DecimalStringScalar.java
GraphQLConfiguration.java
resources/
schema.graphqls
Scalar 1: DateTime in ISO 8601
package com.example.graphql.scalars;
import graphql.GraphQLContext;
import graphql.execution.CoercedVariables;
import graphql.language.StringValue;
import graphql.language.Value;
import graphql.schema.Coercing;
import graphql.schema.CoercingParseLiteralException;
import graphql.schema.CoercingParseValueException;
import graphql.schema.CoercingSerializeException;
import graphql.schema.GraphQLScalarType;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Locale;
public final class DateTimeIso8601Scalar {
public static final GraphQLScalarType INSTANCE = GraphQLScalarType.newScalar()
.name("DateTimeISO")
.description("DateTime in strict ISO 8601 format, e.g. 2026-05-06T14:30:00Z")
.coercing(new Coercing<OffsetDateTime, String>() {
@Override
public String serialize(Object dataFetcherResult,
GraphQLContext context,
Locale locale) throws CoercingSerializeException {
if (dataFetcherResult instanceof OffsetDateTime odt) {
return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(odt);
}
throw new CoercingSerializeException(
"Expected OffsetDateTime but got: "
+ (dataFetcherResult == null ? "null" : dataFetcherResult.getClass().getName()));
}
@Override
public OffsetDateTime parseValue(Object input,
GraphQLContext context,
Locale locale) throws CoercingParseValueException {
if (input == null) {
throw new CoercingParseValueException("DateTimeISO cannot be null");
}
try {
return OffsetDateTime.parse(input.toString(), DateTimeFormatter.ISO_OFFSET_DATE_TIME);
} catch (DateTimeParseException ex) {
throw new CoercingParseValueException(
"Value is not a valid ISO 8601 DateTime: " + input, ex);
}
}
@Override
public OffsetDateTime parseLiteral(Value<?> input,
CoercedVariables variables,
GraphQLContext context,
Locale locale) throws CoercingParseLiteralException {
if (input instanceof StringValue stringValue) {
try {
return OffsetDateTime.parse(stringValue.getValue(), DateTimeFormatter.ISO_OFFSET_DATE_TIME);
} catch (DateTimeParseException ex) {
throw new CoercingParseLiteralException(
"Literal is not a valid ISO 8601 DateTime: " + stringValue.getValue(), ex);
}
}
throw new CoercingParseLiteralException(
"Expected a StringValue for DateTimeISO, got: " + input);
}
})
.build();
private DateTimeIso8601Scalar() {}
}A few things worth pointing out:
- Strict format. Using
DateTimeFormatter.ISO_OFFSET_DATE_TIMErejects loosely-formatted inputs at the schema boundary. The resolver can assume it always receives a validOffsetDateTime. - Null handling is explicit.
parseValuethrows a clean exception on null instead of letting an NPE bubble up. This produces a meaningful GraphQL error rather than a 500. - Pattern-matching switch on input types. The newer signatures from
graphql-java21+ passGraphQLContextandLocale— older tutorials don't show these. If you see a tutorial withCoercingmethods that take only one argument, it's outdated.
Scalar 2: Numeric String
A "numeric string" is a string that must contain only digits. Useful for IDs that look numeric but must preserve format (leading zeros, fixed length).
package com.example.graphql.scalars;
import graphql.GraphQLContext;
import graphql.execution.CoercedVariables;
import graphql.language.StringValue;
import graphql.language.Value;
import graphql.schema.Coercing;
import graphql.schema.CoercingParseLiteralException;
import graphql.schema.CoercingParseValueException;
import graphql.schema.CoercingSerializeException;
import graphql.schema.GraphQLScalarType;
import java.util.Locale;
import java.util.regex.Pattern;
public final class NumericStringScalar {
private static final Pattern DIGITS_ONLY = Pattern.compile("^\\d+$");
public static final GraphQLScalarType INSTANCE = GraphQLScalarType.newScalar()
.name("NumericString")
.description("A string containing only ASCII digits. Preserves leading zeros and fixed length.")
.coercing(new Coercing<String, String>() {
@Override
public String serialize(Object dataFetcherResult,
GraphQLContext context,
Locale locale) throws CoercingSerializeException {
if (dataFetcherResult == null) {
throw new CoercingSerializeException("NumericString cannot be null on serialize");
}
String s = dataFetcherResult.toString();
if (!DIGITS_ONLY.matcher(s).matches()) {
throw new CoercingSerializeException("Value is not a numeric string: " + s);
}
return s;
}
@Override
public String parseValue(Object input,
GraphQLContext context,
Locale locale) throws CoercingParseValueException {
return validate(input);
}
@Override
public String parseLiteral(Value<?> input,
CoercedVariables variables,
GraphQLContext context,
Locale locale) throws CoercingParseLiteralException {
if (input instanceof StringValue stringValue) {
if (!DIGITS_ONLY.matcher(stringValue.getValue()).matches()) {
throw new CoercingParseLiteralException(
"Literal is not a numeric string: " + stringValue.getValue());
}
return stringValue.getValue();
}
throw new CoercingParseLiteralException(
"Expected StringValue for NumericString, got: " + input);
}
private String validate(Object input) {
if (input == null) {
throw new CoercingParseValueException("NumericString cannot be null");
}
String s = input.toString();
if (!DIGITS_ONLY.matcher(s).matches()) {
throw new CoercingParseValueException("Value is not a numeric string: " + s);
}
return s;
}
})
.build();
private NumericStringScalar() {}
}The validation runs in both parseValue and parseLiteral. A common mistake is to validate in only one — the value sneaks in through whichever path is unguarded.
Scalar 3: Numeric String with Decimal Point
For currency or precise decimal values, this scalar accepts a string that represents a decimal number — and parses it into a BigDecimal so the resolver doesn't lose precision.
package com.example.graphql.scalars;
import graphql.GraphQLContext;
import graphql.execution.CoercedVariables;
import graphql.language.StringValue;
import graphql.language.Value;
import graphql.schema.Coercing;
import graphql.schema.CoercingParseLiteralException;
import graphql.schema.CoercingParseValueException;
import graphql.schema.CoercingSerializeException;
import graphql.schema.GraphQLScalarType;
import java.math.BigDecimal;
import java.util.Locale;
import java.util.regex.Pattern;
public final class DecimalStringScalar {
private static final Pattern DECIMAL_PATTERN = Pattern.compile("^-?\\d+(\\.\\d+)?$");
public static final GraphQLScalarType INSTANCE = GraphQLScalarType.newScalar()
.name("DecimalString")
.description("Decimal number serialized as a string to preserve precision.")
.coercing(new Coercing<BigDecimal, String>() {
@Override
public String serialize(Object dataFetcherResult,
GraphQLContext context,
Locale locale) throws CoercingSerializeException {
if (dataFetcherResult instanceof BigDecimal bd) {
return bd.toPlainString();
}
throw new CoercingSerializeException(
"Expected BigDecimal but got: "
+ (dataFetcherResult == null ? "null" : dataFetcherResult.getClass().getName()));
}
@Override
public BigDecimal parseValue(Object input,
GraphQLContext context,
Locale locale) throws CoercingParseValueException {
if (input == null) {
throw new CoercingParseValueException("DecimalString cannot be null");
}
String s = input.toString();
if (!DECIMAL_PATTERN.matcher(s).matches()) {
throw new CoercingParseValueException("Not a valid decimal string: " + s);
}
return new BigDecimal(s);
}
@Override
public BigDecimal parseLiteral(Value<?> input,
CoercedVariables variables,
GraphQLContext context,
Locale locale) throws CoercingParseLiteralException {
if (input instanceof StringValue stringValue) {
if (!DECIMAL_PATTERN.matcher(stringValue.getValue()).matches()) {
throw new CoercingParseLiteralException(
"Literal is not a valid decimal string: " + stringValue.getValue());
}
return new BigDecimal(stringValue.getValue());
}
throw new CoercingParseLiteralException(
"Expected StringValue for DecimalString, got: " + input);
}
})
.build();
private DecimalStringScalar() {}
}BigDecimal.toPlainString() matters here. toString() on a BigDecimal can produce scientific notation for some values, which breaks contract with clients expecting a plain decimal string.
Wiring the Scalars into Micronaut
Here's where the Micronaut-specific part comes in. You need a configuration bean that builds the RuntimeWiring and registers each scalar.
package com.example.graphql;
import com.example.graphql.scalars.DateTimeIso8601Scalar;
import com.example.graphql.scalars.DecimalStringScalar;
import com.example.graphql.scalars.NumericStringScalar;
import graphql.GraphQL;
import graphql.schema.GraphQLSchema;
import graphql.schema.idl.RuntimeWiring;
import graphql.schema.idl.SchemaGenerator;
import graphql.schema.idl.SchemaParser;
import graphql.schema.idl.TypeDefinitionRegistry;
import io.micronaut.context.annotation.Factory;
import io.micronaut.core.io.ResourceResolver;
import jakarta.inject.Singleton;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.stream.Collectors;
@Factory
public class GraphQLConfiguration {
@Singleton
public GraphQL graphQL(ResourceResolver resourceResolver) throws Exception {
var schemaStream = resourceResolver
.getResourceAsStream("classpath:schema.graphqls")
.orElseThrow(() -> new IllegalStateException("schema.graphqls not found"));
String schemaText;
try (var reader = new BufferedReader(new InputStreamReader(schemaStream, StandardCharsets.UTF_8))) {
schemaText = reader.lines().collect(Collectors.joining("\n"));
}
TypeDefinitionRegistry typeRegistry = new SchemaParser().parse(schemaText);
RuntimeWiring runtimeWiring = RuntimeWiring.newRuntimeWiring()
.scalar(DateTimeIso8601Scalar.INSTANCE)
.scalar(NumericStringScalar.INSTANCE)
.scalar(DecimalStringScalar.INSTANCE)
// .type("Query", builder -> builder.dataFetcher("...", ...))
.build();
GraphQLSchema schema = new SchemaGenerator()
.makeExecutableSchema(typeRegistry, runtimeWiring);
return GraphQL.newGraphQL(schema).build();
}
}The key calls are RuntimeWiring.newRuntimeWiring().scalar(...) for each custom scalar. The annotation-only approach you'd reach for first — @GraphQLScalar or similar — does not handle this case. You need the runtime wiring.
Declaring the Scalars in the Schema
In schema.graphqls:
scalar DateTimeISO
scalar NumericString
scalar DecimalString
type Account {
id: NumericString!
balance: DecimalString!
lastUpdated: DateTimeISO!
}
type Query {
account(id: NumericString!): Account
}The scalar declarations at the top tell the schema parser these names exist. The runtime wiring tells the executor how to handle them.
Common Gotchas
After working through this, here are the issues most likely to bite you:
1. Outdated tutorials. Older versions of graphql-java had Coercing methods with different signatures. If you copy from a 2022 blog post, you'll get compile errors. Always check the version of graphql-java you're pulling in and match its API.
2. Forgetting parseLiteral. A scalar with only parseValue works perfectly when clients send variables — and silently fails when they inline values in the query. Every scalar needs all three methods.
3. Schema declaration without runtime wiring. Declaring scalar Foo in your schema file is not enough. Without the RuntimeWiring registration, you'll get "There is no scalar implementation for the named type" at startup.
4. Returning the wrong Java type from the resolver. Your resolver must return the type your Coercing expects in serialize. A resolver returning Date for a scalar implemented around OffsetDateTime will throw at serialization, not at compile time.
5. Trusting BigDecimal.toString(). It can return scientific notation. Use toPlainString() for any user-facing serialization.
Why Custom Scalars Are Worth the Effort
Once these are in place, your schema becomes self-documenting. A field typed DateTimeISO! tells every consumer — frontend, mobile, third-party integration — exactly what format to send and expect. Validation moves from your resolvers to the schema boundary, which means errors surface earlier and resolver code stays focused on business logic.
It's a few hours of one-time work that pays dividends every time a new client integrates.
Further Reading
-
graphql-javadocumentation on scalars - Micronaut GraphQL module documentation
- GraphQL specification — type system
If you've solved any of these in a cleaner way — especially around the runtime wiring step — I'd genuinely like to hear about it. The pattern above works, but I wouldn't bet it's the most idiomatic approach in 2026.