Thanks to the WebAssembly Component Model we can stitch together multiple WebAssembly Components. Reusing Wasm Components boost developer velocity dramatically while language barriers vanish. In this hands-on article, I’ll explain how you can compose Wasm Components using Spin 4.0.

What You Need To Follow AlongπŸ”—

I’ll try to keep things as simple as possible. That’s why we’ll implement both components using Rust, resulting in the following - short - list of requirements:

  • Rust (version 1.97.2 or newer)
  • The wasm32-wasip2 target for Rust (rustup target add wasm32-wasip2)
  • Spin CLI (version 4.0.2 or newer)

What We’ll BuildπŸ”—

For the sake of this article, we’ll implement a simple Spin application which will respond to incoming HTTP POST requests. We’ll validate the payload sent as part of the request and classify the importance of the message provided. The classification will be returned back to the callee as classification property on a JSON object.

Wasm Composition Illustration - Vibe-Crafted with Gemini

We’ll implement the classification itself as a self-contained Wasm component. The entire HTTP-releated code and control flow remains in the bounds of the top-level Wasm Component created by Spin CLI as part of creating the project itself.

Scaffolding The Spin ApplicationπŸ”—

Creating a new application with Spin is as easy as executing spin new. We provide the desired template (http-rust) using the -t flag along with the application name and accept defaults for remaining template parameters by adding the -a (or --accept-defaults) flag:

spin new -t http-rust -a blog-demo
# move into the blgo-demo folder
cd blog-demo

Creating The classifier Wasm ComponentπŸ”—

Instead of creating the classifier Wasm component using cargo new and customizing the Cargo.toml, I’ll use a custom template for Spin which I’ve created a few days ago.

To install the template, use these commands:

spin templates install \
  --git https://github.com/ThorstenHans/spin-headless-components

Once done, you can add the classifier component to the blog-demo application using spin add:

spin add -t headless-rust classifier -a

Using the headless-rust template gives you the following advantages:

  • βœ… Seamless integration with spin build
  • βœ… wit-bindgen set as dependency
  • βœ… Binding generation macro in place
  • βœ… WIT world Skeleton
  • βœ… Proper crate configuration

A Quick Sanity CheckπŸ”—

If you’re following along, the contents of the blog-demo folder should now look like this:

.
β”œβ”€β”€ Cargo.toml
β”œβ”€β”€ classifier
β”‚Β Β  β”œβ”€β”€ Cargo.toml
β”‚Β Β  β”œβ”€β”€ src
β”‚Β Β  β”‚Β Β  └── lib.rs
β”‚Β Β  └── wit
β”‚Β Β      └── world.wit
β”œβ”€β”€ spin.toml
└── src
    └── lib.rs

Matches yours? Good! If not, try to check the commands you executed and ensure, you’ve used the same templates as I did.

Defining The WIT WorldπŸ”—

Let’s get moving! We’ll start by changing the contents of ./classifier/wit/world.wit and make our component export the classify interface as part of the classifier world:

package thorstenhans:[email protected];

interface classify {
    classify: func(input: string) -> string;
}

world classifier {
    export classify;
}

If you’ve never worked with WIT before, consider reading this section of the WebAssembly Component Model book.

Implementing The classifierπŸ”—

With the WIT world being defined, we can move on and provide the actual implementation.

If your IDE or code editor has proper Rust tooling installed, you should already see a bunch of errors being reported as part of the ./classififer/src/lib.rs file. This is because we’ve changed the WIT world and wit-bindgen already regenerated the binding code behind the scenes. If your environment does not report any errors yet, you might wanna run spin build in the blog-demo folder which will try to re-compile both components.

Okay, let’s now address the broken implementation of the classifier component:

use crate::bindings::exports::thorstenhans::components::classify::Guest;

mod bindings {
    wit_bindgen::generate!({
        path: "wit/world.wit",
    });
    use super::ClassifierComponent;
    export!(ClassifierComponent);
}
struct ClassifierComponent;

impl Guest for ClassifierComponent {
    #[allow(async_fn_in_trait)]
    fn classify(input: String) -> String {
        let input = input.to_lowercase();
        let mut result = "neutral";

        if input.contains("asap") ||
          input.contains("today") ||
          input.contains("immediately")
          {
           result = "urgent";
        }else if input.contains("later") ||
        input.contains("whenever") ||
        input.contains("someday") {
           result =  "relaxed";
        } 
        result.to_string()
    }
}

Running spin build once again, it should complete successfully, indicating that you’ve just created a working Wasm Component πŸŽ‰.

Defining The Component Dependency In spin.tomlπŸ”—

Looking at the Spin application, there is one additional modification we’ve to apply in spin.toml, before we could look at the code of our HTTP-triggered Spin component.

We’ve to specify the classifier component as a dependency of the blog-demo component itself. This is done by adding a new table to the application manifest:

# existing spin.toml...
[component.blog-demo.dependencies]
"thorstenhans:[email protected]" = { 
  path = "./classifier/target/wasm32-wasip2/release/classifier.wasm" 
}

Binding Generation - Again πŸ€˜πŸΌπŸ”—

Obviously, we need some kind of glue code within the blog-demo component as well. The Spin SDK streamlines this process as we don’t have to use the macro defined by underlying wit-bindgen.

Instead all we’ve to do is adding the spin_sdk**::**dependencies!(); macro call in src/lib.rs. Personally, I prefer having it immediately before the service definition:

use spin_sdk::http_service;

spin_sdk::dependencies!();

#[http_service]
// ...

You know the play! Run a spin build again and It will generate the bindings for the blog-demo component based on the dependency we added to our application manifest earlier.

Using the classifierπŸ”—

As our interface exports a function called classify, we can bring that one straight into scope and use it. We’ll hardcode a value for now, ensure it compiles, before we take care of the HTTP related stuff:

use crate::thorstenhans::components::classify::classify;

spin_sdk::dependencies!();
#[http_service]
async fn handle_blog_demo(_req: Request) -> anyhow::Result<impl IntoResponse> {    
    let classification = classify("Buy Milk ASAP");
    Ok(Response::builder()
        .status(200)
        .header("content-type", "text/plain")
        .body(classification))
}

Finishing The blog-demo ImplementationπŸ”—

Let’s first add serde and serde_json:

cargo add serde -F derive
cardo add serde_json

Here the final code for the blog-demo component. We’ll iterate over some of the additions below:

use serde::{Deserialize, Serialize};
use spin_sdk::http::body::IncomingBodyExt;
use spin_sdk::http::{IntoResponse, Method, Request, Response, StatusCode};
use spin_sdk::http_service;
use crate::thorstenhans::components::classify::classify;

spin_sdk::dependencies!();

#[derive(Deserialize)]
pub(crate) struct RequestModel {
    pub message: String
}

#[derive(Serialize)]
pub(crate) struct ResponseModel {
    pub classification: String
}

#[http_service]
async fn handle_blog_demo(req: Request) -> anyhow::Result<impl IntoResponse> {
  if req.method() != Method::POST {
        return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response());
  }
  let body_stream = req.into_body();

  let Ok(bytes) = body_stream.bytes().await else {
    return Ok(StatusCode::BAD_REQUEST.into_response());
  };
  let Ok(payload) = serde_json::from_slice::<RequestModel>(&bytes) else {
    return Ok(StatusCode::BAD_REQUEST.into_response());
  };
  if payload.message.is_empty() {
    return Ok(StatusCode::BAD_REQUEST.into_response());
  }

  let payload = serde_json::to_string(&ResponseModel {
    classification: classify(&payload.message)
  })?;

  Ok(Response::builder()
    .status(200)
    .header("content-type", "application/json")
    .body(payload).into_response())
}

In the final implementation, we’ve added:

  • RequestModel and ResponseModel to create struct instances from incoming HTTP request payloads and to produce the desired response payload as JSON
  • We’ve validated incoming request payloads before we hand the actual data over to the classifier component.
  • If requests use methods other than POST, we return a 405
  • The classification is now provided as response payload using proper content-type header

Testing The ApplicationπŸ”—

Finally, we can test our Spin application by running it on our local machine with spin up:

spin up

Here a bunch of curl requests for testing purposes, along with their responses:

# an urgent message
curl -XPOST -d '{"message": "Buy milk today"}' \
  -H 'content-type:application/json' \
  localhost:3000
# {"classification":"urgent"}

# a relaxed message
curl -XPOST -d '{"message": "Someday we should buy milk"}' \
  -H 'content-type:application/json' \
  localhost:3000
# {"classification":"relaxed"}

# a neutral message
curl -XPOST -d '{"message": "Buy milk"}' \
  -H 'content-type:application/json' \
  localhost:3000
# {"classification":"neutral"}

RecapπŸ”—

Component composition in Spin 4.0 makes stitching together decoupled WebAssembly components feel remarkably smooth:

  • Scaffolding: Using the custom Spin template (headless-rust`), you can quickly add dedicated sub-components without huge manual configuring efforts.
  • Interface Definitions: WIT files remain the single source of truthβ€”defining clean exports (like our classify interface) that cross component boundaries seamlessly.
  • Component Dependencies: Setting [component.blog-demo.dependencies] in spin.toml allows keeping track of dependencies at a central place
  • Auto-generated Glue Code: Calling spin_sdk::dependencies!() eliminates the friction of manually adding binding macros, pulling imported interfaces straight into scope.

By decoupling the core classification logic into its own Wasm component, we kept the HTTP control flow lean, standard, and easy to maintainβ€”all powered by Rust and WASI P2. πŸš€