Hexagonal architecture in Rust: driving adapters

Tutorial index
- Chapter 1: Hexagonal architecture in Rust
- Chapter 2: Hexagonal architecture in Rust: the domain
- Chapter 3: Hexagonal architecture in Rust: the use cases
- Chapter 4: Hexagonal architecture in Rust: driving adapters
- Chapter 5: Hexagonal architecture in Rust: driven adapters
- Chapter 6: Hexagonal Architecture in Rust: Driven Adapter Switching — CQRS
- Chapter 7: Hexagonal Architecture in Rust: Driving Adapter Switching — GraphQL
Introduction
In the previous chapter we defined our use cases and provided some simple (and actually quite useless) implementations.
We mainly focused on the hexagon interior, now it’s time to design the adapters.
As we have already discussed, our input ports coincide with our use cases, so our adapters will communicate with our domain through them.
Since we are developing a REST application, let’s focus on the (first) driving adapter: a REST controller.

We will leverage the widely used Actix-web framework, so the first thing to do is to add its dependency to the Cargo.toml
:
[dependencies]
...
actix-web = "4"
Before moving on, we organize the project structure in a way that reflects the hexagon architecture (we use this approach here to make it easier to understand what’s going on, but it’s not mandatory): create the src/driving
folder with the usual mod.rs
file. Then, inside the driving module, we create the rest_handler
one (again a folder with its mod.rs
file).
Well, now we will wrap our REST handlers inside the sandwiches
module, so add a sandwiches.rs
file in the rest_handler
folder. This is our controller responsible for handling incoming sandwich REST requests, this is our adapter. From now on, trying to simplify the tutorial, we will skip the testing part, but under the hood we are still proceeding in that way, as you can see in the code present in the repository.
Create a sandwich
Let’s start as usual with the creation of a sandwich and add the relative handler code:
/// create sandwich recipes
pub async fn create_sandwich(
request: Json<CreateSandwichRequest>,
) -> Result<Json<SandwichResponse>, ApiError> {
validate(&request)?;
let result = domain::create_sandwich::create_sandwich(
&request.name,
string_vec_to_vec_str(&request.ingredients).as_ref(),
&request.sandwich_type).await;
result
.map(|v| {
respond_json(SandwichResponse::from(v))
})
.map_err(|e| match e {
CreateError::Unknown(m) => ApiError::Unknown(m),
CreateError::InvalidData(m) => ApiError::InvalidData(m),
CreateError::Conflict(m) => ApiError::Conflict(m)
})?
}
Obviously we have a lot of errors here, but don’t worry: we are going to fix everything :)
The request struct
The creation of an entity should be done using a Post request, so we need a struct to wrap the received data. Let’s add it:
#[derive(Clone, Debug, Deserialize, Serialize, Validate)]
pub struct CreateSandwichRequest {
#[validate(length(
min = 3,
message = "name is required and must be at least 3 characters"
))]
pub name: String,
#[validate(length(
min = 1,
message = "ingredients is required and must be at least 1 item"
))]
pub ingredients: Vec<String>,
pub sandwich_type: SandwichType,
}
What’s going on here? We are leveraging the validator
crate to automate some validation mechanisms. So let’s add it to the Cargo.toml
and enable its derive
feature:
[dependencies]
...
validator = { version = "0.15", features = ["derive"] }
Create a src/driving/rest_handler/validate.rs
file with the content required to manage a simple validation against every struct implementing the Validate
trait, that we derived automatically:
pub fn validate<T>(params: &Json<T>) -> Result<(), ApiError>
where
T: Validate,
{
match params.validate() {
Ok(()) => Ok(()),
Err(error) => Err(ApiError::ValidationError(collect_errors(error))),
}
}
fn collect_errors(error: ValidationErrors) -> Vec<String> {
error
.field_errors()
.into_iter()
.map(|error| {
let default_error = format!("{} is required", error.0);
error.1[0]
.message
.as_ref()
.unwrap_or(&std::borrow::Cow::Owned(default_error))
.to_string()
})
.collect()
}
Defining the response format
Let’s start by defining the format of the generic response for returning a sandwich: the SandwichResponse
:
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct SandwichResponse {
pub id: String,
pub name: String,
pub ingredients: Vec<String>,
pub sandwich_type: SandwichType,
}
impl From<Sandwich> for SandwichResponse {
fn from(s: Sandwich) -> Self {
SandwichResponse {
id: s.id().value().clone().unwrap_or(String::from("")).to_string(),
name: s.name().value().to_string(),
ingredients: s.ingredients().value().clone(),
sandwich_type: s.sandwich_type().clone(),
}
}
}
As you can see, this struct depends on the domain entity Sandwich
and this is legit. What isn’t legit would be the opposite: if anything inside of the hexagon depends on the outside, the main principles of the hexagonal architecture are compromised because we will not be able to switch easily between external components. Probably it will become more understandable when we will add more adapters in the future.
Go ahead and add a convenience function to our src/helpers.rs
file to facilitate the generation of a successful JSON response:
/// Helper function to reduce boilerplate of an OK/Json response
pub fn respond_json<T>(data: T) -> Result<Json<T>, ApiError>
where
T: Serialize,
{
Ok(Json(data))
}
Adding error interfaces
Now it’s time to implement some error interfaces toward the outside world. Here the approach is a bit different from the previous ones: since we need an interface common to every REST controller (currently we have only the sandwich one, but who knows what the future could reserve us?) we will not place our error interface inside the controller module. We create instead a submodule of the rest_handler
one that will serve as an interface for every current and future REST controller. Let’s create the src/driving/rest_handler/errors.rs
file where defining ApiError
as follows:
#[derive(Debug, PartialEq)]
pub enum ApiError {
BadRequest(String),
InternalServerError(String),
NotFound(String),
InvalidData(String),
Unknown(String),
Conflict(String),
ValidationError(Vec<String>),
}
impl Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ApiError::BadRequest(err)
| ApiError::InternalServerError(err)
| ApiError::NotFound(err)
| ApiError::InvalidData(err)
| ApiError::Conflict(err)
| ApiError::Unknown(err) => writeln!(f, "{},", err),
ApiError::ValidationError(mex_vec) => {
mex_vec.iter().fold(Ok(()), |result, err| {
result.and_then(|_| writeln!(f, "{}, ", err))
})
},
}
}
}
Then fill the errors.rs
file with the code required by Actix to manage an error response:
/// Automatically convert ApiErrors to ResponseError
impl ResponseError for ApiError {
fn error_response(&self) -> HttpResponse {
match self {
ApiError::BadRequest(error) => {
HttpResponse::BadRequest().json(error.to_string())
}
ApiError::NotFound(message) => {
HttpResponse::NotFound().json(message.to_string())
}
ApiError::ValidationError(errors) => {
HttpResponse::UnprocessableEntity().json(&errors.to_vec())
}
ApiError::InternalServerError(error) => {
HttpResponse::Unauthorized().json(error.to_string())
}
ApiError::Conflict(error) => {
HttpResponse::Conflict().json(error.to_string())
}
ApiError::InvalidData(error) => {
HttpResponse::BadRequest().json(error.to_string())
}
ApiError::Unknown(_) => HttpResponse::new(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
Integration tests
Cool, we are ready to successfully run our integration test. I add it here because on this one we have something to say:
#[cfg(test)]
mod tests {
use actix_web::{App, FromRequest, Handler, Responder, Route, test};
use actix_web::test::TestRequest;
use crate::tests::test_utils::shared::{assert_on_ingredients, SANDWICH_NAME, SANDWICH_TYPE, stub_ingredients, stub_sandwich};
use super::*;
#[actix_web::test]
async fn should_create_a_sandwich() {
let create_req = CreateSandwichRequest {
name: SANDWICH_NAME.to_string(),
ingredients: stub_ingredients(),
sandwich_type: SANDWICH_TYPE,
};
// init service
let app = test::init_service(
App::new()
.route("/", web::post().to(create_sandwich))).await;
// create request
let req = TestRequest::post().set_json(create_req);
// execute request
let resp = test::call_and_read_body_json(&app, req.to_request()).await;
// validate response
assert_on_sandwich_response(&resp, &stub_sandwich(false));
}
fn assert_on_sandwich_response(actual: &SandwichResponse, expected: &Sandwich) {
assert_eq!(&actual.name, expected.name().value());
assert_on_ingredients(&actual.ingredients, expected.ingredients().value());
}
}
First of all please note that we used #[actix_web::test]
instead of the simple #[test]
. This way we are saying that our test should be executed within the scope of Actix-web and it also provides support for testing asynchronous functions.
The rest of the code is fairly straightforward, but as far as we know we will write more of these integration tests and, as explained in the previous article, it’s useful to spend some time organizing our tests to reduce code duplication and ease maintenance costs. We will then start by creating a function to wrap and simplify execution logic for REST requests. Let’s refactor our code as follows:
#[cfg(test)]
mod tests {
use actix_web::{App, FromRequest, Handler, Responder, Route, test};
use actix_web::test::TestRequest;
use crate::tests::test_utils::shared::{assert_on_ingredients, SANDWICH_NAME, SANDWICH_TYPE, stub_ingredients, stub_sandwich};
use super::*;
#[actix_web::test]
async fn should_create_a_sandwich() {
let create_req = CreateSandwichRequest {
name: SANDWICH_NAME.to_string(),
ingredients: stub_ingredients(),
sandwich_type: SANDWICH_TYPE,
};
let resp = execute::<>("/",
None,
web::post(),
TestRequest::post(),
create_sandwich,
Some(create_req))
.await;
assert_on_sandwich_response(&resp, &stub_sandwich(false));
}
/// execute a test request
async fn execute<F, Args, Ret>(path: &str, uri_to_call: Option<&str>, http_method: Route, test_req: TestRequest, handler: F, recipe_req: Option<impl Serialize>) -> Ret
where
F: Handler<Args>,
Args: FromRequest + 'static,
F::Output: Responder,
Ret: for<'de> Deserialize<'de> {
// init service
let app = test::init_service(
App::new()
.route(path, http_method.to(handler))).await;
// set uri
let req = match uri_to_call {
Some(uri) => test_req.uri(uri),
None => test_req
};
// Set json body
let req = match recipe_req {
Some(ref r) => req.set_json(recipe_req.unwrap()),
None => req
};
test::call_and_read_body_json(&app, req.to_request()).await
}
}
Here we did heavy use of generics (and it will increase when we will add the output adapter 😉). Anyway let’s focus on the third row of the where
clause: F::Output: Responder
.
With this instruction we are saying to the compiler that the Output
type of F
(that is a Handler<Args>
and if you look at the Handler
code you will find the Output
type) implements the Responder
trait. Thanks to this bound, the compiler can satisfy the required trait bound and the compilation succeeds.
Cool, now that we have all in place, let’s quickly proceed with the other handlers.
Get all sandwiches
This second handler is a bit different, so requires some attention:
/// find all sandwich recipes
pub async fn find_sandwiches(
find_req: QsQuery<FindSandwichRequest>,
) -> Result<Json<SandwichListResponse>, ApiError> {
let name = match &find_req.name {
Some(n) => n.as_str(),
None => ""
};
let ingredients = match &find_req.ingredients {
Some(i) => string_vec_to_vec_str(&i),
None => vec![]
};
let result = domain::find_all_sandwiches::find_all_sandwiches(name, &ingredients);
result
.map(|v| respond_json(SandwichListResponse::from(v)))
.map_err(|e| match e {
FindAllError::Unknown(m) => ApiError::Unknown(m),
})?
}
First of all it accepts optional parameters, so this is the struct intended to parse the request:
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct FindSandwichRequest {
pub name: Option<String>,
pub ingredients: Option<Vec<String>>,
pub sandwich_type: Option<SandwichType>,
}
To be able to correctly deserialize the relative JSON we add the serde_qs
crate to the Cargo.toml
:
[dependencies]
...
serde_qs = { version = "0.11", features = ["actix4"]}
This will make available the QsQuery
struct that will take care of the deserialization process.
Then, this handler does return a list of sandwiches, so we need to define a new response:
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct SandwichListResponse {
sandwiches: Vec<SandwichResponse>,
}
impl From<Vec<Sandwich>> for SandwichListResponse {
fn from(v: Vec<Sandwich>) -> Self {
let sandwiches = v.into_iter()
.map(|s| SandwichResponse::from(s)
).collect();
SandwichListResponse {
sandwiches
}
}
}
And finally we are ready to execute our test:
#[cfg(test)]
mod tests {
use actix_web::{App, FromRequest, Handler, Responder, Route, test};
use actix_web::test::TestRequest;
use crate::tests::test_utils::shared::{assert_on_ingredients, SANDWICH_NAME, SANDWICH_TYPE, stub_ingredients, stub_sandwich};
use super::*;
...
#[actix_web::test]
async fn should_find_all_sandwiches() {
let resp: SandwichListResponse = execute::<>("/",
None,
web::get(),
TestRequest::get(),
find_sandwiches,
None::<FindSandwichRequest>)
.await;
assert_eq!(resp.sandwiches.len(), 0);
}
}
Get a sandwich by id
As defined in the REST specification, to access a single entity by its identifier we should use a path param, so let’s implement our handler in this way.
We don’t need any additional request or response structs, so we can jump directly into the handler code:
/// get by id
pub async fn get_by_id(
path: web::Path<String>,
) -> Result<Json<SandwichResponse>, ApiError> {
let sandwich_id = path.into_inner();
let result = domain::find_one_sandwich::find_one_sandwich(
sandwich_id.as_str(),
"",
vec![].as_ref());
result
.map(|v| respond_json(SandwichResponse::from(v)))
.map_err(|e| match e {
FindOneError::Unknown(m) => ApiError::Unknown(m),
FindOneError::NotFound => ApiError::NotFound(String::from("No sandwich found with the specified criteria")),
})?
}
#[cfg(test)]
mod tests {
use actix_web::{App, FromRequest, Handler, Responder, Route, test};
use actix_web::test::TestRequest;
use crate::tests::test_utils::shared::{assert_on_ingredients, SANDWICH_NAME, SANDWICH_TYPE, stub_ingredients, stub_sandwich};
use super::*;
...
#[actix_web::test]
async fn should_find_a_sandwich_by_id() {
let sandwich= stub_sandwich(true);
let uri_to_call = format!("/{}", sandwich.id().value().as_ref().unwrap());
let resp = execute("/{id}",
Some(&uri_to_call),
web::get(),
TestRequest::get(),
get_by_id,
None::<String>)
.await;
assert_on_sandwich_response(&resp, &sandwich);
}
}
As can be seen, we are limiting the general functionality provided by the queried use case: even if this handler does not take advantage of it, the use case allows us to use other search keys besides the id.
But this causes a problem: if we execute the test, it will fail because it can’t build a sandwich without a name and ingredients. This is caused by our fake implementation of the use case, so let’s keep it as is, for now, we will be back later.
Update a sandwich
It’s time to add a way to handle a request to update an existing sandwich. Using REST we should accomplish this task using a Put, so again we need a request to wrap the deserialized request body:
#[derive(Clone, Debug, Deserialize, Serialize, Validate)]
pub struct UpdateSandwichRequest {
#[validate(length(
min = 5,
message = "id is required and must be at least 5 characters"
))]
pub id: String,
#[validate(length(
min = 3,
message = "name is required and must be at least 3 characters"
))]
pub name: String,
#[validate(length(
min = 1,
message = "ingredients is required and must be at least 1 item"
))]
pub ingredients: Vec<String>,
pub sandwich_type: SandwichType,
}
Nothing new here, just some validation stuff, therefore we go directly to the handler implementation:
/// update sandwich recipes
pub async fn update_sandwich(
request: Json<UpdateSandwichRequest>,
) -> Result<Json<SandwichResponse>, ApiError> {
validate(&request)?;
let result = domain::update_sandwich::update_sandwich(
request.id.as_str(),
request.name.as_str(),
string_vec_to_vec_str(&request.ingredients).as_ref(),
&request.sandwich_type);
result
.map(|v| respond_json(SandwichResponse::from(v)))
.map_err(|e| match e {
UpdateError::Unknown(m) => ApiError::Unknown(m),
UpdateError::InvalidData(m) => ApiError::InvalidData(m),
UpdateError::NotFound => ApiError::NotFound(String::from("No sandwich to update corresponding to the specified criteria")),
UpdateError::Conflict(m) => ApiError::Conflict(m)
})?
}
Now let’s skip the refactor phases and add a new convenience function to the src/tests/test_utils.rs
file:
pub fn stub_cheeseburger_ingredients() -> Vec<String> {
vec!(String::from("ground meat"),
String::from("cheese"),
String::from("ketchup"),
String::from("mayo"))
}
Look at the test function:
#[cfg(test)]
mod tests {
use actix_web::{App, FromRequest, Handler, Responder, Route, test};
use actix_web::test::TestRequest;
use crate::tests::test_utils::shared::{assert_on_ingredients, SANDWICH_NAME, SANDWICH_TYPE, stub_ingredients, stub_sandwich};
use super::*;
...
#[actix_web::test]
async fn should_update_a_sandwich() {
let sandwich = stub_sandwich(true);
let updt_req = UpdateSandwichRequest {
id: sandwich.id().value().as_ref().unwrap().to_string(),
name: CHEESEBURGER_NAME.to_string(),
ingredients: stub_cheeseburger_ingredients(),
sandwich_type: SandwichType::Veggie,
};
let expected = Sandwich::new(updt_req.id.clone(), updt_req.name.clone(), updt_req.ingredients.clone(), updt_req.sandwich_type.clone()).unwrap();
let resp = execute::<>("/",
None,
web::put(),
TestRequest::put(),
update_sandwich,
Some(updt_req))
.await;
assert_on_sandwich_response(&resp, &expected);
}
}
Delete a sandwich
As for the get-by id, we will use a path param to identify the resource to delete too, so no custom request is required.
/// delete one sandwich recipes
pub async fn delete_one_sandwich(
path: web::Path<String>,
) -> Result<HttpResponse, ApiError> {
let sandwich_id = path.into_inner();
let result = domain::delete_one_sandwich::delete_one_sandwich(sandwich_id.as_str());
result
.map(|_| Ok(HttpResponse::Ok().finish()))
.map_err(|e| match e {
DeleteOneError::Unknown(m) => ApiError::Unknown(m),
DeleteOneError::InvalidData(m) => ApiError::BadRequest(m),
DeleteOneError::NotFound => ApiError::NotFound(String::from("No sandwich to delete corresponding with the received id"))
})?
}
#[cfg(test)]
mod tests {
use actix_web::{App, FromRequest, Handler, Responder, Route, test};
use actix_web::test::TestRequest;
use crate::tests::test_utils::shared::{assert_on_ingredients, SANDWICH_NAME, SANDWICH_TYPE, stub_ingredients, stub_sandwich};
use super::*;
...
#[actix_web::test]
async fn should_delete_a_sandwich() {
let uri_to_call = format!("/{}", SANDWICH_ID);
let app = test::init_service(
App::new()
.route("/{id}", web::delete().to(delete_one_sandwich))).await;
let req = TestRequest::delete()
.uri(&uri_to_call)
.to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
}
}
In this final test we cannot use our execute
method to simplify the execution of the REST call, because the delete_one_sandwich
handler does not return a Result<Json<SandwichResponse>, ApiError>
like the others, but a Result<HttpResponse, ApiError>
. Here we call the service using the function test::call_service
and we only check (for now) that the response status code is a success.
Configuring the web app
Now that all the handlers are in place we can focus on the web app initialization and configuration. Put this code inside the main.rs
:
use actix_web::dev::Server;
use actix_web::{App, HttpServer, web};
use actix_web::middleware::Logger;
use crate::driving::rest_handler;
mod domain;
mod helpers;
mod tests;
mod driving;
#[actix_web::main]
async fn main() {
std::env::set_var("RUST_LOG", "debug");
env_logger::init();
create_server().await.unwrap().await.expect("An error occurred while starting the web application");
}
async fn create_server() -> Result<Server, std::io::Error> {
let server = HttpServer::new(|| {
App::new()
.wrap(Logger::default())
.configure(routes)
}).bind(("127.0.0.1", 8080))?
.run();
Ok(server)
}
fn routes(cfg: &mut web::ServiceConfig) {
cfg
.service(
web::scope("/recipes")
.service(
web::scope("/api/v1")
.service(
web::resource("sandwiches")
.route(web::get().to(rest_handler::sandwiches::find_sandwiches))
.route(web::post().to(rest_handler::sandwiches::create_sandwich))
.route(web::put().to(rest_handler::sandwiches::update_sandwich))
).service(
web::resource("sandwiches/{id}")
.route(web::get().to(rest_handler::sandwiches::get_by_id))
.route(web::delete().to(rest_handler::sandwiches::delete_one_sandwich))
)
)
);
}
Apart from the first 2 lines, which enable logging, let’s jump directly into the create_server
function.
We create an HttpServer
by passing it an instance of App
wrapped in a logger and configured with some routes returned by the execution of the namesake function.
We use the mutable instance of the ServiceConfig
to configure our routes.
After having scoped our entire application under the /recipes
path, we add a service specifying the API version. By using this approach from the beginning, if we need to change something in the API contract in the future, we will be able to do so without breaking the contracts used by old clients that still communicate using the old format. There are several ways to add API versioning, here we use this one because it is the simplest to explain but I encourage you to dig a bit to discover the other possibilities.
Then we bind our endpoints to the desired handlers and we are ready to test it!
Full code
If you want to see the full code sample you can find it on GitHub at this repository.
From now on, in the repository, you will also find the postman collection to start querying our application.
Conclusions
In this chapter, we have created our first adapter: a REST interface to interact with our application and we have connected it to the hexagon interior via our ports. Furthermore, to have introduced the Actix-web framework that enhances our app with a lot of useful functionalities while providing a standard approach to solving well-known problems.
In the next chapter, we will focus on driven adapters, adding a persistence interface. Since we will be leveraging MongoDB, firstly we will go through the configuration of a simple docker container, which will also be useful for running our integration tests.
Finally, at the end of the next chapter, although our tutorial will not end, our application will start to look quite complete.