One Schema, Three Languages: Code Generation as Communication Architecture

A practical guide to reducing Java, Python, and TypeScript contract drift without confusing generated types for runtime or domain guarantees.

The difficult part of connecting technologies is rarely moving bytes. HTTP, queues, WebSockets, browser bridges, and embedded Chromium can all move JSON without understanding it.

The difficult part is preserving meaning.

A Java service may call a field requestId, a Python worker may treat it as optional, and a TypeScript application may assume it is always present. One side may distinguish an absent value from null; another may silently replace both with a default. A Java long may contain an integer that a browser cannot represent exactly. A generated interface may look safe while its runtime accepts arbitrary input because the type disappeared during compilation.

Those are not transport failures. The transport delivered the disagreement perfectly.

Schema-driven code generation can make that disagreement visible. A versioned structural contract becomes the source for bindings, codecs, validators, andin a few casestransport descriptors in every participating language. Instead of asking three implementations to remember the same agreement, the platform gives them one agreement and generates the mechanical parts.

The claim is deliberately limited: generation reduces representational drift across Java, Python, and TypeScript; validation, compatibility policy, governance, and domain ownership are still required to preserve meaning.

The Structural Boundary Has an Owner

Informal integration usually begins with duplicated types. The producer declares an object. The consumer writes a similar object. A fixture adds a third representation. Documentation adds a fourth. Each copy then evolves under local pressure: naming conventions, framework defaults, serialisation libraries, and shortcuts.

The result is a distributed data model with no clear owner.

Code generation changes the ownership model, but it should not overclaim it. The schema is authoritative for the structural wire contract: field names, shape, bounds, and representation. It does not own the meaning of a valid command in the current business state.

flowchart LR
    A[Canonical wire contract] --> B[Strict parser and normalised model]
    B --> C[Java wire DTO, codec, validator]
    B --> D[Python wire DTO, codec, validator]
    B --> E[TypeScript wire DTO, codec, validator]
    C --> F[Java domain adapter]
    D --> G[Python domain adapter]
    E --> H[TypeScript domain adapter]
    F --> I[Java domain service]
    G --> J[Python worker]
    H --> K[Browser application]

The division of responsibility is useful precisely because it is narrow:

OwnerResponsibility
Domain service or aggregateBusiness invariants, current state, authorisation decisions, and side effects
Contract stewardStructural wire semantics, compatibility policy, consumer review, and breaking-change gates
Generator/platform teamContract language, normalisation, emitted codecs and validators, reproducibility, and conformance evidence
Runtime/deployment ownerRouting, rollout, retained-message handling, incident response, and recovery

A browser binding can describe an equip-item action, but it must not decide whether an authenticated player owns the item. A worker contract can carry an analysis mode, but it must not become the canonical owner of asset state. The schema controls what an input can represent; owning code decides whether the represented action is allowed.

Define the Contract Language Before Generating It

The following YAML is an illustrative custom contract envelope, not YAML magically interpreted as a universal schema. Its schema member declares the supported JSON Schema dialect and subset. The envelope also carries routing metadata which is not JSON Schema.

name: asset-analysis-request
wireVersion: 1
destination: asset.analysis.request.v1
schemaDialect: https://json-schema.org/draft/2020-12/schema
schema:
  type: object
  additionalProperties: false
  required: [wireVersion, requestId, assetId, revision, mode]
  properties:
    wireVersion:
      type: integer
      const: 1
    requestId:
      type: string
      format: uuid
    assetId:
      type: string
      minLength: 1
      maxLength: 128
    revision:
      type: integer
      minimum: 1
      maximum: 9007199254740991
    mode:
      type: string
      enum: [FAST, COMPLETE]
    options:
      type: object
      additionalProperties: false
      properties:
        generatePreview:
          type: boolean
        maximumWarnings:
          type: integer
          minimum: 0
          maximum: 100

For this example, the contract language supports the listed object, string, integer, enum, const, and format features~nothing else. An unsupported keyword is a generation error, not a best-effort omission. The YAML parser must reject duplicate mapping keys before normalisation; a later map cannot recover which of two duplicate fields was intended. The compiler must also define coercion rather than inherit it from a target library: this contract rejects a numeric string for revision, 1 for a boolean, and "FAST" with surrounding whitespace.

