Practical WebAssembly Components with Wasmtime

A tutorial series on building a Rust plugin system using the WebAssembly Component Model and Wasmtime.

Overview

This series shows how to build a plugin architecture using Wasmtime and the Component Model. The component model handles all the plumbing — memory, strings, type safety — natively, so you focus on the interesting parts.

Parts

  1. Hello, Components — Define a WIT interface, write a plugin, and run it with Wasmtime
  2. Building a Plugin Host — Build a full plugin host with multiple plugins
  3. Advanced Topics — Explore advanced component model features

Part 1: Hello, Components

In the original series, Part 1 got us to the point where we could pass a string into a WASM plugin and get its length back. It required manually writing bytes into WASM memory, passing a pointer and length, and reconstructing the string with from_raw_parts inside an unsafe block.

With the Component Model, we skip all of that. Let's build the same thing — a plugin that takes a string and returns its length — but this time, the only code we write is the actual logic.

The Interface

The Component Model uses WIT files to define the contract between host and plugin. Think of it as a language-agnostic interface definition — like a .proto file, but for WASM.

Create a workspace for this part:

mkdir -p part-1/plugin/wit

Write the WIT file at part-1/plugin/wit/world.wit:

package example:plugin;

world example-plugin {
    export length: func(s: string) -> u32;
}

This says: our plugin exports a function called length that takes a string and returns a u32. That's the whole interface. No pointers, no memory layout conventions, no "write the length to byte index 1" handshake.

Note: In wit-bindgen 0.41+, type definitions like record must be placed inside the world block, not at the top level of the package. Top-level types need to be in separate interface files.

The Plugin

Create the plugin project:

cd part-1/plugin
cargo init --lib

Update Cargo.toml:

[package]
name = "example-plugin"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wit-bindgen = "0.41"

The cdylib crate type tells cargo to produce a C dynamic library, which is what the WASM target expects. And wit-bindgen generates the bindings from our WIT file.

Now the actual code — src/lib.rs:

#![allow(unused)]
fn main() {
wit_bindgen::generate!({
    world: "example-plugin",
});

struct ExamplePlugin;

impl Guest for ExamplePlugin {
    fn length(s: String) -> u32 {
        s.len() as u32
    }
}

export!(ExamplePlugin);
}

Let's break this down:

  • wit_bindgen::generate! reads the wit/ directory, finds our world, and generates a Guest trait matching the exports we defined.
  • We implement Guest for our type — this is where the actual logic lives.
  • export! registers our implementation as the component's exports.

Compare this to the original plugin code, which needed a separate _length wrapper function with unsafe pointer arithmetic. Here, we just... implement a trait.

Build it:

cargo build --target wasm32-wasip2

The output is at target/wasm32-wasip2/debug/example_plugin.wasm. This is a full component — not a core WASM module. You can inspect it:

wasm-tools component wit target/wasm32-wasip2/debug/example_plugin.wasm

You should see the same world definition we wrote in our WIT file.

The Host

Now let's build a host that loads and runs this component.

cd part-1/host
cargo init

Cargo.toml:

[package]
name = "example-host"
version = "0.1.0"
edition = "2021"

[dependencies]
wasmtime = "29"
wasmtime-wasi = "29"
anyhow = "1"

The host also needs a copy of the WIT file so wasmtime::component::bindgen! can generate typed bindings. Copy or symlink it:

mkdir -p wit
cp ../plugin/wit/world.wit wit/

Now src/main.rs:

use anyhow::Result;
use wasmtime::{Engine, Store};
use wasmtime::component::{Component, Linker};
use wasmtime_wasi::{WasiCtxBuilder, WasiCtx, WasiView, ResourceTable};

wasmtime::component::bindgen!({
    world: "example-plugin",
    path: "../plugin/wit",
});

struct State {
    wasi: WasiCtx,
    table: ResourceTable,
}

impl WasiView for State {
    fn ctx(&mut self) -> &mut WasiCtx { &mut self.wasi }
    fn table(&mut self) -> &mut ResourceTable { &mut self.table }
}

