Sharing Custom Scalars Across Multiple Subgraphs

This guide shows the exact workflow for sharing one canonical custom scalar definition across every subgraph in an Apollo Federation v2 graph, so identical scalar names never drift into divergent wire formats at runtime.

When scaling GraphQL Federation, teams routinely duplicate scalar type definitions across independent services, and that duplication is the root of schema drift. The supergraph composes the names cleanly while the resolver logic quietly diverges. This page sits under Custom Scalars in Federated GraphQL Schemas within the broader Subgraph Implementation & Entity Resolution work, and it focuses narrowly on the output side of consistency: making serialize produce byte-identical strings everywhere.

When to Use This Pattern

  • You have two or more subgraphs that declare the same custom scalar (DateTime, UUID, BigDecimal, EncryptedToken) and need their wire formats to match exactly.
  • You are seeing clients receive different string formats for the same field depending on which subgraph resolved it.
  • You want platform-wide scalar updates to ship as a single versioned dependency bump rather than a hand-coordinated edit across many repositories.

Prerequisites

Why Scalar Duplication Breaks Federation

The Apollo Router enforces scalar name consistency during supergraph composition. If two subgraphs define a scalar with the same name, composition succeeds — the router merges the SDL definitions. The dangerous failure is not composition rejection but semantic drift: identical names with divergent serialize, parseValue, or parseLiteral implementations produce inconsistent wire formats across services.

For example, one subgraph serialises DateTime as "2024-01-15T10:30:00.000Z" while another emits "2024-01-15 10:30:00". The router relays both unchanged, so clients receive different formats depending on which subgraph resolved the field. Composition only fails when declarations conflict structurally (one declares scalar DateTime, another tries to redefine the name as an object type). The remedy is a single source of truth: consistent naming with centrally managed resolver logic, exactly the contract described in the parent guide on custom scalars.

Implementation Walkthrough

Treat the scalar as an infrastructure dependency rather than copy-pasted SDL. The migration is four steps:

  1. Extract inline scalar definitions from individual subgraph repositories.
  2. Publish a shared package that exports both the SDL string and the resolver instance.
  3. Update every subgraph to import the package and delete its local definition.
  4. Pin the package version across services so deployments stay synchronised.

1. The shared definition

Keep the SDL string and the resolver in one module so a subgraph can never import one without the other.

// packages/shared-scalars/src/DateTime.ts
import { GraphQLScalarType, Kind, GraphQLError } from 'graphql';

// One canonical wire format for the entire platform: ISO-8601, ms precision, UTC.
export const DateTimeScalar = new GraphQLScalarType({
  name: 'DateTime',
  description: 'ISO-8601 date-time string, millisecond precision, UTC (Z)',

  serialize(value: unknown): string {
    const date =
      value instanceof Date ? value :
      typeof value === 'string' ? new Date(value) :
      null;
    if (!date || isNaN(date.getTime())) {
      throw new GraphQLError('DateTime.serialize expected a Date or ISO string');
    }
    return date.toISOString();          // always "...000Z"
  },

  parseValue(value: unknown): Date {
    if (typeof value !== 'string') {
      throw new GraphQLError('DateTime.parseValue expected a string variable');
    }
    const date = new Date(value);
    if (isNaN(date.getTime())) throw new GraphQLError('Invalid DateTime variable');
    return date;
  },

  parseLiteral(ast): Date {
    if (ast.kind !== Kind.STRING) {
      throw new GraphQLError('DateTime.parseLiteral expected a string literal');
    }
    const date = new Date(ast.value);
    if (isNaN(date.getTime())) throw new GraphQLError('Invalid DateTime literal');
    return date;
  },
});

// Export the SDL alongside the resolver so they ship together.
export const dateTimeSDL = /* GraphQL */ `scalar DateTime`;

2. Consuming the package in a subgraph

Attach the imported instance to the schema builder. Never redefine coercion in a service-specific codebase.

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

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

  ${dateTimeSDL}

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

export const schema = buildSubgraphSchema({
  typeDefs,
  resolvers: {
    DateTime: DateTimeScalar,            // the one canonical instance
    Order: {
      __resolveReference(ref: { id: string }) {
        return loadOrderById(ref.id);
      },
    },
  },
});

Every other subgraph imports the same DateTimeScalar, so createdAt serialises identically no matter which service resolves it. Note that scalars are implicitly shareable in Federation v2 — do not add @shareable to a scalar declaration; it is not valid on scalar types.

Verification Steps

Compose before publishing

Run a local composition check so naming drift fails the build, not production:

#!/bin/bash
# scripts/validate-supergraph.sh
set -euo pipefail

echo "Checking orders subgraph against the published graph..."
rover subgraph check "$APOLLO_GRAPH_REF" \
  --name orders \
  --schema ./services/orders/schema.graphql

echo "Composing the supergraph locally..."
rover supergraph compose \
  --config ./supergraph-config.yaml \
  --output ./supergraph.graphql