format: uuid needs the same care. JSON Schema defines separate format vocabularies, including assertion semantics; merely writing a format does not guarantee every implementation rejects an invalid value. The contract must require an assertion-capable validator or explicit UUID check in each codec, and should emit one UUID spelling on output. The relevant JSON Schema format vocabulary makes that distinction explicit.

The choices above do real work:

  • wireVersion names this message representation, not the release that happens to send it.
  • requestId supports correlation only when it is generated once and reused for one logical operation. It is not an idempotency key.
  • revision gives owning code enough information to atomically reject an obsolete result. It does not, by itself, stop stale application.
  • The upper integer bound is JavaScript’s maximum safe integer, not Java’s maximum long.
  • options may be absent or an object. Explicit options: null is invalid.
  • Both objects reject unknown fields, so a misspelling cannot masquerade as an ignored extension.

String limits need a policy too. For the declared JSON Schema Draft 2020-12 dialect, maxLength counts Unicode code points, so a supplementary-plane character counts as one rather than two UTF-16 code units. The safe decoder must reject malformed Unicode input, including isolated UTF-16 surrogate escapes, during decoding before length validation; RFC 8259 notes the interoperability risk. Any other counting unit requires an explicit custom dialect or keyword; it is not the meaning of this standard keyword. This remains separate from limits on total encoded bytes, decompressed bytes, JSON nesting, tokens, or collection cardinality.

Generate a Complete Boundary Surface, Not Convincing Type Signatures

The following are abridged generated wire signatures, not complete validators. The raw bytes must be decoded and structurally validated before a lossy DTO construction or a domain adapter.

public record AssetAnalysisRequestWire(
    int wireVersion,
    UUID requestId,
    String assetId,
    long revision,
    AnalysisMode mode,
    Optional<AssetAnalysisOptionsWire> options
) {}

// Generated codec: decodeAndValidate(byte[]) and encodeCanonical(AssetAnalysisRequestWire)
@dataclass(frozen=True)
class AssetAnalysisRequestWire:
    wire_version: Literal[1]
    request_id: UUID
    asset_id: str
    revision: int
    mode: AnalysisMode
    options: AssetAnalysisOptionsWire | Unset = UNSET

# Generated codec: decode_and_validate(bytes) and encode_canonical(message)
export interface AssetAnalysisRequestWire {
  wireVersion: 1;
  requestId: string;
  assetId: string;
  revision: number;
  mode: 'FAST' | 'COMPLETE';
  options?: AssetAnalysisOptionsWire;
}

export declare function decodeAndValidateAssetAnalysisRequest(
  bytes: Uint8Array,
): AssetAnalysisRequestWire;
export declare function encodeCanonicalAssetAnalysisRequest(
  message: AssetAnalysisRequestWire,
): Uint8Array;

The signatures alone do not establish the contract. Java records are concise carriers, but records do not automatically validate their components or remember whether a null came from an absent JSON member. Pydantic defaults, aliases, and coercion are configuration-dependent; aliases and strict mode need deliberate configuration (aliases, strict mode). TypeScript types and assertions are erased, and as AssetAnalysisRequestWire performs no runtime checking (TypeScript’s documentation). Library and version choices therefore matter even when the architectural pattern does not favour one library.

The generator should produce, or bind to, the following capabilities consistently:

Contract ruleJava mappingPython mappingTypeScript mappingRequired runtime behaviour
Required wireVersion, requestId, assetId, revision, modeRecord components after codec validationRequired constructor fields after codec validationRequired properties after decoder validationReject absence before constructing a model
Optional options, never nullOptional<Options> only after raw validator rejects nullUnset represents absence; None is not a valid wire valueoptions?: Options; decoder rejects nullPreserve absent versus null at the parser boundary
camelCase wire namesGenerated JSON codec namesGenerated aliases such as wireVersion and requestIdNative property namesDecode and encode canonical wire names, not target-language guesses
Integer bounds and safe rangeChecked numeric parser plus range validationStrict integer parser plus range validationNumber parser verifies finite safe integer and rangeReject strings, booleans, fractions, and out-of-range integers
Closed objects and enumsCodec rejects unknown members and unknown enum valuesValidator forbids extras and unknown enum valuesRuntime validator rejects bothStatic types are insufficient
DefaultsExplicit normalisation rule, if the contract defines oneSame rule, not an incidental model defaultSame rule, not ?? in a clientApply at one specified phase or omit defaults entirely
Canonical fixture encodingUTF-8 JSON encoderUTF-8 JSON encoderUTF-8 JSON encoderEmit schema-order camelCase keys, omitted absent optionals, lowercase hyphenated UUIDs, canonical enum strings, and the defined byte form below

