Custom Scalars in Federated GraphQL Schemas

Federated GraphQL architectures demand strict type consistency across distributed services, and custom scalars are where that consistency most often breaks down silently. When extending Subgraph Implementation & Entity Resolution strategies, handling non-standard data types — UUIDs, ISO-8601 timestamps, monetary BigDecimal values, or encrypted tokens — becomes a critical engineering challenge precisely because the supergraph composition layer validates names but never inspects coercion logic. This guide details the implementation workflows, composition rules, runtime serialisation contracts, and performance trade-offs required to deploy custom scalars reliably across a federated environment, and it links out to the deeper guides on sharing custom scalars across multiple subgraphs and validating custom scalar inputs across subgraphs.

The Problem: Names Compose, Behaviour Does Not

A custom scalar in Apollo Federation v2 is two distinct artifacts that travel separately. The first is the SDL declaration — a single line, scalar DateTime — that the composition engine reads, merges by name, and bakes into the supergraph. The second is the resolver triad — serialize, parseValue, and parseLiteral — that lives only inside each subgraph’s runtime and is never visible to rover supergraph compose. The supergraph is therefore blind to the part that actually determines wire format.

This asymmetry produces the defining failure mode of custom scalars in a distributed system: two subgraphs declare the same scalar name, composition reports zero errors, and at runtime each subgraph emits a different string format for the same type. Clients receive "2024-01-15T10:30:00.000Z" from one path and "2024-01-15 10:30:00" from another, with no error anywhere in the pipeline to flag it. Everything you build around custom scalars in federation is, at bottom, a discipline for closing that gap between what composes and what executes.

Core Concepts Overview

This section breaks down into two focused guides, each covering one half of the consistency problem:

  • Sharing custom scalars across multiple subgraphs — how to package a single canonical scalar definition (SDL plus resolver map), distribute it as a versioned dependency, and keep every subgraph emitting byte-identical wire formats. This is the output side: guaranteeing serialize consistency.
  • Validating custom scalar inputs across subgraphs — how to enforce identical parseValue and parseLiteral rules so that an input accepted by one subgraph is never silently rejected (or worse, coerced differently) by another. This is the input side: guaranteeing parse symmetry.

The page below establishes the shared foundation both guides build on — the resolver triad, composition semantics, runtime contracts, and the performance envelope.

Anatomy of a Federated Custom Scalar

Custom scalars require an explicit SDL declaration and a mandatory resolver triad. The Apollo Router treats custom scalars as opaque payloads at the routing layer, shifting all validation, transformation, and error handling responsibility to the subgraph that owns the field.

SDL Declaration

Define the scalar in your subgraph’s schema. Federation composition merges identical scalar names across services at the name level — resolver logic must be kept consistent separately, by sharing a package rather than copying definitions.

extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key"])

scalar DateTime
scalar EncryptedToken

type User @key(fields: "id") {
  id: ID!
  createdAt: DateTime!
  sessionToken: EncryptedToken
}

Resolver Implementation

Attach the scalar to your GraphQL execution engine using the GraphQLScalarType constructor. Each function serves a distinct execution phase:

  • serialize: outbound transformation (subgraph resolver output → wire format sent toward the client)
  • parseValue: inbound transformation from query variables (JSON input)
  • parseLiteral: inbound transformation from static query literals (AST input)
import { GraphQLScalarType, Kind, GraphQLError } from 'graphql';

export const DateTimeScalar = new GraphQLScalarType({
  name: 'DateTime',
  description: 'ISO-8601 compliant date-time string',

  // Outbound: resolver returns a Date, client receives a string.
  serialize(value: unknown): string {
    if (!(value instanceof Date)) {
      throw new GraphQLError('DateTime serialize expects a Date instance');
    }
    return value.toISOString();
  },

  // Inbound from variables ($input): JSON string -> Date.
  parseValue(value: unknown): Date {
    if (typeof value !== 'string') {
      throw new GraphQLError('DateTime parseValue expects a string variable');
    }
    const parsed = new Date(value);
    if (isNaN(parsed.getTime())) {
      throw new GraphQLError('Invalid DateTime variable format');
    }
    return parsed;
  },

  // Inbound from inline literals: AST node -> Date.
  parseLiteral(ast): Date {
    if (ast.kind !== Kind.STRING) {
      throw new GraphQLError('DateTime parseLiteral expects a string literal');
    }
    const parsed = new Date(ast.value);
    if (isNaN(parsed.getTime())) {
      throw new GraphQLError('Invalid DateTime literal syntax');
    }
    return parsed;
  },
});