fn main() -> Result<()> {
    let engine = Engine::default();
    let mut linker = Linker::new(&engine);

    // Add WASI support — the plugin targets wasm32-wasip2 which requires WASI
    wasmtime_wasi::add_to_linker_sync(&mut linker)?;

    let state = State {
        wasi: WasiCtxBuilder::new().build(),
        table: ResourceTable::new(),
    };
    let mut store = Store::new(&engine, state);

    let bytes = std::fs::read("../plugin/target/wasm32-wasip2/debug/example_plugin.wasm")?;
    let component = Component::new(&engine, bytes)?;

    let instance = linker.instantiate(&mut store, &component)?;

    let plugin = ExamplePlugin::new(&mut store, &instance)?;

    let result = plugin.call_length(&mut store, "supercalifragilisticexpialidocious")?;
    println!("length: {result}");

    Ok(())
}

The WasiView trait connects WASI context to the store. Since our plugin targets wasm32-wasip2, it needs WASI available — even though our plugin doesn't use any WASI features directly, the component runtime requires the WASI plumbing to be present.

Run it:

cargo run
# length: 34

What Just Happened

In the original Part 1, getting to this point required:

  1. Getting the module's context
  2. Getting memory(0) from the context
  3. Getting a view of that memory
  4. Looping over memory cells to write string bytes
  5. Binding the _length function with raw type signatures
  6. Calling it with (ptr, len) as i32/u32
  7. Reconstructing the result

Now it's:

#![allow(unused)]
fn main() {
plugin.call_length(&mut store, "supercalifragilisticexpialidocious")?;
}

The Component Model and wit-bindgen handled all the memory management. We described our interface in WIT, and both sides got generated, type-safe bindings. The host passes a Rust string. The plugin receives a Rust string. The result is a Rust u32.

That's the entire point of this rewrite: the plumbing goes away.

What's Next

In Part 2, we'll pass structured data — tuples, records, and lists — across the boundary. In the original series, this required bincode serialization, reserved memory slots for return lengths, and more pointer arithmetic. With Components, it just means adding fields to a WIT record.

Part 2: Structured Data

In the original series, Part 2 was where things got complicated. Passing a string back from the plugin required returning a pointer, reserving memory for the return length, and reading bytes back out. Passing a (u8, String) tuple required bincode serialization, writing serialized bytes into WASM memory, deserializing on the plugin side, re-serializing the result, writing the length to a fixed memory offset, and then doing the whole thing in reverse on the host side.

The plugin code looked like this:

#![allow(unused)]
fn main() {
// Original: serialize, deserialize, pointer arithmetic
#[no_mangle]
pub fn _multiply(ptr: i32, len: u32) -> i32 {
    let slice = unsafe {
        ::std::slice::from_raw_parts(ptr as _, len as _)
    };
    let pair = deserialize(slice).expect("Failed to deserialize tuple");
    let updated = multiply(pair);
    let ret = serialize(&updated).expect("Failed to serialize tuple");
    let len = ret.len() as u32;
    unsafe {
        ::std::ptr::write(1 as _, len);
    }
    ret.as_ptr() as _
}
}

With the Component Model, structured data is a first-class concept. You define your types in WIT, and wit-bindgen generates Rust structs that map to them. No serialization. No pointer arithmetic. No unsafe.

The Interface

Create the workspace:

mkdir -p part-2/plugin/wit

Write part-2/plugin/wit/world.wit:

package example:plugin;

world example-plugin {
    record pair {
        count: u8,
        word: string,
    }

    export double: func(s: string) -> string;
    export multiply: func(pair: pair) -> pair;
}

