Different Language

[WASM Component Model in Practice: Building a Composable Plugin System Across Go, Python, and Rust. Part 1: Architecture, Toolchain, and WIT Interface Design]

Analyze with AI

Get AI-powered insights from this Mad Devs tech article:

WebAssembly (WASM) started as a compilation target for the browser — a way to run near-native code inside a JavaScript runtime without using JavaScript. But the properties that made it useful in a browser turned out to be useful everywhere: a compact binary format, a memory-safe sandbox, and a language-agnostic compilation target. You compile Rust, Go, Python, or C++ to the same .wasm binary, and that binary runs unchanged in a browser, on a server, or at an edge node. The host changes, but the binary does not.

Image

Three terms appear throughout this guide and are worth defining before anything else.

WASI (WebAssembly System Interface) gives WASM modules access to system resources — files, clocks, HTTP, and sockets. Without it, a module runs in a pure sandbox with no I/O.

The Component Model adds typed interfaces between modules: a standardized way for one .wasm binary to declare what it needs and what it provides, checkable at build time.

WIT (WebAssembly Interface Types) is the interface definition language for the Component Model — the .wit file is the contract, wit-bindgen generates the language bindings from it.

Image

The problem all of this solves: raw WASM modules have no typed boundary. Every value that crosses between modules is an integer. Both sides must agree on what those integers mean — and nothing enforces that agreement. The rest of this guide is about what happens when you replace that with something the toolchain can verify.

Image

This is Part 1 of a two-part guide. It covers the architecture, the toolchain, and WIT interface design — the decisions you make before writing a single line of implementation code. Part 2 covers polyglot implementation in Go, Python, and Rust; composition with wasm-tools; and the production trade-offs the Bytecode Alliance documentation does not discuss.

Why raw WASM modules break at scale: the ABI problem

WASM's linear memory model is the root of the problem. Every module owns a flat, untyped block of memory. To pass a string from module A to module B, you write the bytes into A's memory, pass a pointer (integer) and length (integer) across the boundary, and trust that B reads the same layout from the same offset. If A and B were compiled from the same language, the same compiler version, and the same ABI convention, this often works. Add a second language, a different malloc implementation, or a compiler flag that changes struct alignment, and it does not.

This is the ABI problem: there is no standard. Teams have solved it ad hoc in three ways, each with a different failure mode.

Manual serialization. Both sides agree to JSON or MessagePack over linear memory. Works, but adds encoding overhead on every call, requires a serialization library in every component, and still fails if one side's allocator frees memory before the other side reads it.

Language-specific bridges. wasm-bindgen (Rust ↔ JavaScript), cgo (Go ↔ C). These are well-engineered solutions for specific pairs. A plugin system that supports three languages needs three bridges, each maintained separately. Adding a fourth language means writing a new bridge.

Shared-nothing isolation. Each module runs completely separately; communication happens through a host runtime that serializes and deserializes. This is what plugin systems tend toward when the other approaches accumulate too much maintenance cost. The host becomes the bottleneck, the protocol becomes the maintenance burden, and the promise of composable modules is gone.

What raw WASM modules cannot express. Beyond the memory problem, WASM modules have no way to declare what they need or what they provide in a machine-readable format. A module's interface is its export list — a flat collection of function names. There is no concept of "this module requires an implementation of http-client and provides an implementation of key-value-store." You cannot compose two modules by describing their interfaces and letting tooling wire them together. You wire them manually, at runtime, in the host.

The Component Model replaces this with a type system at the binary level and a composition model at the toolchain level. The result is that "does module A's output type match module B's input type?" is a question the toolchain can answer before you deploy anything.

Component Model architecture: modules, components, and the WIT layer

The Component Model adds three things on top of core WASM.

Components. A component is a WASM module wrapped in a new binary layer that declares its interface explicitly: what it imports, what it exports, and the types of both. Two components can only be composed if their interfaces are compatible — the toolchain checks this at link time, not at runtime.

The Canonical ABI. A standardized, language-agnostic convention for encoding high-level types (strings, lists, records, variants, options, results) into the integers and memory operations that core WASM understands. Every language's toolchain generates code that follows the same convention. The ABI problem moves into the toolchain instead of the application code.

