DEV Community

Nikolay Kim
Nikolay Kim

Posted on

Component / Service Model

The term “component model” is somewhat overloaded. It often brings to mind complex IoC containers, layers of dependency injection, and a fair amount of indirection. That’s not what we’re aiming for here.

What we want instead is something much simpler: a way to build reusable components that are, above all, easy to compose.

Imagine a service whose only responsibility is to execute an operation and return a result. Internally, it might be arbitrarily complex—but from the outside, its interface should remain minimal and predictable.

In Rust, we can express this idea using the most basic building block we have: a function.

async fn execute(op: Operation) -> Result<OperationResult, Err> {
   ...
}
Enter fullscreen mode Exit fullscreen mode

That’s it.

This tiny abstraction already gives us everything we need. It takes a single input and produces a single output. There’s no hidden state, no framework magic, and no ceremony. Just a clear contract.

And importantly, this kind of interface is trivial to test, easy to reason about, and naturally composable.

Now, let’s imagine we want to use our execution engine service inside an http endpoint. What do we actually need in that case?

At a minimum, we need a thin layer responsible for http concerns: deserialization and serialization. In other words, something that can load an Operation from an incoming request and convert an OperationResult into an http response.

Once again, the shape of this can stay very simple.

Our http endpoint might look like just another function:

async fn endpoint(req: HttpRequest) -> Result<HttpResponse, Error> {
    // Load operation from the request
    let oper = load_operation(req).await?;

    // Execute it using our engine
    let result = execute(oper).await?;

    // Convert the result into an HTTP response
    Ok(into_response(result).await?)
}
Enter fullscreen mode Exit fullscreen mode

There’s nothing particularly fancy happening here. The endpoint is just glue code:

  • translate http request → domain (Operation)
  • call the service
  • translate domain → http response (OperationResult)

Each piece has a single responsibility, and the boundaries are explicit. The execution engine knows nothing about http, and the http layer knows nothing about how the operation is executed.

What you might notice is that all of these services are completely independent. None of them depend on each other directly. Each one simply represents a transformation from an input type to an output type.

That’s the key idea.

To compose them, all we need is for the output of one service to match the input of the next. When all input and output types line up, we naturally get a transformation chain.

In our example, we’re ultimately transforming an http request into an http response:

HttpRequest -> HttpResponse
Enter fullscreen mode Exit fullscreen mode

What happens in between is an implementation detail.

We can insert additional steps—like authentication or authorization—without changing the overall shape of the system. It’s still just a sequence of transformations from request to response.

async fn endpoint(req: HttpRequest) -> Result<HttpResponse, Error> {
    // Authentication
    let req = authn(req).await?;

    // Load operation from the request
    let oper = load_operation(req).await?;

    // Authorization
    let oper = authz(oper).await?;

    // Execute it using our engine
    let result = execute(oper).await?;

    // Convert the result into an http response
    Ok(into_response(result).await?)
}
Enter fullscreen mode Exit fullscreen mode

Each step is small, focused, and composable. There’s no shared state, no tight coupling—just a pipeline of transformations.

Once you start thinking in these terms, the “component model” becomes almost trivial: components are just functions, and composition is just wiring outputs to inputs.

And that simplicity scales surprisingly far.

Now that we have the idea, we can start formalizing what a “service” actually is.

At its core, a service is just something that takes an input and produces an output—possibly asynchronously, and possibly failing. That should already sound familiar.

We can capture this idea with a simple Service trait, which looks very similar to Rust’s Fn traits:

trait Service {
/// Response produced by the service.
type Response;

/// Error produced by the service.
type Error;

async fn call(&self, req: Request) -> Result<Self::Response, Self::Error>;
Enter fullscreen mode Exit fullscreen mode

}

This abstraction gives us a uniform way to describe any transformation—from Request to Response.

The important part is what this unlocks.

We’re no longer limited to plain functions. A service can be:

  • a function,
  • a struct,
  • or a more complex composition of other services.

And once everything implements the same Service trait, we can start building generic operations that work over any service—middleware, combinators, pipelines, and so on.

In other words, we move from “just calling functions” to describing a system where everything is composable by design.

Final Notes

If you look closely at network services—especially the generic and reusable parts—you’ll notice something interesting: many of these components naturally fit the service model we’ve been discussing.

For example, consider a TCP connection handler. We can describe it like this:

impl Service<TcpStream, Response = (), Error = io::Error>
Enter fullscreen mode Exit fullscreen mode

At its core, it’s just a function: it takes a TcpStream and either handles it successfully or returns an error. With this abstraction, you can build a generic server capable of handling any kind of TCP connection. In fact, this is exactly how ntex-server works under the hood.

Next, think about a TCP connector—something that creates outbound connections:

impl Service<net::SocketAddr, Response = TcpStream, Error = io::Error>
Enter fullscreen mode Exit fullscreen mode

It takes a socket address and returns a live TCP stream. Simple, composable, and reusable anywhere in your network stack.

Finally, consider a TLS handshake:

impl Service<T: Stream, Response = TlsStream<T>, Error = io::Error>
Enter fullscreen mode Exit fullscreen mode

It transforms a plain stream into a secure TLS stream. The beauty is that it doesn’t care whether the stream comes from a server, a client, or even an in-memory source.

The common thread here is clear: each of these components is just a transformation from input to output. By modeling them as services, we can compose them, reuse them, and swap them in and out without rewriting code. It’s the same idea we explored with HTTP requests earlier—applied to networking in general.

These ideas are implemented in practice in the ntex-service crate, which applies this model to real-world use cases. In fact, the entire ntex framework is built on top of the “service” model—from top to bottom.

Naturally, once you move from a minimal abstraction to a production-ready system, things become more complex than the simplified examples shown here. The crate introduces additional concepts that we haven’t covered yet—such as pipelines, middlewares, service readiness, and configuration.

Each of these plays an important role in making the model practical and scalable in real applications.

I’ll cover them in the next posts.

Top comments (0)