We define a record called pair inside the world block with a u8 count and a string word. (In wit-bindgen 0.41+, records must be inside the world or a separate interface — they can't be at the package top level.)

The Plugin

cd part-2/plugin
cargo init --lib

Cargo.toml — same as Part 1:

[package]
name = "example-plugin"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wit-bindgen = "0.41"

src/lib.rs:

#![allow(unused)]
fn main() {
wit_bindgen::generate!({
    world: "example-plugin",
});

struct ExamplePlugin;

impl Guest for ExamplePlugin {
    fn double(s: String) -> String {
        s.repeat(2)
    }

    fn multiply(pair: Pair) -> Pair {
        let repeated = pair.word.repeat(pair.count as usize);
        let new_count = pair.count.wrapping_mul(repeated.len() as u8);
        Pair {
            count: new_count,
            word: repeated,
        }
    }
}

export!(ExamplePlugin);
}

That's the entire plugin. The Pair struct is generated by wit_bindgen::generate! from our WIT record definition. It has public fields, it implements Clone and Debug, and it can be passed across the WASM boundary without any serialization code on our part.

Build it:

cargo build --target wasm32-wasip2

The Host

cd part-2/host
cargo init

Cargo.toml:

[package]
name = "example-host"
version = "0.1.0"
edition = "2021"

[dependencies]
wasmtime = "29"
wasmtime-wasi = "29"
anyhow = "1"

src/main.rs:

use anyhow::Result;
use wasmtime::{Engine, Store};
use wasmtime::component::{Component, Linker};
use wasmtime_wasi::{WasiCtxBuilder, WasiCtx, WasiView, ResourceTable};

wasmtime::component::bindgen!({
    world: "example-plugin",
    path: "../plugin/wit",
});

struct State {
    wasi: WasiCtx,
    table: ResourceTable,
}

impl WasiView for State {
    fn ctx(&mut self) -> &mut WasiCtx { &mut self.wasi }
    fn table(&mut self) -> &mut ResourceTable { &mut self.table }
}

fn main() -> Result<()> {
    let engine = Engine::default();
    let mut linker = Linker::new(&engine);

    wasmtime_wasi::add_to_linker_sync(&mut linker)?;

    let state = State {
        wasi: WasiCtxBuilder::new().build(),
        table: ResourceTable::new(),
    };
    let mut store = Store::new(&engine, state);

    let bytes = std::fs::read("../plugin/target/wasm32-wasip2/debug/example_plugin.wasm")?;
    let component = Component::new(&engine, bytes)?;

    let instance = linker.instantiate(&mut store, &component)?;

    let plugin = ExamplePlugin::new(&mut store, &instance)?;

    // Test double
    let doubled = plugin.call_double(&mut store, "supercalifragilisticexpialidocious")?;
    println!("doubled: {doubled}");

    // Test multiply
    let pair = Pair {
        count: 3,
        word: "hello".to_string(),
    };
    let result = plugin.call_multiply(&mut store, &pair)?;
    println!("count: {}, word: {}", result.count, result.word);

    Ok(())
}

Run it:

cargo run
# doubled: supercalifragilisticexpialidocioussupercalifragilisticexpialidocious
# count: 15, word: hellohellohello

Compare and Contrast

Let's look at what the original Part 2 required for the double function alone:

Original host code (~20 lines):

  1. Get memory view
  2. Zero out first 4 bytes (reserved for return length)
  3. Write string bytes into memory starting at index 5
  4. Bind _double with raw types (i32, u32) -> i32
  5. Call it, get back a pointer
  6. Read 4 bytes from index 1-4 to get the return length
  7. Convert those bytes to u32
  8. Read the return string from memory using pointer + length
  9. Convert bytes to String

Component Model host code (1 line):

#![allow(unused)]
fn main() {
let doubled = plugin.call_double(&mut store, "supercalifragilisticexpialidocious")?;
}

And for structured data, the original needed bincode on both sides. The Component Model just uses WIT records that map directly to Rust structs. No serialize, no deserialize, no [0u8; 4] arrays for reading lengths out of raw memory.

WIT Types Beyond Records

WIT supports more than just records. Here's a quick tour of what's available:

// Enums
enum color {
    red,
    green,
    blue,
}

// Variants (like Rust enums with data)
variant shape {
    circle(f32),
    rectangle(f32, f32),
}

// Lists
export sum: func(values: list<u32>) -> u32;

// Options (like Rust Option)
export find: func(name: string) -> option<u32>;

// Results (like Rust Result)
export parse: func(input: string) -> result<u32, string>;

// Flags (like bitflags)
flags permission {
    read,
    write,
    execute,
}

All of these map to idiomatic Rust types through wit-bindgen. list<u32> becomes Vec<u32>, option<u32> becomes Option<u32>, result<u32, string> becomes Result<u32, String>. You write normal Rust on both sides.

What's Next

In Part 3, we'll build a real-world plugin: an mdbook preprocessor. The original Part 4 showed how to wire up a WASM plugin to mdbook's preprocessor system using the Wasmer-based approach. We'll do the same thing with Components, and see how the Component Model handles the full lifecycle of a real plugin.

Part 3: Real World — An mdbook Preprocessor

In the original Part 4, we built an mdbook preprocessor as a WASM plugin. The preprocessor's job was simple: find every occurrence of "WASM" and replace it with "Wasm" (because it's not an acronym). What wasn't simple was the plumbing — the host had to serialize the entire Book struct to JSON, write those bytes into WASM memory, have the plugin deserialize, modify, re-serialize, write the result back to memory, and then the host would read it all back out.

