Files
kasperhermansen-blog/content/posts/2024-02-18-building-business-services-in-rust.md
kjuulh 4bb6b0228a
Some checks failed
continuous-integration/drone/push Build is failing
feat: add blog contents
2025-07-31 11:01:22 +02:00

670 lines
24 KiB
Markdown

---
type: blog-post
title: Building services in rust
description: This post should show that it is not just possible to write production ready services in rust, but that it can be a nice experience without too much boiler plate. I also go over some does and dont's so that you hopefully have a nice experience
draft: false
date: 2024-02-19
updates:
- time: 2024-02-19
description: first iteration
tags:
- "#blog"
---
Building business services might sound like a boring topic, and in some
instances it can be. But from my point of view, building business services is a
test for how ready a language or tool is, to achieve mainstream appeal. With
this article I hope to show that Rust is right around that point, if not already
there, so lets jump right in.
# But what is a business service
First of all we should define some criteria of what a business services is,
otherwise we have nothing to measure against, even if we're gonna use half-baked
fluffy metrics to decide on anyways.
A business service, is a long running application, capable of running multiple
different endpoints at once, maybe an http stack on a port, grpc on another,
sending logs somewhere, calling external services, putting stuff on a queue, you
name it. It is a multi facetted application that serves an external need of some
sort and is basically a shell around some business logic.
A business service can be anything from a microservice, which serves one of the
above, or monolith, it doesn't really matter as those are orthogonal metrics,
i.e. they are about scale not capabilities, how much damned stuff you can cram
in a box, and how many engineers to page once it goes down.
Most importantly of all a business service should be testable, it should be
relatively self-serving once not receiving direct maintenance other than patch
upgrades, and do its absolute damnest to fulfill its requirements; serve a
business need.
To me the most important is test-ability, can it serve:
- Unittests
- Integrationtests
- Acceptests
If these above are cumbersome to do, then the language isn't ready for
mainstream usage. It doesn't matter how capable, how fast, or how secure it is,
if normal engineers can't write code in the language without arcane knowledge
then it isn't ready.
So to sum up the criteria:
- Ergonomics
- How easy is it to manage external dependencies
- Testability
As you can probably tell, these are not some of rusts core values, maybe except
for ergonomics, but I'll show that it is still possible to do great work in it.
# Rust as a Service
Lets start from the top and go through a few architectural patterns that are
common in business software, such as handling dependency injection, interfaces
and concrete types, strategy pattern, etc. And which tools you need to rely on
to achieve them.
## Dependency injection ~~hell~~
Dependency management or injection as it is normally called for services, is
simply a way for a function to take in some abstraction from outside and use its
functionality, without having to deal with the complexities of how it actually
implements said functionality. It is also extremely useful for testing a piece
by itself.
I come from an object-oriented background as such that is usually how I go about
solving these issues, especially as Rusts functional programming model have some
ergonomic downsides that makes it difficult to do dependency injection using it
(for reasons I won't go into here).
Usually you use dependency injection via a constructor
```rust
pub struct MealPlannerAPI {
meal_planner: MealPlannerDatabase
}
impl MealPlannerAPI {
pub fn new(meal_planner: MealPlannerDatabase) -> Self {
Self {
meal_planner
}
}
pub async fn book_meal(&self) -> Result<()> {
self.meal_planner.book_meal(/* some input */).await?;
Ok(())
}
}
```
Quite simply we take in some `struct` or `trait` in the `new` function, which
serves as our constructor. And we can now just call the `book_meal` on the
`meal_planner` inner type. This has a few benefits. If the input is a trait, we
can mock it out, or we can use macro to mock a struct and swap a concrete value
with it (even if I don't recommend it, but more on that later).
Lets say for now that MealPlannerDatabase is a `trait`
```rust
#[async_trait]
pub trait MealPlannerDatabase {
pub async fn book_meal(&self) -> Result<()>;
}
pub struct MealPlannerPsqlDatabase {
psql: sqlx::ConnPool<Postgres>
}
impl MealPlannerPsqlDatabase {
pub fn new(psql: sqlx::ConnPool<Postgres>) -> Self {
Self {
psql
}
}
}
#[async_trait]
impl MealPlannerDatabase for MealPlannerPsqlDatabase {
pub async fn book_meal(&self) -> Result<()> {
self.psql.query!("INSERT ... INTO ...").execute(&mut self.psql).await?;
Ok(())
}
}
```
This is a small example, which we'll make use off later, but notice that we've
split up the implementation up into two parts, the interface (contract) and the
concrete type (implementation). This helps us in a few ways, i.e. we can swap
out the implementation for either a different database in this case, or a mock
if we want to test the `MealPlannerAPI`.
We can also add the `mockall` trait to our trait to automatically get mocks
generated. This is quite convenient, but comes with some downsides in that it
can reduce the feature set that you would normally have available. For example
you cannot use `impl` in functions.
The keen eyed among you may notice that the above code wouldn't actually
compile. I.e. you cannot take a trait as input to a function without a pointer,
this is because we don't know the size of said trait (it may be any of the
possible implementations or none), as such we need some abstraction around it.
Secondly, the database might have some requirements that it needs to be called
exclusively, so it may need an `Arc` or a `Mutex`, which we didn't deal with
either.
For that we'll make use of the facade pattern. I.e. we're gonna create a facade,
such that our external code doesn't have to deal at all with us having a trait,
a mutex, arc whatever. The only thing that matters is that it can depend on the
functionality without too much hassle.
```rust
#[derive(Clone)]
pub struct MealPlannerDatabase(Arc<dyn traits::MealPlannerDatabase>);
impl MealPlannerDatabase {
// Options we want to expose
pub fn psql(psql: sqlx::ConnPool<Postgres>) -> Self {
Self(Arc::new(MealPlannerPsqlDatabase::new(psql)))
}
// Escape hatch
pub fn dynamic(concrete: Arc<dyn traits::MealPlannerDatabase>) -> Self {
Self(concrete)
}
}
impl std::ops::Deref for MealPlannerDatabase {
Target = Arc<dyn traits::MealPlannerDatabase>
deref(&self) -> Self::Target {
&self.0
}
}
```
Now you could technically have an `Arc`, `Mutex`, whatever and the consumer
would be none the wiser, it still allows you to use the inner functions as you
normally would `self.meal_planner.book_meal().await?`.
You can even expand on it with an actual inner pattern if you need the Mutex, or
something more complicated. The `dynamic` specifies that we can still use it as
a test, as we can replace the internals with our mock.
### Shared dependencies as a Service
The last pattern I want to show is shared dependency management. For that we'll
use a few rust features as well. The corner stone of the pattern is to create a
single shared resource, which we can use to new up all the required dependencies
we need.
```rust
pub struct App {
}
#[derive(Clone)]
pub struct SharedApp(Arc<App>)
impl SharedApp {
pub fn new() -> Self {
Self(Arc::new(App{}))
}
}
impl std::ops::Deref for SharedApp {
Target = Arc<App>;
fn deref(&self) -> Self::Target {
&self.0
}
}
```
Again we use a custom deref that makes sure we can reach the inner pattern,
without having to wrap everything in Arcs, and or mutexes. I forgot to mention
why we do so. When you've got 10-100 dependencies, it becomes a little long in
the tooth, to have to wrap each an everything in Arcs because the `SharedApp` is
a shared object and needs to be `clone`.
Before we move on to how to actually use this pattern, I'd like to give a
recommendation. The App should not contain every single struct you need, it
should contain foundational IO resources. Such as a database connection pool,
queue manager, grpc connection, logger instance etc. Things that need setup from
external configuration.
```rust
pub struct App {
psql: sqlx::ConnPool<Postgres>
// ...
}
```
That means that it won't contain `MealPlannerAPI` or `MealPlannerDatabase`.
We'll get to those in another way.
To actually get to the concrete types we'll use something called extension
traits
```rust
//file: meal_planner_api.rs
pub struct MealPlannerAPI {
// skipped for brevity ...
}
pub mod extensions {
pub trait MealPlannerAPIExt {
fn meal_planner_api(&self) -> MealPlannerAPI;
}
impl MealPlannerAPIExt for SharedApp {
fn meal_planner_api(&self) -> MealPlannerAPI {
MealPlannerAPI::new(self.meal_planner_database())
}
}
}
```
This means that we can now from the outside call `app.meal_planner_api()` and
we'll get an instance of the concrete type. If you've got a high volume service,
you can either choose to move these values down into the shared struct itself,
or cache them in the `SharedApp` using an object pool. In most cases the
performance cost is negligible. In some cases rust will even inline these
functions even if they're traits, to make them faster.
The database is the same, but uses values on self instead.
```rust
//file: meal_planner_database.rs
pub struct MealPlannerDatabase {
// skipped for brevity ...
}
pub mod extensions {
pub trait MealPlannerDatabaseExt {
fn meal_planner_database(&self) -> MealPlannerDatabase;
}
impl MealPlannerDatabaseExt for SharedApp {
fn meal_planner_database(&self) -> MealPlannerDatabase {
MealPlannerDatabase::psql(self.psql.clone())
}
}
}
```
Notice that we use the `psql` method instead, and that this acts like a normal
struct, even if it fronts for a trait. This is super convenient. This also means
that you could technically create multiple `App`s for different purposes and
only choose to implement the extensions for those that need said dependencies.
This should cover all of our needs to handling dependencies. And if you'd like
to can see this in action at: https://git.front.kjuulh.io/kjuulh/flux-releaser.
Where I heavily use this pattern both for a cli and for a service in the same
crate.
## Dependencies all of them
We may have to run multiple different hot paths in our code, which are code
paths which see high traffic, or where the main traffic comes through. This may
be a http runtime, grpc, messaging etc.
For that right now, tokio is the name of the game. This is also why I didn't
touch on the question above of why I marked nearly every function as async. If
you develop this kind of software, it is a given that nearly all functions will
touch some IO, and as such will be async, if not you will just have to go back
afterwards and add async.
You want a fast, ergonomic, and stable runtime. In most languages these are
built in. In rust the defacto standard is tokio. Even if there are multiple
other alternatives on the marked, but for now, tokio is what you'd probably
choose if you built services. It may change in the future though so don't take
my word as gospel, and figure out what fits best for you. The only thing I ask
is that you be consistent.
Tokio has the benefit of being able to spawn many virtual `threads` (tasks), and
as such even if we only have a single core, or part of one. We can still run
asynchronous work.
This should most of the time be done by a lifecycle management library,
something that can make sure that a bunch of parallel services are running at
the same time, and if one fails they all shut down. But we can just start by
hacking our own together to illustrate how it works.
```rust
#[tokio::main]
async fn main() -> Result<()> {
let app = SharedApp::new();
tokio::select! {
res = app.meal_planner_api().serve() => {
res
},
res = app.meal_planner_grpc().serve() => {
res
},
// .. As many as you'd like
}
Ok(())
}
```
This is a bit of a naive example, but should illustrate that you can run
multiple tasks at the same time serving requests. Do note that if one exits all
of them will terminate. But we can now share `app` between all the different
runtimes and execution flows, like you'd normally do in any service.
I will go into how to actually make a nice development environment in another
article, such that you should know which packages to provide as a standard
development offering. But for now we'll just let our little service setup
everything for itself. So keep in mind that the database setup, apis, runtimes
etc. could be provided by a dedicated team.
## Testability
One of the most important criteria for myself is being able to test a service. I
usually defer on writing fewer more end-2-end tests rather than a lot of small
unit tests. This is convenient, because rust doesn't make it easy to write unit
tests.
Lets start with integration tests and then afterwards move on to unittests,
because in rust they're quite different.
### Integration tests
Integration tests I categorize a a test that span an entire service, including
its io dependencies, but not other services. Such that you'd include a database,
messaging broker, but not S3 or another service in your tests. It should poke
the application from the outside, at least as much as possible, but still be
able to introspect the state of the app using the libraries. So for me
integration tests are categorized as a greybox test. Somewhere in the middle of
whitebox and blackbox.
To setup integration tests for a service in rust, is a bit different than what
you're used to. First of all, you'll want to place the test file somewhere else
than where they normally life (in the code beside the functionality is the usual
place). As such you'd create a folder in your crate:
```bash
tests/ # new folder
src/
```
Each file under tests will be module like we normally have it in rust, this will
become important later.
A tests file looks like this
```rust
#[tokio::test]
async fn can_book_a_meal() -> Result<()> {
let (endpoints, app) = setup().await?; // TODO: more on this in a bit
let resp = reqwest::post(endpoints.meal_planner_http).await?;
assert!(resp.status.is_success())
let meal_bookings = app.meal_planner_database().get_meal_bookings().await?;
// ... more asserts
Ok(())
}
```
There is a few different pieces we haven't gone through before, but the first
important piece is the setup function. You'd want as a few as possible
concurrent apps running, as such the setup can be shared across tests (this is
only possible pr. file, as each file is a binary in of itself, as such they
cannot share memory between them).
So the setup should setup an app once, let the tests do its thing, and once all
of them are done, shut down.
```rust
async fn setup() -> Result<(Endpoints, SharedApp)> {
// You need a separate tokio runtime, as otherwise it would shutdown between each test
// OnceCell to only spawn a server once
INIT.call_once(|| {
std::thread::spawn(|| {
let rt = tokio::Runtime::new().unwrap();
rt.block_on(async move {
// Init the server
let server = Server::new().await.unwrap();
// Set global options
unsafe {
ENDPOINTS = Some(server.endpoints.clone());
APP = Some(server.app.clone());
}
// Actually wait for the server, this should never terminate before the tests are done. I.e. start a webserver and stay blocking.
server.start().await.unwrap();
});
});
});
// Wait for the server to come up, i.e. call a ping endpoint or something
wait_for_server().await?;
return Ok(unsafe { (ENDPOINTS.unwrap(), APP.unwrap()) })
}
```
Again lots of technicalities (see flux_releaser for a more thorough example).
Just remember that we start up a process once in a separate thread, which has
its own runtime. Let the server run, and outside of that we wait.
A small disclaimer here, this is what I would constitute as arcane knowledge,
thankfully you only have to do this once, and it can be packaged up, so that you
don't have to deal with this complexity all the time. It is just too useful and
essential for testing to not mention.
I will also stop here for now with integration testing, if you'd like a follow
up let me know at `contact@kasperhermansen.com`.
### Unit testing
Depending on what you're doing in rust, unit testing can either be a breeze, or
an absolute nightware. Essentially if you use structs all the way down with
dependency injection shown in the previous section, without using traits, it is
very difficult to do proper unittesting. I.e. you have no way of slicing
functionality. If you use traits all the way down, then it will require a lot of
boiler plate, or excessive usage of macros. Which I will touch on after this
section.
What I recommend is:
- Using traits for IO
- Splitting functionality to make the business logic parts isolated and
testable, this is not always applicable, but does make things easier.
#### Split dat IO
IO, oh, IO without you we would just be a space heater, with you we're filled
with heartbreak, and stupid proses somehow.
IO doesn't come equal, and when I mean IO in this case, I mean side effects
pretty much, not everything that happens external to the program. I mean any
external part of your application that we've got no control over. This means
from a testing point of view, the database, sometimes filesystem, other
services, http requests, etc. etc.
This is pretty much the only place outside of the strategy pattern, where I use
traits, especially async traits.
```rust
#[async_trait]
pub trait MealPlannerDatabase {
async fn book_meal(&self) -> Result<()>;
}
pub type DynMealPlannerDatabase = Arc<dyn MealPlannerDatabase>
#[async_trait]
pub trait MealPlannerEvents {
async fn meal_booked(&self) -> Result<()>
}
pub type DynMealPlannerEvents = Arc<dyn MealPlannerEvents>
```
This means like in the previous sections that we can mock the external services,
which allows us to focus on the business logic inside the `MealPlannerAPI`, or
rather `MealPlannerService`
```rust
pub struct MealPlannerService {
// Please use the wrapper pattern shown in a previous section, this is just an example
database: DynMealPlannerDatabase,
events: DynMealPlannerEvents
}
impl MealPlannerService {
pub fn new(database: DynMealPlannerDatabase, events: DynMealPlannerEvents) -> Self {
Self {
database,
events
}
}
pub async fn book_meal(&self) -> Result<()> {
let meal_booking = self.generate_meal_booking();
self.database.book_meal(&meal_booking).await?;
self.events.meal_booked(&meal_booking).await?;
Ok(())
}
fn generate_meal_booking(&self) -> MealBooking {
// ...
}
}
```
As you can see there isn't a terrible amount of meat on this logic, I'd actually
normally argue that this shouldn't even be unit tested, but for completeness
sake, lets just say that generate_meal_booking is unreasonably complicated and
requires not just locking down its functionality, but helping guide development.
You can now choose to implement your own mocks for the `Database` and/or
`Events`. And test the `book_meal` function to make sure the database and events
are called with what you expect them too. Currently I'd either recommend rolling
your own mocks, or using `mockall`.
#### Split dat class
It may be useful in rust to simply split your functionality into multiple parts,
those that call external services, and simply isolating business logic.
```rust
impl MealPlannerService {
pub fn new(database: DynMealPlannerDatabase, events: DynMealPlannerEvents) -> Self {
Self {
database,
events
}
}
pub async fn book_meal(&self) -> Result<()> {
let meal_booking = self.generate_meal_booking();
self.database.book_meal(&meal_booking).await?;
self.events.meal_booked(&meal_booking).await?;
Ok(())
}
fn generate_meal_booking(&self) -> MealBooking {
// ...
}
}
```
Now we can simply call `generate_meal_booking`, simple as that. But now you may
say, but, but I don't get my precious 100% test coverage, and I'd like to ask if
you're out here collecting points, or actually building software. Enough
feathers ruffled, I'd highly recommend choosing wisely what to test, if you want
100% test coverage, you're gonna trade that for increased boilerplate and
complexity, and unless you're building a rocket, it may not be warranted.
This is it for testing, next one we're gonna move into a few general points
## Ergonomics
### To macro, or not to macro
Macros are useful, so much so, that they're tempting to use everywhere.
`Procmacro` is literally crack cocaine, I will provide a word of caution though.
Macros are another language inside rust, and can do anything the heart desires.
First of all if you use macros, you will trade complexity and developer
experience for decreased perceived complexity. Sometimes it is needed, other
times it is a convenience, so be sure to choose wisely.
For example:
- async_trait is essential, rust doesn't have object safe traits without, or at
least not without arcane knowledge, and increased boilerplate. This is the
only non-struct procmacro I regularly use for filling gaps in functionality.
- mockall is quite useful for generating mocks, though be careful with it, it
can introduce unexpected code, and introduce general limitations on your
traits and structs. I only use it for traits.
You should definitely use procmacros if they're essential for your app, such as
in rocket, clap, tracing, leptos, etc. A good rule of thumb is, simply to really
think if a procmacro is essential for your use-case. Often it is, most I've
overused them in the past, and had a hell of a time cleaning them up.
### Defer for simplicity
Rust has enough tools and features to do a lot of things in 100 different ways.
If you're serious about building services and product, defer for simplicity and
be consistent. You could take a stance and say that you wont use async, or never
use clone, etc. You'd end up taking on a whole load of complexity that would
make the service quite unapproachable for further development. Raw dogging
channels for request/reply is a nice feature, but honestly, it is a foundational
block of functionality not a great api.
Keep things simple, and resist the need for creating abstractions for
everything. It is okay to have the same code in a few places, and don't use
macros for doing DRY. I've never seen it play out right
### Use crates, and build your own
Quite simply if you're building services, build your own crates, tailor them to
your needs, develop a crate that automatically setups up a database connection,
bundle your own logging setup that makes sure we export things as json etc.
Implement your own context libraries for sharing variables throughout a call
etc. There are a lot of libraries that isn't useful on crates.io for others, but
if you choose to build small individual services, it can be quite useful to have
easy to use out of the box functionality
### Workspaces, be aware
Workspaces are nice, and I actually default to them for my own services, but be
careful I've got a tendency to make small libraries in these workspaces
alongside my app. This can make it difficult to know where a crate comes from,
and gives the service multiple responsibilities, or reason to be deployed /
worked on. As such remember to keep services and workspaces focused on the topic
at hand. That is unless you use a mono repo approach, but that is quite
difficult to do with rusts compile times.
# Conclusion
I hoped that I've shown you some currently good practices for how to develop
services in rust. We've covered anything I think is essential for building
production ready code, which trades some performance for increased ergonomics,
while keeping complexity at bay. It should be mentioned that this is just my own
opinions and what feels right in 2024, where we're still missing crucial async
features in rust. So it could change quite a bit over the next few years.
If you feel like something was unclear, or you'd like a topic to be expanded
upon, let me know at `contact@kasperhermansen.com`.
Thanks a lot of reading, and I hope to see you at some point to a Rust Aarhus
Meetup