Component Composition with Spin 4.0
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.2or newer) - The
wasm32-wasip2target for Rust (rustup target add wasm32-wasip2) - Spin CLI (version
4.0.2or 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-bindgenset 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:
RequestModelandResponseModelto 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
classifiercomponent. - If requests use methods other than
POST, we return a405 - The classification is now provided as response payload using proper
content-typeheader
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
classifyinterface) that cross component boundaries seamlessly. - Component Dependencies: Setting
[component.blog-demo.dependencies]inspin.tomlallows 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. π