Skip to content

PATTERN Cited by 1 source

Single JSON spec to multi-platform codegen

Problem

You have a shared UI component, RPC message, or API interface that multiple platforms (iOS, Android, web, backend) all need to encode / decode in matching shapes. Hand-writing the deserialiser in each language guarantees drift — different property names, different field types, different action shapes. Yelp's pre-Konbini example shows two backend teams' "the same button" implementations:

# Team A
SDUIButton(label="Click me", button_type="primary",
           action=OpenURLAction(url="https://yelp.com"),
           size="small")

# Team B
ServerButton(text="Click me", style="primary",
             click_handler={"type": "open_url",
                            "url": "https://yelp.com"},
             display_size="sm")

Verbatim: "Both solutions were trying to render the same button component, but their backend representations were completely different—different property names, different ways to handle actions, different size values." Maintenance burden + inconsistent user experience.

Pattern

Define each interface once, as data, in a language-agnostic JSON file (one file per interface). Wire up CI to run code generation on every commit to the spec repo and publish per-language libraries that every consumer depends on.

             spec repo (one JSON per interface)
                        │  git push
                 CI (Jenkins / Actions)
              ┌─────────┼─────────┬─────────┐
              ▼         ▼         ▼         ▼
          Kotlin     Swift     Python    TypeScript
           lib        lib       lib        lib
           (Moshi)   (JSON)  (serialise)  (types)
              │         │         │         │
              ▼         ▼         ▼         ▼
           Android    iOS     backend     web

Consumers depend on the published library, not on the spec directly. Regeneration is automatic and hermetic — generated code is not edited by hand. See concepts/single-source-interface-spec for the deeper discipline.

Canonical wiki instance (Yelp Konbini)

Konbini generates four libraries per Cookbook component from one JSON interface definition:

Library Language Platform Role
componentinterfaces Kotlin Android client Deserialise JSON
YLInterfaces Swift iOS client Deserialise JSON
component_interfaces Python Backend services Serialise to JSON
component-interfaces TypeScript React web Deserialise JSON

Input (single JSON):

{
  "name": "cookbook.Button",
  "version": "0.8",
  "parameters": {
    "text": { "type": "String", "description": "..." },
    "style": { "type": "cookbook.ButtonStyle", "description": "..." },
    "on_click": { "type": "Nullable<Action>", "default": null, "description": "..." },
    "size": { "type": "cookbook.ButtonSize", "default": "standard", "description": "..." },
    "background_color": { "type": "Color", "description": "..." }
  }
}

Yelp: "Because both the Python and Kotlin code are generated from the same JSON source, the parameter names are guaranteed to stay in sync."

Every CHAOS backend at Yelp that wants to emit a button now instantiates the same generated CookbookButton class:

CookbookButton(
    text="Click me to go to Yelp",
    style=CookbookButtonStyle("primary"),
    on_click=CookbookOpenUrl(url="https://yelp.com"),
    size=CookbookButtonSize("small"),
    background_color=ColorToken.COM_CAROUSELBUTTON_COLOR_BG_INLINE,
)

And every client library deserialises that into an identically- shaped interface model (Kotlin example):

data class CookbookButtonInterfaceParams(
    @Json(name = "text")             val text: String,
    @Json(name = "style")            val style: CookbookButtonStyle,
    @Json(name = "on_click")         val onClick: ActionModel? = null,
    @Json(name = "size")             val size: CookbookButtonSize = CookbookButtonSize.standard,
    @Json(name = "background_color") val backgroundColor: ColorToken,
)

Preconditions

  • Language-agnostic type system in the spec. Every type (String, cookbook.ButtonStyle, Color, Nullable<T>, Action) must map cleanly to every target language. Yelp's types are loosely curated (e.g. cookbook.ButtonStyle is an enum, Color is a token reference).
  • CI on the spec repo. Hermetic, automated, fast.
  • Generated libraries as first-class dependencies. The consumer build just depends on componentinterfaces / component_interfaces / etc. — same as any other library.
  • Version discipline — the spec carries a version; every change produces a new library version. See concepts/breaking-change-requires-major-bump.

Scope — where codegen stops

Konbini generates the deserialiser data class but not the widget-composition code that renders it. The render binding is hand-written per platform:

@Composable
fun CookbookButtonInterface.Render(...) {
    when (size) {
        small    -> ButtonSmall(buttonParams)
        standard -> ButtonStandard(buttonParams)
        large    -> ButtonLarge(buttonParams)
    }
}

This is a deliberate separation: the codegen covers the data interface of the component; the widget-composition layer remains an artful choice per platform.

Composes with

Contrasts

  • Protocol Buffers (Protobuf) — the RPC-message cousin of this pattern at a different altitude (data messages vs UI component interfaces). Same mechanism; same family of trade-offs.
  • Hand-maintained per-platform libraries with a shared types file (Zalando's patterns/platform-specific-ts-file-resolution discipline) — lighter-weight; codegen complexity avoided but drift prevention depends on compile-time type check alone. Works when platforms share a language toolchain; doesn't work across Kotlin + Swift + Python + TypeScript.
  • GraphQL SDL + per-language codegen (Apollo Codegen, graphql-code-generator, Strawberry) — same pattern at the query-schema altitude rather than the component- interface altitude. Often coexists with the Konbini-style pattern: GraphQL for top-level API shape, a component interface spec for UI-level shape.

When to reach for this pattern

  • You need ≥3 platforms to agree on the exact shape of a data structure.
  • Hand-maintained per-language serialisers have already caused drift.
  • You have — or are willing to invest in — CI automation for the spec repo.
  • You're writing server-authored UI or cross-platform protocols where any divergence shows up immediately as user-visible bugs.

When not to

  • Single-language stack — a shared types file is simpler.
  • Low cross-platform coupling — if the platforms' views of the data are allowed to differ, codegen adds ceremony without benefit.
  • Too-dynamic types — if the schema is heavily polymorphic or relies on generative union types, a static codegen may be a poor fit.

Seen in

Last updated · 550 distilled / 1,221 read