For byte-comparison fixtures and checksums, this article’s canonical fixture encoding is UTF-8 JSON with object keys in declared schema order; JSON numbers spelt as decimal integers without exponent notation; Unicode left unnormalised and unescaped except for JSON’s required control-character escapes; no insignificant whitespace; and no final newline. This is a deliberately exact fixture encoding, not a claim that ordinary semantically equivalent JSON must have identical bytes. Any boundary that compares bytes must use this encoder rather than a target language’s default JSON writer.

The generated surface should normally stop at wire DTOs, codecs, runtime validators, and transport descriptors. Thin clients are optional when every consumer genuinely shares their mechanics. Retry, timeout, authentication propagation, idempotency, orchestration, and telemetry policy usually remain in hand-written wrappers because they vary by caller and deployment. Do not generate clients for external consumers that cannot safely adopt the SDK, or where a well-documented wire protocol is the more durable boundary.

Treat the Generator Like a Compiler

A maintainable generator is not a loop that reads YAML and concatenates strings. It behaves like a small compiler:

  1. A strict loader rejects malformed YAML, duplicate keys, unknown envelope and schema keywords, invalid bounds, unresolved references, unsupported formats, and names that collide after conversion to PascalCase or snake_case.
  2. A normaliser resolves requiredness, nullability, defaults, numeric ranges, aliases, nested types, and canonical encoding rules into a language-neutral model.
  3. Emitters translate that model into each target’s DTOs, codecs, validators, descriptors, fixtures, and optionally thin clients. Templates render decisions; they do not invent semantics.
  4. The build checks generated targets and records reproducibility evidence.

Deterministic generation is relative to complete, pinned inputs: contract source, templates, generator version, dependency and toolchain versions, formatter version, locale, ordering, and line-ending policy. A clean regeneration-and-diff check or checksum manifest detects drift and supports reproducibility. It does not establish that an artefact is authentic; package provenance, signing, and trusted build infrastructure are separate concerns. The reproducible-builds definition is a useful baseline for what repeatability means.

Build Assurance and Runtime Handling Are Different Flows

Producer-side compilation and validation are useful, but a receiver cannot trust them. Stored messages, retries, replay, partial rollouts, external callers, and attackers all bypass that assumption.

Build and release assurance establishes that the platform can produce compatible artefacts:

flowchart LR
    A[Validate contract language] --> B[Normalise and generate]
    B --> C[Compile, type-check, and import-check each target]
    C --> D[Run shared conformance evidence]
    D --> E[Exercise integration and E2E scenarios]

Runtime handling protects each received message in its actual environment:

flowchart LR
    A[Transport and resource limits] --> B[Safe decode]
    B --> C[Structural and wire-version validation]
    C --> D[Wire-to-domain adapter]
    D --> E[Authentication, authorisation, and domain execution]

The first flow checks schema-language validation, normalisation and generation, target compilation/type checks/imports, conformance evidence, and integration scenarios. The second applies byte and decompression limits before expensive work; safe decoding; structural and version validation; then a domain adapter and the appropriate authentication, authorisation, and business checks. Input validation is a boundary control, not an identity system; OWASP’s guidance is clear about validating as early as practical.

Shared conformance evidence should run the whole path:

bytes → decoder → validator → wire model → canonical encoder

For each vector, assert accept or reject, expected normalised values, canonical field names, omission-versus-null behaviour, canonical bytes where specified, and stable error categories. Exercise mixed-version producer/consumer combinations too. Duplicate-key tests must enter at the raw-parser level because a native map has already lost the evidence.