With the Component Model, we define the Book structure in WIT and pass it across the boundary as a native type. No JSON. No memory management. No serialization format to agree on.

The Interface

mkdir -p part-3/plugin/wit

Write part-3/plugin/wit/world.wit:

package example:plugin;

world example-plugin {
    record book-item {
        chapter-name: string,
        content: string,
    }

    record book {
        items: list<book-item>,
    }

    export process: func(book: book) -> book;
}

We're simplifying mdbook's Book type for this example. A real implementation would mirror the full mdbook data model in WIT, but the principle is the same — define your types inside the world, pass them across the boundary as structured data.

The Plugin

cd part-3/plugin
cargo init --lib

Cargo.toml:

[package]
name = "example-plugin"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wit-bindgen = "0.41"

src/lib.rs:

#![allow(unused)]
fn main() {
wit_bindgen::generate!({
    world: "example-plugin",
});

struct ExamplePlugin;

impl Guest for ExamplePlugin {
    fn process(mut book: Book) -> Book {
        for item in &mut book.items {
            item.content = item.content.replace("WASM", "Wasm");
        }
        book
    }
}

export!(ExamplePlugin);
}

That's the entire preprocessor. It receives a Book, iterates over the items, does a string replacement, and returns the modified Book. No deserialization. No memory management. No unsafe.

Build it:

cargo build --target wasm32-wasip2

The Host

The host loads the component, creates a Book with some test content, calls process, and prints the result.

cd part-3/host
cargo init

Cargo.toml:

[package]
name = "example-host"
version = "0.1.0"
edition = "2021"

[dependencies]
wasmtime = "29"
wasmtime-wasi = "29"
anyhow = "1"

src/main.rs:

use anyhow::Result;
use wasmtime::{Engine, Store};
use wasmtime::component::{Component, Linker};
use wasmtime_wasi::{WasiCtxBuilder, WasiCtx, WasiView, ResourceTable};

wasmtime::component::bindgen!({
    world: "example-plugin",
    path: "../plugin/wit",
});

struct State {
    wasi: WasiCtx,
    table: ResourceTable,
}

impl WasiView for State {
    fn ctx(&mut self) -> &mut WasiCtx { &mut self.wasi }
    fn table(&mut self) -> &mut ResourceTable { &mut self.table }
}

fn main() -> Result<()> {
    let engine = Engine::default();
    let mut linker = Linker::new(&engine);

    wasmtime_wasi::add_to_linker_sync(&mut linker)?;

    let state = State {
        wasi: WasiCtxBuilder::new().build(),
        table: ResourceTable::new(),
    };
    let mut store = Store::new(&engine, state);

    let bytes = std::fs::read("../plugin/target/wasm32-wasip2/debug/example_plugin.wasm")?;
    let component = Component::new(&engine, bytes)?;

    let instance = linker.instantiate(&mut store, &component)?;

    let plugin = ExamplePlugin::new(&mut store, &instance)?;

    let book = Book {
        items: vec![
            BookItem {
                chapter_name: "Chapter 1".to_string(),
                content: "WASM is a technology. WASM stands for WebAssembly.".to_string(),
            },
            BookItem {
                chapter_name: "Chapter 2".to_string(),
                content: "Using WASM plugins is powerful.".to_string(),
            },
        ],
    };

    let result = plugin.call_process(&mut store, &book)?;

    for item in &result.items {
        println!("=== {} ===", item.chapter_name);
        println!("{}", item.content);
        println!();
    }

    Ok(())
}

Run it:

cargo run
# === Chapter 1 ===
# Wasm is a technology. Wasm stands for WebAssembly.
#
# === Chapter 2 ===
# Using Wasm plugins is powerful.

Every "WASM" has been replaced with "Wasm". The plugin logic is three lines. The host passes structured data in and gets structured data back.

What About the Original's JSON-over-stdin Approach?