echo "Composition succeeded; scalar names are consistent."

Contract-test the triad

Composition cannot test behaviour, so add a roundtrip test in the shared package. This is the gate that actually prevents drift.

// packages/shared-scalars/__tests__/DateTime.test.ts
import { Kind } from 'graphql';
import { DateTimeScalar } from '../src/DateTime';

describe('DateTimeScalar symmetry', () => {
  const iso = '2024-01-15T10:30:00.000Z';

  test('parseLiteral -> serialize roundtrip', () => {
    const parsed = DateTimeScalar.parseLiteral({ kind: Kind.STRING, value: iso }, {});
    expect(DateTimeScalar.serialize(parsed)).toBe(iso);
  });

  test('parseValue -> serialize roundtrip', () => {
    const parsed = DateTimeScalar.parseValue(iso);
    expect(DateTimeScalar.serialize(parsed)).toBe(iso);
  });

  test('normalises non-canonical input to the canonical format', () => {
    // Input without milliseconds must still serialise WITH them.
    expect(DateTimeScalar.serialize('2024-01-15T10:30:00Z')).toBe(iso);
  });

  test('rejects malformed input', () => {
    expect(() => DateTimeScalar.parseValue('not-a-date')).toThrow();
    expect(() => DateTimeScalar.parseValue(12345)).toThrow();
  });
});

Confirm the wire format end to end

Issue a query through the router and assert the exact JSON string:

query GetOrder($id: ID!) {
  order(id: $id) {
    id
    createdAt
  }
}
{
  "data": {
    "order": {
      "id": "ord_9f8e7d",
      "createdAt": "2024-01-15T10:30:00.000Z"
    }
  }
}

If a second subgraph that also exposes a DateTime field returns "2024-01-15T10:30:00Z" (no milliseconds) or a Unix timestamp, you have drift — and the fix is to bring that subgraph onto the same package version. The router will not coerce the difference away for you.

Sequencing the Rollout Safely

The migration steps above are safe in any order except one: you must not remove a subgraph’s local scalar definition before that subgraph imports the shared package, and you should not bump the shared package to a format-changing version until every consumer is on it. The safe sequence is additive first, subtractive last. Publish the shared package, switch each subgraph to import it (verifying with the roundtrip test that the imported behaviour matches the old local behaviour), and only then delete dead local definitions. Because Federation v2 merges identical scalar names without complaint, an intermediate state where some subgraphs use the shared instance and others still use a byte-identical local copy is harmless — the danger is only ever a difference in output, never duplication itself.

When the shared package must change the wire format — say, moving DateTime from second precision to millisecond precision — treat it as a coordinated, format-versioned rollout rather than a silent patch. Ship a version that accepts both formats on input but standardises output, deploy it everywhere, confirm via end-to-end queries that every path now emits the new format, and only then tighten input parsing in a follow-up release. Pinning exact versions across services makes this tractable: a single dependency-bump pull request per subgraph, mergeable in lockstep, gives you one atomic flip rather than a slow trickle of stragglers emitting the old format. This mirrors the immutable-contract discipline described in the parent guide on custom scalars: once a format is published to clients, changing it is a migration, not an edit.

A final sequencing note concerns entity keys. If a shared scalar is ever used inside a @key, its serialised form is the identity string the router compares during reference resolution. Changing that scalar’s output format changes entity identity, which can silently break stitching even when every subgraph agrees on the new format. Audit @key usage before touching a shared scalar’s serialize, and coordinate with the reference-resolution behaviour covered in optimizing reference resolvers for performance.

Common Mistakes & Gotchas

  • Duplicating resolver logic per service instead of importing one instance. Two implementations are two formats waiting to diverge.
  • Letting versions drift. Pin @company/shared-scalars to an exact version and bump every subgraph together; a staggered upgrade reintroduces drift on a schedule.
  • Adding @shareable to a scalar. It is only valid on object-type fields; scalars merge implicitly in Federation v2.
  • Mixing date libraries. Two subgraphs formatting DateTime with different libraries (or different timezone handling) produce different strings even with identical SDL. Standardise the library inside the package. Symmetric parse rules are covered in depth in validating custom scalar inputs across subgraphs.

Frequently Asked Questions

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

No. Scalars do not support @shareable — that directive is for object-type fields. Federation v2 merges identically named scalars automatically; the risk to manage is semantic drift in resolver logic, not directive declarations.

Can I override a shared scalar in a single subgraph?

If one subgraph emits a different wire format for the same scalar name, clients receive inconsistent output. If a service genuinely needs different behaviour, mint a distinctly named scalar (LegacyDateTime) and map it explicitly in that subgraph rather than diverging the shared one.

What happens if the gateway encounters same-named scalars that behave differently?

Composition still succeeds because the SDL is valid, but runtime output becomes unpredictable — different formats for the same type depending on the resolving subgraph. It is a semantic-consistency problem, not a composition error, which is exactly why a centralised package is essential.