Architecture Diagram: Where a Scalar Is Touched

The diagram below traces a single DateTime value through the federation request path, showing exactly which component owns coercion at each hop. The router never transforms the value; it only relays bytes.

Custom scalar coercion path in Apollo Federation A client sends a DateTime variable; the router relays it unchanged to the subgraph, which parses, resolves, and serialises before the router relays the result back. Client sends string var Apollo Router opaque relay no coercion query plan only Subgraph parseValue resolve serialize request subquery serialised string response Coercion happens only at the subgraph The router validates the query plan, never the scalar payload owns the triad

Reading the diagram left to right: the client sends a DateTime as a JSON variable string; the router parses the query document and builds a query plan but passes the scalar value through untouched; the owning subgraph runs parseValue, resolves the field, and runs serialize to produce the outbound string. On the return path the router again relays bytes. Because every transformation is confined to the green box, two subgraphs that disagree about formatting produce divergent output with nothing in the purple box to reconcile them. That is the architectural reason sharing and validation both have to be solved at the package level rather than the router level.

Directive & Config Reference

Element Where it lives Composition-time vs runtime Notes
scalar Name (SDL) Subgraph schema Composition-time Merged by name across subgraphs; structural conflicts fail composition
serialize Subgraph resolver Runtime Internal value → wire format; missing it yields null payloads
parseValue Subgraph resolver Runtime Variable JSON → internal value; guards reject bad variables
parseLiteral Subgraph resolver Runtime Inline AST → internal value; runs during query validation
@shareable Object fields only Composition-time Not valid on scalar types — scalars merge implicitly
@inaccessible Schema element Composition-time Hide a scalar-typed field from the public supergraph
include_subgraph_errors router.yaml Runtime Surfaces serialise/parse failures from subgraphs in router logs

Canonical Implementation Pattern

The production-grade pattern keeps the SDL string and the resolver instance together in one importable module so a subgraph can never accept one without the other. This is the seed of the shared-package approach detailed in sharing custom scalars across multiple subgraphs.

// services/orders/src/schema.ts
import { buildSubgraphSchema } from '@apollo/subgraph';
import { gql } from 'graphql-tag';
import { DateTimeScalar } from '@company/shared-scalars';

const typeDefs = gql`
  extend schema
    @link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key"])

  scalar DateTime

  type Order @key(fields: "id") {
    id: ID!
    createdAt: DateTime!
    shippedAt: DateTime
  }
`;

export const schema = buildSubgraphSchema({
  typeDefs,
  resolvers: {
    // The single canonical instance is mapped to the SDL name.
    DateTime: DateTimeScalar,
    Order: {
      __resolveReference(ref: { id: string }) {
        return loadOrderById(ref.id);
      },
    },
  },
});

The contract is simple: the SDL name DateTime and the resolver key DateTime must match, and DateTimeScalar must be the same instance every other subgraph imports. Once that holds, the entity reference resolver (__resolveReference) returns a domain object whose createdAt is a Date, and serialize produces the canonical wire string regardless of which subgraph resolved the field.

Composition Pipeline Integration

The federation composition engine validates SDL alignment across independent subgraphs. It checks scalar naming consistency and structural validity — it does not inspect or execute resolver logic. Enforce pre-deployment validation with composition checks in CI:

# Validate one subgraph against the current published supergraph.
rover subgraph check "$APOLLO_GRAPH_REF" \
  --name orders \
  --schema ./services/orders/schema.graphql

