An opinionated helper for the hexagonal design pattern.
The hexagonal architecture, or ports and adapters architecture, is an architectural style used in software design. It aims at creating loosely coupled application components that can be easily connected to their software environment by means of ports and adapters. This makes components exchangeable at any level and facilitates test automation.
sixer generates service boilerplate traits and structures inside of your
crate that allows you to focus on the core of your domain logic. Let's
follow the working example to see how it works.
#[derive(Debug, ::thiserror::Error)]
pub enum Error {
#[error("general error: {0}")]
General(String),
#[error("user is required")]
UserRequired,
}
#[derive(Debug, PartialEq, Eq, Default)]
pub enum Context {
#[default]
Nobody,
User(u32),
Root,
}
impl Context {
pub fn user_id(&self) -> Option<u32> {
match self {
Self::User(id) => Some(*id),
_ => None,
}
}
}-
Here we have a crate level
Error. A query, a command, and a workflow each return their own error, and that error converts into this one.launchuses the conversion when a workflow run fails. Callers of a query or command still see that use case's own error. -
A context is not required for
sixer; however, in this tutorial we're going to be using one. Your context MUST implement default. The default is provided when a context is not provided for queries and commands.
Without ports your hexagonal design won't get far; this is how to define them:
pub mod port {
#[::sixer::port(async_send)]
#[cfg_attr(test, ::mockall::automock)]
pub trait Messages: Send + Sync {
#[query]
async fn get(&self, id: u32) -> Result<Option<String>, crate::Error>;
#[command]
async fn put(
&self,
id: u32,
message: String,
) -> Result<(), crate::Error>;
}
}sixer::port will require each method be tagged with either #[query]
or [#command]. Methods tagged with query can only be used from
queries while commands can call methods tagged with either. We'll
demonstrate that more in the [cqrs] section.
Some things to note:
- ports require
Send+Syncso they are async compatible #[sixer::port(async_send)]rewrites eachasync fninto-> impl Future<Output = ...> + Send.#[sixer::port]leaves the signature alone, for a handwrittenimpl Future. Sync methods stay as written either way, and adapters still implement the async methods withasync fn.#[cfg_attr(test, ::mockall::automock)]is required, testing is a first class citizen and the test setup is expecting aMockMessages
We have everything we need to create a service runtime:
::sixer::runtime! {
error = crate::Error,
context = crate::Context,
ports {
messages: MSG: crate::port::Messages,
}
}This macro writes the bulk of the code that makes everything work. It does
this by creating a service module; and is required for this to be at the
root of your crate. It also exports a Service struct and a Ports trait.
Ports is a trait bag which allows us to define all of the concrete types
used at runtime for Service. The context = crate::StructName is optional
and if left out will not create any of the context plumbing for a service.
Each port line is can be read as the following:
<port_identifier>: <port_type_alias>: <full_path_to_port_trait>
It is important for each port's path to be a full path so that the macro
system can correctly find the helper types created by sixer::port.
Now that we have the service runtime created we're ready to start building command and queries for our domain.
// in mod cqrs
use crate::Error;
use crate::service::{CommandEnv, QueryEnv};
#[derive(Debug)]
pub struct FetchMyMessage;
#[::sixer::query]
impl FetchMyMessage {
async fn run(self, env: &impl QueryEnv) -> Result<Option<String>, Error> {
let Some(user_id) = env.ctx().user_id() else {
return Err(crate::Error::UserRequired);
};
env.messages().get(user_id).await
}
}
#[derive(Debug)]
pub struct StoreMyMessage(pub String);
#[::sixer::command]
impl StoreMyMessage {
async fn run(self, env: &impl CommandEnv) -> Result<(), Error> {
let Some(user_id) = env.ctx().user_id() else {
return Err(crate::Error::UserRequired);
};
env.messages().put(user_id, self.0).await
}
}workflow on runtime! opts the service into durable runs. The
application crate depends on durare 0.4 and on serde with derive.
Durare 0.4 needs a postgres or sqlite feature. postgres skips the
SQLite C build, and InMemoryProvider is still a valid state backend.
durare = { version = "0.5", default-features = false, features = ["postgres"] }
serde = { version = "1", features = ["derive"] }Error implements From<durare::Error> and Display. The context
implements Clone, Serialize, and Deserialize.
workflows lists the types launch registers. Leave the list empty
until a workflow exists, then add its path. Paths are full paths, the
same way port paths are. launch takes no type arguments.
::sixer::runtime! {
error = crate::Error,
context = crate::Context,
workflow,
ports {
database: DB: crate::port::Database,
},
workflows {
crate::cqrs::Copy,
},
}A command starts a run and returns a WorkflowRun. It does not wait.
Pass the idempotency id to start_workflow. The workflow value is the
input. A query env has no start_workflow. A running workflow cannot
start another one.
#[::sixer::command]
impl Copy {
async fn run(
self,
env: &impl CommandEnv,
) -> Result<WorkflowRun<()>, Error> {
let id = format!(
"{}:{}:{}",
Self::name(),
self.key,
self.dest,
);
env.start_workflow(&id, self).await
}
}
#[::sixer::workflow("copy")]
impl Copy {
async fn run(
self,
ctx: &WorkflowContext,
env: &impl CommandEnv,
) -> Result<(), Error> {
let key = self.key;
let value = match ctx
.step("read", env.database().get(key))
.await
{
Ok(value) => value,
Err(CallError::Failed(err)) => return Err(err),
Err(CallError::Recorded(message)) => {
return Err(Error::General(message));
}
};
Put { key: self.dest, val: value }.run(env).await
}
}#[::sixer::workflow("copy")] sets the registration name. A handwritten
fn name() -> &'static str does the same job. Without either, the
default calls std::any::type_name, and that string can change
between compilers, so a persisted run sets a fixed name. ctx.step
journals the first outcome of a port call. A call left outside
step runs live, so a privilege check still applies.
The workflow Result error is that workflow's own type, the same
rule as a command or a query. It converts into the service Error.
ctx.step and Attempt::run return a CallError. Failed holds
the port error when the call ran, so the workflow error can hold
it. Recorded is the journal message when the call does not run.
The workflow maps that message into its own error. The journal
keeps the call error's message. WorkflowRun::result returns the
service Error.
ctx.attempt retries before that write. It takes the step name and
returns a builder. max_retries counts attempts after the first
failure. run takes a closure the builder can call again. Only the
final success or the final error is saved. With no retry_if
predicate, every error is retried. The call's error converts into the
service error before the predicate runs. The predicate sees that
service error built from the failure's message. The builder also
takes backoff_factor, base_interval, and max_interval.
let value = ctx
.attempt("read")
.max_retries(3)
.base_interval(std::time::Duration::from_millis(50))
.run(|| env.database().get(key))
.await?;#[::sixer::attempt_defaults] sets that policy for every
ctx.attempt in the workflow. A duration is a whole number of
ns, us, ms, or s, written 50ms or "50ms", or any
Duration expression. The trait default is one try, a backoff
factor of 2.0, a 100ms base delay, and a 5s cap. One call can
still change its own attempt. A handwritten fn attempt_defaults
does the same job.
#[::sixer::workflow("copy")]
#[::sixer::attempt_defaults(
max_retries = 3,
backoff_factor = 2.0,
base_interval = 50ms,
max_interval = 5s,
)]
impl Copy {
async fn run(
self,
ctx: &WorkflowContext,
env: &impl CommandEnv,
) -> Result<u32, Error> {
let key = self.key;
match ctx
.attempt("read")
.run(|| env.database().get(key))
.await
{
Ok(value) => Ok(value),
Err(CallError::Failed(err)) => Err(err),
Err(CallError::Recorded(message)) => {
Err(Error::General(message))
}
}
}
}let service = Service::builder()
.database(db)
.workflow_provider(std::sync::Arc::new(
durare::InMemoryProvider::new(),
))
.build();
service.launch().await?;Service::new takes that provider after the ports. A test sets
MockEnv::expect_start_workflow and returns WorkflowRun::ready.
run_workflow runs one workflow against a mock env and returns the
outcome. step and attempt call those ports. Retries follow the
workflow's attempt policy and happen immediately. Nothing is
journaled.
let mut env = MockEnv::default();
let value = run_workflow(&env, FlakyRead { key: 7 }).await?;shutdown waits up to the timeout for background tasks. The workflow
body does not watch that deadline. A run still awaiting when the wait
ends keeps going. The engine and the ports stay until the last
Service clone drops. Shutdown before launch returns Ok.
Here is some examples of tests for our new query and command:
#[cfg(test)]
mod tests {
use crate::cqrs::{FetchMyMessage, StoreMyMessage};
use crate::service::{Command, MockEnv, Query};
use crate::{Context, Error};
#[::tokio::test]
async fn fetch_requires_user_in_context() {
let env = MockEnv::default();
let err = FetchMyMessage.run(&env).await;
assert!(matches!(err, Err(Error::UserRequired)));
}
#[::tokio::test]
async fn store_requires_user_in_context() {
let env = MockEnv::default();
let err = StoreMyMessage("test".into()).run(&env).await;
assert!(matches!(err, Err(Error::UserRequired)));
}
#[::tokio::test]
async fn fetch_with_user_id_in_context() {
let mut env = MockEnv::default();
env.ctx = Context::User(42);
env.messages
.expect_get()
.once()
.withf(|id| *id == 42)
.returning(|_| {
// I recommend making a helper to cut out this boilerplate
Box::pin(::std::future::ready(Ok(Some("hello test".into()))))
});
let msg = FetchMyMessage.run(&env).await.expect("fetch message");
assert_eq!(msg.as_deref(), Some("hello test"));
}
#[::tokio::test]
async fn store_with_user_id_in_context() {
let mut env = MockEnv::default();
env.ctx = Context::User(42);
env.messages
.expect_put()
.once()
.withf(|id, msg| *id == 42 && msg == "test")
.returning(|_, _| Box::pin(::std::future::ready(Ok(()))));
StoreMyMessage("test".into())
.run(&env)
.await
.expect("store to work");
}
}In a test environment you have access to MockEnv; which allows loading the
context and setting up any expectation of ports before running any of the
tests.
The following is an integration test; however, it highlights exactly the same process you would use from the binary crate that wires up your service.
use ::context_demo::{Context, Error, Ports, Service, cqrs};
use ::std::collections::HashMap;
use ::std::sync::Arc;
use ::tokio::sync::RwLock;
/// A simple in-memory store adapter of our messages
#[derive(Clone, Default)]
pub struct MemoryMessageStore(Arc<RwLock<HashMap<u32, String>>>);
// implementing our adapter to the Messages port
impl ::context_demo::port::Messages for MemoryMessageStore {
async fn get(&self, id: u32) -> Result<Option<String>, Error> {
let read = self.0.read().await;
Ok(read.get(&id).cloned())
}
async fn put(&self, id: u32, message: String) -> Result<(), Error> {
let mut write = self.0.write().await;
write.insert(id, message);
Ok(())
}
}
// DemoPorts is the port bag we use to implement our Ports
// bag with all of the types used at runtime.
struct DemoPorts;
// defining concrete types for a port bag
impl Ports for DemoPorts {
type MSG = MemoryMessageStore;
}
// attaching the port bag to the service via type alias
type Demo = Service<DemoPorts>;
#[::tokio::test]
async fn demo_works() -> Result<(), Error> {
// service comes with a builder that requires each adapter for
// our service's ports before it can be built.
let demo = Demo::builder()
.messages(MemoryMessageStore::default())
.build();
// the service creates run-times with a context that can run
// both queries and commands we have defined
let user1 = demo.with_context(&Context::User(1));
let user2 = demo.with_context(&Context::User(2));
user1.command(cqrs::StoreMyMessage("test".into())).await?;
let msg1 = user1.query(cqrs::FetchMyMessage).await?;
let msg2 = user2.query(cqrs::FetchMyMessage).await?;
assert_eq!(msg1.as_deref(), Some("test"));
assert!(msg2.is_none());
// the service itself can also call queries and commands and
// are provided a default context for the request
let err = demo.command(cqrs::StoreMyMessage("!".into())).await;
assert!(matches!(err, Err(Error::UserRequired)));
Ok(())
}