WIT (WebAssembly Interface Types). An interface definition language for describing component interfaces. A .wit file declares the types, functions, and interfaces that components expose and consume — independent of any programming language. wit-bindgen generates the language-specific bindings from WIT. The WIT file is the source of truth; the bindings are output.

                    ┌─────────────────────────────────────┐
                    │          WIT Interface              │
                    │   types, functions, worlds          │
                    └──────────┬──────────────────────────┘
                               │ wit-bindgen
              ┌────────────────┼────────────────┐
              │                │                │
     ┌────────▼──────┐ ┌───────▼──────┐ ┌──────▼────────┐
     │  Go bindings  │ │ Rust bindings│ │Python bindings│
     └────────┬──────┘ └───────┬──────┘ └──────┬────────┘
              │                │                │
     ┌────────▼──────┐ ┌───────▼──────┐ ┌──────▼────────┐
     │  Go component │ │Rust component│ │  Py component │
     └────────┬──────┘ └───────┬──────┘ └──────┬────────┘
              │                │                │
              └────────────────┼────────────────┘
                      wasm-tools compose
                               │
                    ┌──────────▼──────────┐
                    │  Composed component │
                    │  (single .wasm file)│
                    └──────────┬──────────┘
                               │
                    ┌──────────▼──────────┐
                    │   Host runtime      │
                    │   (Wasmtime, WAMR,  │
                    │    Spin, wasmCloud) │
                    └─────────────────────┘

Worlds. A world is a WIT construct that defines a complete boundary: what a component imports from its environment and what it exports to the outside world. A component targets a specific world — it can only be used in contexts that satisfy its imports and that accept its exports. This makes the component's operational requirements explicit and checkable.

Packages. WIT interfaces are organized into packages with versioned namespaces: my-org:[email protected]. The package system enables the same interface to evolve independently across versions, with the toolchain tracking compatibility.

The architecture has one important implication: the WIT file is not documentation. It is the contract that the toolchain enforces. Changing a WIT interface requires regenerating bindings and recompiling every component that uses it. Treat WIT files the same way you treat protobuf or Thrift schemas — as a versioned artifact with a migration discipline.

Toolchain setup: wasm-tools, wit-bindgen, Wasmtime, and version compatibility traps

The Component Model toolchain is young and moving fast. Version mismatches between tools are the single most common source of confusing errors when starting.

Tested environment

The versions below were current as of May 2026, when this guide was assembled and tested. They are not a recommendation to stay on these versions indefinitely — they are a reproducibility anchor. If you are reading this later, check the release pages linked below for current versions. What matters is that all tools in your setup come from the same release cycle.