# Compose locally to verify zero composition errors before publishing.
rover supergraph compose --config supergraph.yaml --output supergraph.graphql

# Publish once checks pass.
rover subgraph publish "$APOLLO_GRAPH_REF" \
  --name orders \
  --schema ./services/orders/schema.graphql \
  --routing-url https://orders.internal/graphql

Treat scalar definitions as immutable contracts once published to the registry. Any change to a scalar’s name or coercion semantics requires a coordinated multi-subgraph deployment, because the registry will happily accept a one-sided change that breaks wire consistency at runtime. Scalar contracts mirror entity-resolution contracts: just as implementing entity resolvers with @key directives demands exact field matching and deterministic reference resolution, scalar contracts demand identical naming and parsing semantics across every subgraph boundary.

Choosing What Deserves a Custom Scalar

Not every domain format justifies a custom scalar. Each one you mint becomes a contract that every subgraph must honour and every client must learn, so the bar should be deliberately high. A custom scalar earns its place when three conditions hold at once: the value has a strict, well-defined string representation; that representation is validated identically wherever the value appears; and the validation belongs in the type system rather than in business logic. ISO-8601 timestamps, RFC-4122 UUIDs, and fixed-precision monetary amounts all clear that bar. A free-form description field does not — it is just a String, and dressing it up as a scalar only adds a coercion surface with nothing to validate.

The opposite mistake is equally common: encoding structure that should be an object type into a single scalar. A geographic coordinate is tempting to model as scalar LatLng parsed from "51.5,-0.12", but the moment a client wants to select latitude without longitude, or the moment one subgraph wants to attach an accuracy radius, the opaque scalar fights you. Object types compose, support partial selection, and let the router plan field-by-field; scalars are atomic and opaque by design. Reach for a scalar only when the value is genuinely indivisible at the API boundary. When in doubt, model it as a type and let federation’s normal merge rules — the same ones behind using the @shareable directive for overlapping types — handle ownership.

A useful tie-breaker is to ask who owns the validation. If the rule (“amounts are always two decimal places, never negative”) is universal across the platform, a shared scalar centralises it in one place and is worth the contract. If the rule varies by context, a scalar forces a false consensus and you will end up with the semantic drift this whole section warns about. In that case prefer an input object with explicit fields, or distinct named scalars per context, over one overloaded name.

Nullability and Error Semantics

Custom scalars interact with GraphQL’s null-propagation rules in ways that surprise teams the first time a coercion throws. When serialize raises an error on a non-nullable field, the error bubbles up to the nearest nullable ancestor, potentially nulling out a large slice of the response. A single malformed createdAt on one entity in a list can therefore wipe the entire list if the list element is non-nullable. Decide deliberately whether a scalar field should be nullable: a nullable field lets one bad value degrade gracefully to null with an error entry, while a non-nullable field treats any coercion failure as a structural defect that fails its parent.

The corollary is that coercion errors must be precise and actionable. A serialize failure means the server produced data it cannot represent on the wire — that is a bug in the resolver or the data layer, and the error message should say which value failed. A parseValue/parseLiteral failure means the client sent something invalid — that is a client error, and the message should guide the caller toward the expected format. Conflating the two leaves operators unable to tell, from a log line alone, whether to page the on-call engineer or close the ticket as client misuse. Set extensions.code accordingly (INTERNAL_SERVER_ERROR versus BAD_USER_INPUT) so dashboards can route them differently. This discipline pays off most under the router error-surfacing configuration shown earlier, where subgraph errors flow back through the supergraph.

Cross-Section Integration Points

Custom scalars rarely live in isolation. They intersect with several other parts of a federated platform:

  • Entity keys. When a custom scalar appears in a @key, its serialize output becomes the identity string the router uses for reference resolution. Non-canonical serialisation here corrupts entity stitching — see optimizing reference resolvers for performance for why deterministic key strings matter.
  • Computed fields. A scalar field marked @requires is parsed in the subgraph after its dependencies resolve. Coordinate the parsing window with using @external and @requires for field resolution so the required value is available before coercion runs.
  • Authorization. Encrypted-token scalars frequently carry security context. Pair them with directive patterns for cross-service authorization so the value is decrypted and validated in one place rather than re-parsed per field.