Representative vectors include:

  • missing required members; non-object roots; and unknown top-level or nested members;
  • values around the safe-integer limits, numeric strings, booleans, fractions, and exponent notation;
  • uppercase and supplementary-plane Unicode characters under the declared length policy;
  • accepted and rejected UUID spellings, including the chosen canonical spelling;
  • explicit options: null, absent options, and an empty options object;
  • closed-enum evolution and unknown enum values;
  • retained old messages decoded by new consumers, and new messages received by old consumers where the rollout permits it.

Fixtures and end-to-end results are evidence that these scenarios were exercised, not proof that every implementation or future version is correct. Round trips alone are insufficient: a broken encoder and its matching broken decoder can agree.

Compatibility Is More Than a Package Number

Several version axes are often collapsed into one word. They answer different questions.

VersionWhat it identifiesWhat it does not establish
Package versionPublished SDK API and distribution releaseWhether retained messages remain decodable
Generator versionCompiler behaviour that produced an artefactA message’s wire shape
Contract-language versionSupported YAML/envelope and schema semanticsA specific queue consumer’s compatibility
Wire/message versionStructural representation carried by a messageWhich destination or deployment received it
Destination or queue versionRouting and retention boundaryThe generated package API

Semantic Versioning communicates package API change. It does not itself prove wire compatibility. A compatibility policy needs explicit rules, automated gates where possible, and consumer review for breaking changes.

Closed objects and enums make malformed input visible, but they constrain evolution. Adding an optional member can break an old strict consumer. Adding an enum member can break an old strict consumer. Adding a required member, narrowing a bound, or changing a default can break retained messages as well as live callers. Those may be good choices; they require a migration rather than a version bump alone.

A rolling queue migration can be concrete:

  1. Introduce asset.analysis.request.v2 and a versioned envelope while retaining the v1 destination.
  2. Release consumers that dual-read v1 and v2 and adapt both into the same domain command where that remains meaningful.
  3. Verify conformance and mixed-version scenarios, then observe version and rejection telemetry.
  4. Cut producers over to v2 only after compatible readers are live.
  5. Keep v1 routing through the configured retention, retry, and DLQ drain window.
  6. Keep a rollback window in which producers can return to v1 and consumers still understand both.
  7. Quarantine or deliberately replay terminal v1 failures according to the incident plan.
  8. Retire v1 only after retention, retry, rollback, and external-consumer support windows have elapsed.

Dual-writing deserves extra caution. It is unsafe for commands and other non-idempotent actions unless the operation, deduplication key, ordering, and side effects were specifically designed for it. A dual-read migration is often safer than telling two destinations to perform one business action.

Deployment Topology Chooses the Strictness

The following are architectural scenarios, not claims about deployed systems or support policies.

Argonath: an atomic Fabric and React boundary

Imagine a Fabric client hosting a React interface through an embedded browser bridge. MCEF is relevant here only as a generic example: its public repository describes an embedded Chromium browser for Minecraft and supports Fabric (MCEF). Java owns gameplay and authenticated server behaviour; TypeScript owns browser presentation; adapters separate both from the bridge contract.

An unversioned live protocol can be reasonable only under a strong deployment invariant: no independently cached browser bundle, persisted or replayed message, external consumer, partial update, or rollback mismatch can cross the boundary. The build should detect schema checksum or generated-binding mismatches at startup as well as generate both sides together. If any part of that invariant weakens, the boundary needs the same versioning and migration policy as an independently deployed service.

Generated shape checks narrow browser input, but they do not authenticate the browser. Byte, decompressed-byte, nesting, token, and collection limits need enforcement before expensive parsing. Origin, navigation, and action allowlists bound the embedded browser surface; authenticated Java services still authorise actions. Keeping actor or player identity out of browser action inputs narrows confused-authority opportunities, but omission alone does not identify a caller.

MeshSync: a versioned worker boundary

MeshSync’s worker-platform context is a useful related design context for an asynchronous Java, Python, and TypeScript boundary; it is not evidence that the specific generator, tests, or policy described here has been implemented there. In this scenario, services deploy independently, queues retain messages, and producers or workers may run different releases during a rollout.