wasm-tools       1.219.1   (cargo install [email protected])
wit-bindgen-cli  0.32.0    (cargo install [email protected])
wasmtime         25.0.0    (install via https://wasmtime.dev/install.sh)
rustc            1.79.0    (stable, target wasm32-wasip1)
TinyGo           0.34.0    (native WASI 0.2 / Component Model support)
componentize-py  0.15.0    (pip install componentize-py==0.15.0)

Install the core tools:

# Rust toolchain tools
cargo install [email protected]
cargo install [email protected]

# Add the WASM target to rustc
rustup target add wasm32-wasip1

# Wasmtime -- use the official installer, not cargo
curl https://wasmtime.dev/install.sh -sSf | bash
# To pin in CI:
# WASMTIME_VERSION=25.0.0
# curl -sSL "https://github.com/bytecodealliance/wasmtime/releases/download/v${WASMTIME_VERSION}/wasmtime-v${WASMTIME_VERSION}-x86_64-linux.tar.xz" | tar -xJ

# TinyGo (Linux amd64)
wget https://github.com/tinygo-org/tinygo/releases/download/v0.34.0/tinygo_0.34.0_amd64.deb
sudo dpkg -i tinygo_0.34.0_amd64.deb

# Python
pip install componentize-py==0.15.0

# Verify
wasm-tools --version   # 1.219.1
wit-bindgen --version  # 0.32.0
wasmtime --version     # 25.x.x
tinygo version         # 0.34.0

Version compatibility rule

wasm-tools and wit-bindgen must come from the same Bytecode Alliance release cycle. Mixing versions from different cycles, for example, wasm-tools 1.219 with wit-bindgen 0.20, produces type-mismatch errors at composition time. These errors look like bugs in your WIT files, but they are actually version skew. Pin both tools to explicit versions in CI; an unversioned cargo install wasm-tools six months from now installs a different binary.

Note on Go/TinyGo. TinyGo 0.34.0 introduced native WASI 0.2 support for the standard wasi:cli/command world. However, the -target wasip2 flag is currently tied to that standard CLI world. Custom worlds — like the validator-world and transformer-world in this guide, which do not import from WASI and are not CLI commands — may require additional binding setup, workaround steps, or manual WIT preparation. This area of Go/TinyGo tooling is evolving; verify that your TinyGo version supports exporting the specific custom world you target before committing to this path. Go bindings are generated by wit-bindgen-go, which lives in the bytecodealliance/go-modules repository — separate from wit-bindgen-cli. The older TinyGo WIT bindings generator at go.bytecodealliance.org is no longer maintained. Always inspect the generated output before writing implementation code against it.

Project layout

my-plugin-system/
├── wit/
│   ├── world.wit          # top-level world definition
│   └── deps/              # imported WIT packages
│       └── wasi/          # WASI interfaces (http, filesystem, etc.)
├── components/
│   ├── validator/         # Go/TinyGo component
│   │   ├── main.go
│   │   └── Makefile
│   ├── transformer/       # Rust component
│   │   ├── src/lib.rs
│   │   └── Cargo.toml
│   └── pipeline/          # Python component
│       ├── app.py
│       └── requirements.txt
├── composed/
│   └── pipeline.wasm      # output of wasm-tools compose
└── compose.yaml           # composition graph

Fetching WASI dependencies

Most real components need WASI interfaces (clocks, random, HTTP). Fetch the official WIT definitions rather than writing them yourself:

Note. wasm-tools component wit is for extracting WIT from a compiled component binary, not for downloading packages. For dependency fetching, use wkg wit fetch. For projects using wasmCloud, wash wit deps provides a similar workflow.

# Install wkg 
cargo install wkg

# Configure wkg to know where WASI packages live. # Add to ~/.config/wkg/config.toml: # [namespace_registries] # wasi = "ghcr.io/webassembly"

# Fetch all dependencies referenced in your WIT files 
# wkg reads your wit/ directory, resolves imports, and writes 
# packages to wit/deps/ with a lock file. 
wkg wit fetch

# Verify: WASI interfaces should now be in wit/deps/ ls wit/deps/ 
# wasi-clocks-0.2.0/ wasi-http-0.2.0/ wasi-io-0.2.0/ ...

WIT interface design: types, worlds, and the decisions that matter

WIT is a small language. It has interfaces, types, and worlds. The design decisions you make in WIT propagate into every language-generated binding, so getting the WIT right before writing implementation code saves significant rework.

A complete WIT example

The following WIT defines a document processing pipeline. This is the interface that all three components in Part 2 will implement against.

Note on logging. The worlds above import nothing from the host environment — no WASI, no logging. This is intentional for the minimal reproducible example: external imports must be satisfied at composition time or by the host, and wasi:logging/logging adds a dependency that requires either a concrete implementation component or host linker wiring. In production, add logging imports once the core composition is working, and you understand how your host runtime satisfies them.

// wit/world.wit
package my-org:[email protected];

// A document flowing through the pipeline
interface types {
  // Records are named structs
  record document {
    id: string,
    content: string,
    metadata: list<tuple<string, string>>,
  }

  // Variants are discriminated unions -- use these instead of error codes
  variant validation-error {
    schema-violation(string),
    size-limit-exceeded(u64),
    encoding-error,
  }

  // Results encode success/failure at the type level
  type validation-result = result<document, validation-error>;
}

// The validator interface: takes a raw document, returns validated or error
interface validator {
  use types.{document, validation-result};

  validate: func(doc: document) -> validation-result;
  validate-batch: func(docs: list<document>) -> list<validation-result>;
}

// The transformer interface: takes validated documents, transforms them
interface transformer {
  use types.{document};

  // option<T> for nullable returns -- not null, not a special sentinel
  transform: func(doc: document, rules: list<string>) -> option<document>;
}

// World for the validator component
// No external imports -- keeps the minimal example self-contained
world validator-world {
  export validator;
}

// World for the transformer component
world transformer-world {
  export transformer;
}

// World for the composed pipeline
// Imports validator and transformer; exports the top-level process function
world pipeline-world {
  import validator;
  import transformer;

  export process: func(docs: list<types.document>) -> list<types.document>;
}

Type decisions that matter

Use result<T, E> instead of exceptions or error codes. Generated bindings in Rust map result to Result<T, E>. In Python, they become return types you check. In Go, they generate a two-value return.

Use variant instead of stringly-typed errors. A variant produces a discriminated union in Rust, a sealed class hierarchy in Python, and a typed enum in Go.

Prefer option<T> over nullable semantics. WIT has no null. option<T> generates Option<T> in Rust, Optional[T] in Python, and a pointer or bool-pair in Go.

Keep types in a shared interface, not in each interface. The types interface in the example above is imported by both validator and transformer. If you define document separately in each interface, composition will fail because the two definitions are not of the same type, even if they look identical in the WIT source.

The failure mode here is particularly painful: everything compiles, wit-bindgen happily generates bindings, and only wasm-tools compose tells you at the very end that your "identical" documents are in fact incompatible types.

Resources for stateful objects. Resources are a WIT mechanism for handling objects with methods. They compile to reference-counted handles in most languages.

interface cache {
  resource cache-handle {
    constructor(capacity: u32);
    get: func(key: string) -> option<string>;
    set: func(key: string, value: string);
  }
}

Worlds and composition planning

Design your worlds to reflect your composition topology, not your domain. Each component targets exactly one world — the world defines the full set of imports and exports for that component. The composition graph follows from the worlds: a component that exports validator satisfies the import validator in pipeline-world.

Sketch the composition graph before writing WIT:

validator-component  ──exports──▶  validator interface
                                          │
                                  imported by
                                          │
transformer-component ──exports──▶  transformer interface
                                          │
                                  imported by
                                          │
pipeline-component   ──imports──▶  validator + transformer
                    ──exports──▶  process function

Then write one world per node in this graph. If you cannot map your WIT worlds to this graph cleanly, the composition will either fail at link time or produce a composed component that the host cannot instantiate.

Versioning strategy

WIT packages are versioned: my-org:[email protected]. Treat minor versions as additive (new exports to an interface), major versions as breaking (types change, interfaces split or merge). The toolchain does not enforce SemVer, you do.

A practical rule: once a WIT interface has a production component implementing it, treat it as a schema migration. Changing a function signature requires bumping the minor version, generating new bindings, and recompiling every component that implements or imports the interface. Changing a type in a shared types interface requires bumping the major version and treating it as a breaking change for all downstream components.

Three decisions to make before opening Part 2

Before writing implementation code, three decisions from Part 1 should be settled. Each one propagates through every component, every language, and every build step that follows.

1. Are your WIT types shared correctly? If document is defined separately in the validator and transformer interfaces instead of being imported from a shared types interface via use types.{document}, composition will fail with a type mismatch — even if the fields look identical. Shared types must come from one source and be imported everywhere.

2. Do your worlds reflect your composition graph? Each world maps to one component, and the imports/exports of worlds must form a valid dependency graph: pipeline-world imports validator + transformer; validator-world exports validator; transformer-world exports transformer. If you cannot draw a clean graph from your worlds, wasm-tools compose will not produce a working binary.

3. Is your toolchain version-pinned? All components in a composition must be compiled with the same wit-bindgen release. Cross-version composition fails silently with type mismatches that are hard to trace. Pin before you start — not after the first confusing error.

Part 2 picks up from a validated WIT file and walks through implementing validator-world, transformer-world, and pipeline-world in Go/TinyGo, Rust, and Python — then composes them into a single binary and examines the production trade-offs that apply once components are running in a real host.