Common Failure Modes & Composition Errors

  • Naming collisions. Subgraph A defines scalar DateTime, Subgraph B defines scalar Date. Composition succeeds for each independently, but clients and resolvers end up with two different types for the same concept. Standardise on one canonical name across the platform.
  • Missing serialize. Composition succeeds because the SDL is valid, but runtime queries return Cannot return null for non-nullable field when the engine has no way to produce the wire format. Always export the full triad together with the SDL.
  • Semantic drift. Identical scalar names with different validation rules — one subgraph accepts YYYY-MM-DD, another requires full ISO-8601. Both compose cleanly and produce inconsistent wire formats at runtime. This is the most insidious mode because no tool flags it; it is the central subject of validating custom scalar inputs across subgraphs.
  • Omitting parseLiteral. Static (inline) query values fail validation before execution begins, surfacing as opaque SyntaxError-style responses that point at the document rather than the scalar.
  • Staggered package updates. Deploying a new scalar version to one subgraph while others still consume the legacy version reproduces semantic drift on a schedule. Pin versions and roll forward in lockstep.

Performance & Scale Considerations

Custom scalar coercion executes on every field resolution, so heavy transformations directly drive subgraph CPU and p99 latency.

  • Synchronous parsing. GraphQL scalar resolvers are synchronous. Blocking work — cryptographic decryption, regex-heavy validation — stalls the Node.js event loop and degrades throughput under load. Keep coercion cheap and push expensive work to the business-logic layer.
  • Per-field cost. When a scalar field interacts with @requires or @external, the router may batch the underlying fetch, but coercion still runs once per field instance in the subgraph. A list of 1,000 entities each carrying a DateTime runs serialize 1,000 times.
  • Router overhead. The router performs zero scalar transformation; payload inspection happens only when telemetry or a custom plugin intercepts the trace.
Strategy Trade-off Implementation
LRU caching Memory vs CPU Cache deterministic transforms (string → validated UUID) with lru-cache
Regex precompilation Startup memory vs per-request CPU Compile validation patterns once at module init, not per call
Deferred parsing Complexity vs latency Return raw strings from resolvers and parse only where business logic needs the typed value

Enable observability for coercion failures at the router:

# router.yaml
telemetry:
  exporters:
    logging:
      stdout:
        enabled: true
        format: json
  apollo:
    errors:
      subgraph:
        all:
          send: true     # surface subgraph serialise/parse errors
          redact: false

Decision Guide

Use this checklist to decide how much machinery a given scalar needs:

Frequently Asked Questions

Does the GraphQL router validate custom scalar payloads?

No. The router treats custom scalars as opaque values at the routing layer and performs zero transformation or validation. All parsing, coercion, and error handling must be implemented explicitly within each subgraph’s resolver triad.

Can custom scalars be used as entity keys in federation?

Yes, but they must implement strict equality and deterministic serialisation. Using a non-canonical string format as a @key field degrades entity resolution and complicates cache invalidation across subgraphs, because the router compares the serialised representation.

Do I need the @shareable directive for custom scalars in Federation v2?

No. @shareable applies to object-type fields, not scalar types. Federation v2 merges identically named scalars automatically; the risk to manage is semantic drift in resolver logic, not directive declarations.

How do I handle backward compatibility when updating a custom scalar?

Implement versioned, dual-format parsing inside the resolver, keep both formats accepted during a coordinated transition window, and synchronise deployments across every dependent subgraph before removing the legacy path.

Why does composition succeed even when two subgraphs format the same scalar differently?

Because composition only sees SDL names, not resolver code. Identical names compose regardless of behaviour. Catching the divergence is the job of shared packages and contract tests, not rover supergraph compose.

Where should expensive scalar validation run?

Outside the synchronous resolver. Return the raw string from serialize/parseValue and run cryptographic or remote validation in the business-logic layer, or cache deterministic results so the hot path stays cheap.