That topology calls for versioned envelopes and destinations, compatible readers before producer cutover, retained-message handling, and an explicit support window. Packages can still use SemVer, generators can still publish checksums, and generated bindings can still reduce drift~but none of those substitutes for a wire-compatibility policy.

Operations, Security, and Recovery Belong Around the Generated Code

Malformed or unsupported-version messages are normally terminal rather than candidates for endless retry. Quarantine them or send them to a DLQ with redacted diagnostics; retry only failures that are plausibly transient and safe to repeat. Record contract version, generator or package version, schema checksum, destination, and stable rejection code. Do not use request IDs as metric labels: their cardinality is unbounded. They remain useful in logs and traces when redaction policy permits.

Operational checks should also find stale packages and validator divergence. A generator defect calls for a bounded recovery plan: stop or gate the affected producer; roll back to the last-known-good generator and generated packages or artefacts; regenerate and redeploy compatible readers and writers; then identify affected messages by contract/checksum range. Only after the defect is fixed and idempotency and side-effect safety are established should those messages undergo controlled replay; malformed and unsupported-contract messages remain terminal rather than becoming retry storms. A checksum detects drift or reproducibility problems; it does not prove an artefact came from a trusted build.

The threat boundary is larger than the JSON shape:

  • enforce compressed and decompressed byte limits, nesting, token, and collection limits before allocating or validating deeply;
  • apply authentication, authorisation, replay protection, and rate controls at the receiving boundary;
  • for embedded browsers, allowlist origins, navigations, and exposed actions;
  • secure the generated package and toolchain supply chain with pinned dependencies, provenance controls, and restricted publication rights;
  • keep rejection diagnostics useful but redacted, so hostile payloads, signed URLs, credentials, and tokens do not become logs.

Closed shapes and actor omission reduce the input space. They do not authenticate callers or make a generated client trustworthy.

Choose Generation Against Real Alternatives

Generation is a platform investment, not an automatic sign of architectural maturity.

ApproachChoose it whenIts limiting condition
Custom schema/compilerThe boundary has unusual semantics or several targets and the team can own compiler, compatibility, and security workStop expanding when language-specific escape hatches or platform ownership cost exceed the drift it removes
Standard IDL and toolchainProtobuf, OpenAPI, Avro, or another established ecosystem fits the protocol and consumersPrefer it over a custom language when it already supplies the required evolution and client story
Hand-written boundary types plus shared fixturesFew consumers, a small stable boundary, or specialised domain mapping makes generation uneconomicIt still needs explicit validation and conformance evidence
Consumer-driven contracts or a runtime registryMany independently evolving consumers need negotiated compatibility or discoveryA registry cannot repair ambiguous structural semantics or missing domain ownership

Do not adopt, or stop expanding, a generator when standard tools already fit; external consumers cannot use generated SDKs; long-lived event evolution is unsupported; generated clients begin absorbing domain or deployment policy; or the team cannot sustain the governance required to keep the compiler correct.

Migrate Without a Flag Day

Existing hand-written contracts do not require a rewrite weekend. Start by inventorying producers, consumers, external integrations, retained data, retries, and replay paths. Capture the current wire fixtures before changing anything. Generate alongside the hand-written model, then run differential decoding and canonical-encoding tests against those fixtures.

Deploy compatible readers first. Observe version, checksum, and rejection telemetry. Cut over writers only when the receiving fleet and rollback plan are ready. Retire old paths after the retention and rollback windows, rather than when the new code happens to compile. This turns migration into an observable compatibility exercise instead of a flag day.

The Useful Limit

The goal is not to eliminate hand-written code. It is to reserve hand-written code for decisions.

Schemas decide what can be represented on the wire. Generators make that representation available in each technology. Validators reject structural disagreement at each real boundary. Adapters protect domain ownership. Compatibility policy and deployment practice decide how the representation can change.

One schema does not give Java, Python, and TypeScript the same runtime, type system, trust boundary, or deployment lifecycle. It gives them a shared place to negotiate meaning~and a disciplined way to notice when they no longer agree.