yurii.
back to work
CASE STUDY

AsyncAPI to Kotlin Compiler

SHIPPED
Started: Dec 2023Shipped: Feb 2024Role: Tooling Engineer (API code-generation infrastructure)

One Spec, Three Languages: Building a Multi-Target API Code Generator

A home-security and automation platform I worked on had a physical panel, a cloud-connected backend, and eventually a new Android application, all talking to each other and to a remote monitoring provider over MQTT. The wire protocol was substantial - dozens of channels, messages with nested and recursive schemas, several composite types - and it lived in three separate codebases: an embedded C++ client, a Python service, and, once the Android app started, a Kotlin library sharing none of the first two's code. Every protocol change meant editing serialization logic by hand in three languages and trusting that all three edits agreed on the same edge cases. I designed and built a code generator that removed that trust requirement entirely: one AsyncAPI YAML specification as the single source of truth, and a pipeline that turns it into working, type-safe source for all three targets.

Three Layers, One Direction of Dependency

The generator split into three separate packages, each with one job and a dependency arrow pointing only one way: an orchestration layer resolved file paths and environment settings and drove the overall run; a protocol layer read channel, server, and operation definitions and decided what client classes to emit; and a schema layer turned JSON Schema definitions into actual data classes. The schema layer had zero knowledge of MQTT, channels, or operations - it only knew about types, properties, and validation rules, which meant it served the protocol layer's message payloads unmodified and would serve any other JSON-Schema-shaped data just as well. Both upper layers rendered through the same open-source templating engine (Jinja2); the Python code decided what to render, never how to format the output text, which kept every target language's syntax quirks contained to templates instead of leaking into the generator's own control flow.

Getting Reference Resolution Wrong Once

AsyncAPI specs reference each other constantly - a message reused across five channels, a schema pulled from a shared file everyone imports. Two kinds of references needed resolving: a local one pointing inside the same YAML file, and a global one starting with a file name and pointing into a different file entirely. My first resolver implementation always looked a reference up against whichever file was currently being processed, which is correct right up until a schema loaded from inside an externally-referenced document contains its own local reference - at that point "currently being processed" and "the document that actually owns this reference" are two different files, and the lookup fails with an error that gives no hint a file was ever swapped underneath it. The fix was to thread an explicit root-document argument through every recursive resolution call, so a schema always knows which document actually defines it, independent of which file happened to trigger the recursion that reached it. It's a small code change and a real change in mental model: reference resolution has to be a property of the reference's own document, not of whatever call stack got there first.

Wrapping Data Instead of Reaching Into It

A parsed YAML spec is just nested dictionaries — accurate, but useless for template code that needs to ask real questions like "what includes does this C++ struct need" or "what's the default retry behavior for this operation." I wrapped every schema and every spec node in a small adapter layer that exposed typed, purpose-built methods - a method that lists a schema's property names, one that computes required includes for a given target language, one that reads an operation's defaults - instead of templates reaching into a dictionary and hoping a key exists. This is also where the composite schema types lived: a schema requiring exactly one of several shapes generated a small visitor-style interface for reading back whichever branch was actually populated; a schema requiring all of several shapes generated a constructor that demanded every one of them up front; a schema allowing any subset generated optional properties throughout. Three genuinely different runtime contracts, expressed through the same wrapper interface, so the templates rendering them stayed symmetric instead of special-casing each type inline.

Generated Builders, Extended Through Configuration, Not Code

Every non-primitive schema also got a generated builder class, a simplified setter for primitive-typed properties, a plain setter for object-typed ones,
and a build() that throws if a required property was never set. Making that generation configuration-driven mattered more than making it merely
correct: a small, separate JSON file (introduced specifically so I'd stop hardcoding build wiring into templates) controlled per-module dependencies,
plugin lists, language versions, and which generated modules depended on which others — all editable without touching a line of the generator's own
code. That's the open/closed principle applied to the generator itself: a new module, a bumped dependency, or a changed language-version target became a JSON edit, not a change to generation logic.

The Build-System Wiring Problem

Generating correct classes was only half the job they also had to compile as a real, linkable multi-module project. Every run regenerated each module's own build file, plus one root settings file listing every module as a subproject; the generator rewrote that file from the actual on-disk folder structure every time, specifically so an added or removed module could never silently drift out of sync with what the build tool believed existed. The host Android app then pulled in the whole generated library with a single includeBuild() and linked individual modules the ordinary way, by name - which meant a module rename on the generator side was a breaking change on the app side unless both were updated together, a sharp edge I documented explicitly rather than leave for someone to rediscover as a mysterious sync failure.

A Narrow, Predictable Failure Surface

Generated code deliberately threw only a handful of exception types - IllegalArgumentException for a primitive value that failed validation,
IllegalStateException for a builder left incomplete or a composite type left uninitialized, and two custom exceptions for malformed input at parse
time — and every generated class documented exactly which of those it could throw and why. That narrowness was a deliberate constraint, not an accident of whatever got implemented first: a generator producing thousands of classes across three languages is only maintainable if failures are boring and enumerable, not a different ad hoc exception per schema author's mood. I also kept a running catalog of the resolution bugs real enough to bite more than once, including the root-document bug above, with root cause and fix recorded together, so the next engineer hitting the same symptom didn't have to re-derive it from scratch.

The result: a single specification edit propagated, unattended, into a compiling C++ client, a running Python service, and a Kotlin module ready to link into the Android app - and the entire MQTT client and data layer of that Android app was never hand-written at all.

Stack

PythonHiveMQAsyncAPICode GenerationDevExperienceMQTTKotlinJinja