The original mdbook integration used stdin/stdout with JSON serialization because that was mdbook's plugin protocol — and because WASM couldn't do file I/O. The Component Model doesn't change the fact that mdbook's protocol is JSON-over-stdin. What it does change is the experience inside the plugin boundary:

  • Original: The plugin received raw bytes via pointer/length, manually deserialized JSON, modified the Book, re-serialized to JSON, and wrote the result back to memory.
  • Component Model: The plugin receives a Book struct. It modifies it. It returns it. The host can adapt to mdbook's JSON protocol at the boundary — translate JSON → WIT types before calling the component, and WIT types → JSON after.

This is the key architectural insight: the Component Model gives you a clean interface inside the WASM boundary, and you adapt to external protocols at the edges. Your plugin code stays simple and type-safe. The adaptation layer — JSON parsing, stdin/stdout, whatever — lives in the host, where it belongs.

Host Imports: Letting the Plugin Call the Host

So far, our plugins only export functions. But the Component Model also supports imports — functions the host provides that the plugin can call. This is useful for capabilities like logging, HTTP requests, or any host-provided service.

Add an import to the WIT file:

package example:plugin;

record book-item {
    chapter-name: string,
    content: string,
}

record book {
    items: list<book-item>,
}

world example-plugin {
    import log: func(msg: string);
    export process: func(book: book) -> book;
}

Now the plugin can call log() to send messages to the host:

#![allow(unused)]
fn main() {
wit_bindgen::generate!({
    world: "example-plugin",
});

struct ExamplePlugin;

impl Guest for ExamplePlugin {
    fn process(mut book: Book) -> Book {
        log(&format!("Processing {} items", book.items.len()));
        for item in &mut book.items {
            item.content = item.content.replace("WASM", "Wasm");
        }
        book
    }
}

export!(ExamplePlugin);
}

On the host side, you provide the log function through the Linker:

#![allow(unused)]
fn main() {
let mut linker = Linker::new(&engine);
linker.root().func_wrap("log", |mut store, msg: String| {
    println!("[plugin] {msg}");
    Ok(())
})?;

let instance = linker.instantiate(&mut store, &component)?;
}

The bindgen! macro generates a trait that the linker expects, but for simple cases, func_wrap works directly. Either way, the host controls what the plugin can access — no ambient authority, no surprise filesystem access.

The Full Picture

Here's what we built across all three parts:

PartOriginalComponent Model
1 — String in, number outManual memory writes + from_raw_partsfn length(s: String) -> u32
2 — Structured databincode + pointer arithmetic + reserved memoryWIT records → Rust structs
3 — Real pluginJSON-over-stdin + manual deserializationWIT types at the boundary, adapt protocols at the edges

The Component Model doesn't just make the code shorter — it makes it correct by construction. You can't forget to write the return length to the right memory offset. You can't get the pointer arithmetic wrong. You can't have a mismatch between what the host serializes and what the plugin expects. The WIT file is the single source of truth, and wit-bindgen ensures both sides agree.

Going Further

  • Multiple plugins: The host can load multiple components, each targeting the same WIT world. Swap plugins by swapping the .wasm file.
  • Versioning: WIT supports package versions. Evolve your interface without breaking existing plugins.
  • WASI: Components can use WASI 0.2 for filesystem, network, and other capabilities — no adapter needed when targeting wasm32-wasip2.
  • Other languages: The same WIT file can generate bindings for C, C++, Go, C#, Python, and more. Your plugin interface isn't locked to Rust.

The Component Model is what WASM plugin systems should have been from the start. Now they can be.

REVIEW.md — WebAssembly Component Tutorial

Reviewed by Esme, 2026-04-21

Overall

This is strong work. The progression from Part 1 through Part 3 is well-structured, the before/after comparisons with the original series are compelling, and the writing voice is clear and conversational without being sloppy. The "plumbing goes away" thesis is well-argued. Below are the issues I found.


Part 1

[clarity] The sentence "Think of it as a language-agnostic interface definition — like a .proto file, but for WASM." is great. More of this kind of analogy, please.

[suggestion] The section "The Interface" jumps straight into creating directories and writing a WIT file without explaining what a "world" is in WIT terminology. You say "our plugin exports a function called length" but the concept of a world is new to the reader at this point. A one-sentence explanation — "A WIT world defines the contract: what the component exports and imports" — would help.

[clarity] The bindgen! path in the host is path: "../plugin/wit" but the README text says "Copy or symlink it" into a local wit/ directory. These are contradictory — the code uses a relative path to the plugin's wit directory, not a local copy. If the intent is to use the relative path (which the code does), the copy/symlink instructions are unnecessary and confusing. Pick one approach.

