← Back to blog
GraphQLMicronautJavaBackend

Custom Scalar Types in GraphQL Micronaut: A Working Guide

RK

Ravi Kumar

May 6, 2026 · 10 min read

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:

  1. A class implementing graphql.schema.Coercing<I, O> for each scalar
  2. A GraphQLScalarType definition for each, exposed as a Micronaut @Singleton bean or registered via configuration
  3. The scalar registered with the schema, typically through a GraphQLDataFetchers-style configuration class that builds the RuntimeWiring

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 String scalar 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 Int loses 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 carry
  • parseValue(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")
groovy

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() {}
}
java

A few things worth pointing out:

  • Strict format. Using DateTimeFormatter.ISO_OFFSET_DATE_TIME rejects loosely-formatted inputs at the schema boundary. The resolver can assume it always receives a valid OffsetDateTime.
  • Null handling is explicit. parseValue throws 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-java 21+ pass GraphQLContext and Locale — older tutorials don't show these. If you see a tutorial with Coercing methods 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() {}
}
java

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() {}
}
java

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();
    }
}
java

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
}
graphql

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

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.