[style] "we just... implement a trait" — the ellipsis is a bit informal for the tone. Minor.


Part 2

[error] The expected output for multiply is wrong. The README says:

# count: 15, word: hellohellohello

But the code computes new_count = pair.count.wrapping_mul(repeated.len() as u8) = 3 * 15 = 45. The output should be count: 45, word: hellohellohello. Either the expected output or the multiply logic needs fixing.

[clarity] The multiply function's purpose is unclear. It takes a (count, word) pair, repeats the word count times, then multiplies count by the length of the repeated string. This is confusing as a teaching example — the reader is trying to understand data passing, not puzzle through arithmetic. A simpler multiply (e.g., repeat the word count times and return count + 1) would keep the focus on the WIT record passing, which is the point of Part 2.

[suggestion] The "WIT Types Beyond Records" section is a nice reference but feels like it belongs in an appendix or a sidebar. It breaks the flow of the tutorial — you've just shown structured data working, and then you list a bunch of types you don't use. Consider moving it to the end as a "Reference" or integrating the types into the examples.

[error] The WIT type result<u32, string> maps to Result<u32, String> in Rust only for the guest (plugin) side. On the host side via wasmtime bindgen, results are represented differently — typically as a struct with ok/err fields or as Result<T, E> depending on the bindgen configuration. This should be noted if you're claiming direct Result mapping.


Part 3

[error] The "Host Imports" section shows adding import log: func(msg: string); to the WIT file and using log() in the plugin, but the actual part-3/plugin/wit/world.wit and part-3/plugin/src/lib.rs don't include this. If this is meant to be an incremental addition shown in the tutorial, that's fine — but it should be clearly marked as "here's how you'd extend it" rather than presented as something the reader has already built. The current framing reads like the code the reader already wrote should have log in it.

[error] The host import linker.root().func_wrap("log", ...) is likely incorrect. With wasmtime's component bindgen, host imports need to be registered on the specific interface/world namespace, not on the root. The correct API would be something like linker.root().instance_wrap(...) or using the generated ExamplePluginAddToLinker trait. The func_wrap call as shown probably won't compile with wasmtime 29's component API. This needs verification against the actual wasmtime 29 API.

[clarity] The section "What About the Original's JSON-over-stdin Approach?" is excellent — it addresses the question the reader is definitely thinking. Good placement.

[clarity] The sentence "The adaptation layer — JSON parsing, stdin/stdout, whatever — lives in the host, where it belongs" is a key architectural point that could use a bit more unpacking. Why does it belong in the host? Because the host owns the boundary with the outside world. One more sentence would nail this.

[suggestion] The "Going Further" section lists multiple plugins, versioning, WASI, and other languages. These are good teasers but "WASI" deserves slightly more than a bullet point — it's directly relevant because the reader is targeting wasm32-wasip2, and they might wonder what "p2" means and why it matters.


Cross-Cutting

[error] The wasmtime version "29" should be verified against the current release. Wasmtime's API has changed significantly between versions, and the component model APIs in particular have been evolving. If the reader tries wasmtime 29 and the APIs have changed, every code example breaks. Consider pinning to a specific minor version (e.g., "29.0" or whatever is current) and noting the version explicitly.

[error] The wit-bindgen version "0.41" should similarly be verified. The wit_bindgen::generate! syntax and the Guest trait generation have changed across versions.

[suggestion] All three parts have near-identical host boilerplate (Engine, Store, read bytes, Component::new, Linker, instantiate, get plugin). By Part 3, this is repetitive. Consider extracting a helper function or at least acknowledging the repetition and explaining why you're showing it each time (e.g., "We'll show the full setup each time so each part is self-contained").

[style] The comparison tables are effective. The "That's it." single-line paragraphs after showing simplified code land well — don't overuse them but they work here.

[suggestion] There's no mention of error handling on the plugin side. What happens if the plugin panics? What if process gets a malformed book? A brief note about how panics propagate (or don't) across the component boundary would be valuable for a "real world" tutorial.


What's Working

  • The before/after framing is the strongest part of this tutorial. Every section earns its place by showing concrete code that was replaced.
  • The progression from simple → structured → real-world is clean and natural.
  • The writing voice is confident without being condescending. Honest about simplifications.
  • The WIT file is used as the single source of truth throughout — good architectural discipline.