Hexagonal architecture, test doubles, and traits

Table of contents

  1. Hexagonal architecture and test doubles
    1. Refresher on hexagonal architecture
    2. Sample use-case: loyalty system
  2. Traits for test doubles
    1. Using traits for driven ports
    2. Traits and the compiler
    3. Creating fakes for driven ports
  3. Domain logic
    1. Creating domain logic services with tower
    2. Domain logic architecture and the need for test doubles
    3. Testing functions with no side effects
    4. Sociable vs solitary tests
  4. Next article

In the previous article, we explored the fundamentals of automated testing: why we need automated tests, what benefits they bring, and some of the challenges when writing those tests.

As a quick reminder, we discussed the idea of using tests as feedback loops to quickly know if there is an issue with our changes before it could impact users.

You Unit Tests Service Tests UI Tests Q&A Your users

A common approach to achieve this is through unit tests, which are fast, small, and self-contained tests that can be executed locally. However, these tests require either self-contained logic or rely on test doubles. Test doubles are used because these tests aim to run locally without any side effects, necessitating the creation of substitutes for real-world counterparts.

Hexagonal architecture and test doubles

Before going into the different types of test doubles and how to inject them into our code, we must first take a step back and talk a bit about how software architecture will impact test architecture.

One of the services that my team at Apollo GraphQL owns has to interact with multiple cloud providers and external APIs. It’s a core component of our control plane that’s responsible for provisioning and managing resources across cloud providers. We decided to opt for an hexagonal architecture to isolate our core domain logic from concrete implementations. That gave us three major benefits:

  1. We can switch or refactor our implementation more easily. Once we realised that our initial DNS provider would not meet one of our requirements at scale, we wrote a new adapter for a new provider without changing our domain logic. We also used a combinatorial adapter to keep DNS records across providers in sync during the migration process.
  2. We can isolate provider-specific logic away from business logic. As data integrity is paramount to our system, we wrote an abstract domain model that prioritizes write safety but doesn’t exactly map to its database representation. With this, we could isolate all the logic for parsing database values into this domain model and back into a single part of our codebase.
  3. Testing the business logic in isolation is significantly simpler. This is the part we’ll delve into for this article. In short, we were able to test calls to each of the ports without invoking real resources.

Now, no architecture can be considered a panacea and different use cases demand different solutions. The service in question encompasses a relatively intricate domain logic and interacts with multiple external services. Our team is responsible for various other components within the overall system, each designed with radically different software architectures tailored to meet specific requirements. Nonetheless, I will use this particular architecture as a base here, as it closely aligns with many of the web services I’ve observed in the wild.

Refresher on hexagonal architecture

Events Users Adapter Adapter Adapter Domain Logic Adapter Adapter Adapter Dependency Dependency Dependency

A hexagonal architecture consists of five main components:

  1. Driving Adapters: These are concrete implementations that serve as the entry point for external systems or interfaces. Their role is to receive specific requests from these external systems, transform them if necessary, and then pass them on to the driving ports. The driving adapters act as translators between the external systems and the internal architecture.
  2. Driving Ports: These are interfaces that define the contracts or protocols through which requests from the driving adapters are received. The driving ports provide a boundary for external communication. They accept incoming requests from the driving adapters and forward them to the domain logic for processing.
  3. Driven Ports: These interfaces define the contracts through which the domain logic communicates with external systems. The driven ports encapsulate the interactions with external resources such as databases, or external services. The domain logic communicates its orders or instructions to the driven ports, which then forwards them to the driven adapters.
  4. Driven Adapters: These are concrete implementations that bridge the gap between the domain logic and the external systems accessed through the driven ports. They receive the orders or instructions from the driven ports and translate them into specific calls or actions to external systems. The driven adapters handle the technical details of interacting with external resources, leaving the domain logic decoupled from these details.
  5. Domain Logic: This component represents the core of the application, containing abstract business rules, algorithms, and object models. The domain logic is independent of external systems and focuses solely on expressing the business requirements and behaviours. It interacts with the driving ports to receive requests and deliver responses, as well as with the driven ports to issue orders and receive feedback from the external systems.

In this architecture, the driving and driven adapters provide flexibility to integrate the application with different external systems or interfaces without needing to change the core domain logic. The driving and driven ports define the boundaries through which the communication occurs.

Regarding unit tests, ports act as a clean boundary for testing the business logic in isolation. We can write a test that’ll send an expected input to a driving port, and then validate that we are seeing the expected invocations of driven ports, and correct output through the driving port.

Driving port Domain logic Driven port Driven port

There are other components of our architecture that we’ll want to test, such as the driving and driven adapters, but I am deliberately leaving this for a future article.

Sample use-case: loyalty system

To provide a tangible example that demonstrates the application of these abstract concepts, let’s delve into a concrete use case: the implementation of a loyalty system for a gym chain.

Gym members will earn loyalty points for each month they remain a member and for every purchase they make, whether it’s in one of the physical gym shops or through the online store. If someone stays a member of the gym for a long enough period, they’ll move to the next tier where they can accrue points at an accelerated rate. If they stop being a member, they will lose their membership tier. They can then redeem those points for discounts on purchases made in stores.

We can write a struct that will represent a Member in our domain model:

use uuid::Uuid;

pub struct Member {
    /// Unique identifier for the `Member`
    ///
    /// This is also used by other services.
    member_id: Uuid,
    /// Number of continuous months of membership
    ///
    /// This is set to `None` if the person is not an active member anymore.
    membership_months: Option<u32>,
    /// Number of accrued loyalty points
    loyalty_points: u32,
}

We can also calculate their loyalty program tier level based on the number of months they’ve been a member:

pub enum Tier {
    None,
    Basic,
    Silver,
    Gold,
    Platinum,
}

impl Member {
    pub fn tier(&self) -> Tier {
        match self.membership_months {
            // Non-members
            None => Tier::None,
            // First year of continuous membership
            Some(0..=11) => Tier::Basic,
            // Second year of continuous membership
            Some(12..=23) => Tier::Silver,
            // Third year of continuous membership
            Some(24..=35) => Tier::Gold,
            // Fourth year and more
            Some(_) => Tier::Platinum,
        }
    }
}

Adding points

For this article, we’ll delve into one specific use case of our loyalty service: adding loyalty points whenever the member makes a purchase or for each month they remain a member. Since the number of points depends on the current membership tier, we cannot have a function that will just take a member ID and a number of points.

Instead, we can create an input type AddPointsRequest that will take into consideration the two possible use cases, while leaving the door open for future ones. This will be part of the driving port for this command.

use uuid::Uuid;

pub struct AddPointsRequest {
    member_id: Uuid,
    event: AddPointsEvent,
}

pub enum AddPointsEvent {
    /// The member continues their membership for another month
    MembershipRenewed,
    /// The member purchases in a physical store
    InStorePurchase {
        purchase_amount: f64,
    },
    /// The member makes a purchase online
    OnlinePurchase {
        purchase_amount: f64,
    },
    /// Manually adding points, e.g. for support
    Manual {
        loyalty_points: u32,
        reason: Option<String>,
    }
}

Speaking of future-proofing, we might not need to differentiate in-store and online purchases, but business requirements might change in the future – we might decide to award more points for in-store purchases over online ones for example.

We’ll return information about the member, their membership tier, how many points they had, and their new total in an AddPointsResponse type. We’re not sure yet if the driving adapters will use this, but the point of this architecture is to decouple and give us more flexibility.

pub struct AddPointsResponse {
    pub member_id: Uuid,
    pub tier: Tier,
    /// Previous number of loyalty points
    pub old_loyalty_points: u32,
    /// New number of loyalty points
    pub new_loyalty_points: u32,
}

For this command, we need to communicate with two external systems: a Database containing points and loyalty events; and the Member service that contains the source of truth regarding member data. For the database, we need to do the following operations: fetch the current number of loyalty points, save the new number, and save a new loyalty point event. For the member service, we will contact it to retrieve the current membership status and check if the member exists or not. I’ll leave the implementation for later once we explore traits for test doubles.

As a result of our current requirements, the architecture for our loyalty service looks like this at a high level. We either receive events whenever there is a new purchase or a membership is renewed, or the support staff could make an API call to manually add points. This will invoke the Add Points Port which will call our Add Points business logic. This, in turn, will use the Database and Member ports to retrieve and update information as needed.

Events Support API Add Points Port Add Points Database Port Member Port Database Member

Traits for test doubles

In the last article, I used a link to the test double page from Martin Fowler’s website without going into more details about it. That page gives a good cursory explanation of what those are, but to give my spin on it, a test double is something you can use as a substitute for the real thing when running tests.

For example, when deploying our loyalty service, we would need Database and Member adapters that would interact with a real database and member service. This would go against the principle of unit tests, where we run tests locally without side effects. This is where test doubles come into play, enabling us to create substitutes that simulate the behaviour of the actual components without the need to interact with the complete system during testing.

In the case of the database port, we could use a fake object that utilizes an in-memory database. Fakes typically provide a functional implementation but may take certain shortcuts compared to the real system. In this case, an in-memory database can be used to mimic the behaviour of a running database, but it may not possess the long-term data persistence capabilities we would expect normally. This allows us to create unit tests for the loyalty service without relying on external systems.

In many scenarios, the use of mocks is often preferable. While mocks may not offer a complete implementation of the port, they do provide valid responses for expected calls. Unlike fakes, mocks have the additional capability of throwing exceptions when an unexpected call is made and ensuring that all expected calls were made at the end of a test run.

Using traits for driven ports

Traits are typically used in Rust to define generic behaviour that can be applied across a wide range of use cases. For example, if you want to write a function that returns the uppercase version of an input, you might want to accept any object that implements a certain trait.

use std::net::Ipv6Addr;

pub fn to_uppercase(s: impl ToString) -> String {
    s
        // Transforms `s` into a `String`
        .to_string()
        // Create an uppercase `String`
        .to_uppercase()
}

fn main() {
    // HELLO
    println!("{}", to_uppercase("hello"));
    // WORLD
    println!("{}", to_uppercase(String::from("world")));
    // FALSE
    println!("{}", to_uppercase(false));
    // FFFF::FFFF
    println!("{}", to_uppercase(Ipv6Addr::new(0xffff, 0, 0, 0, 0, 0, 0, 0xffff)));
}

However, nothing prevents us from creating a custom trait that has a single implementation. Since we only need to make one type of call to the Member adapter, we could write a trait with a single method called get_member.

use chrono::{DateTime, Utc};
use uuid::Uuid;

#[mockall::automock]
#[async_trait::async_trait]
pub trait MemberPort {
    async fn get_member(&self, member_id: Uuid) -> Result<Member, Error>;
}

pub struct Member {
    pub member_id: Uuid,
    pub active_member: bool,
    pub membership_since: DateTime<Utc>,
}

#[derive(Debug, thiserror::Error)]
pub enum Error {
    /// Domain-level error when a member does not exist
    #[error("member {0} does not exist")]
    MemberDoesNotExist(Uuid),

    /// Concrete adapter errors
    /// 
    /// This could represent any errors from a concrete adapter that is not part
    /// of the domain model, such as connectivity, configuration, or permission errors.
    #[error("adapter error: {0:?}")]
    Adapter(Box<dyn std::error::Error + Send + Sync>),
}

We’re introducing a lot of new concepts in just a few lines of code here. Let’s break it down section by section. First, when we are defining the new MemberPort trait, we are using two attributes that will generate and modify code on our behalf: async_trait::async_trait and mockall::automock.

async_trait::async_trait

As of the time of writing (June 2023), Rust does not yet support asynchronous methods in trait, but this is slated for stabilization this year. As a result, we have to use a crate like async_trait that will perform some magic under the hood to transform async functions into sync functions that return futures.

We’ll see once we go into the creating domain logic services part of this article how using this crate simplifies our lives significantly compared to just writing traits with methods that return Futures.

thiserror::Error

Another crate that I highly recommend is thiserror. It offers a convenient derive macro that generates many of the desired implementations of a good Error type, such as Display and From other error types.

Adapter error variant

The Error type includes an Adapter variant that accepts any boxed object implementing the std::error::Error trait. This variant arises from the design principles of our hexagonal architecture.

In our system, it is essential to handle both domain-level errors (e.g., a member not existing) and adapter-specific errors (e.g., networking issues) within a single enum type. To support concrete types in this Error enum, we would need to introduce separate variants for each possible error object originating from all supported adapters.

One of the key benefits of a hexagonal architecture is the decoupling of ports from concrete adapters. By avoiding the creation of variants for known concrete adapters, we preserve this decoupling and maintain a more flexible and adaptable codebase.

mockall::automock

The most important part of this code sample regarding testing is the usage of the mockall crate. By using the automock attribute macro, we can generate a new struct called MockMemberPort that implements the MemberPort trait.

Moreover, the automock attribute macro automatically generates methods for defining expectations on our mock. For instance, when used on the MemberPort trait, it creates an expect_get_member method that allows us to write expectations for our mock implementation:

use chrono::Utc;
use mockall::predicate::*;
use uuid::Uuid;

async fn call_mock_get_member() {
    // Create a new mock MemberPort object with the right expectations
    const EXPECTED_MEMBER_ID: Uuid = uuid::uuid!("50e620a3-0d6d-4bf0-8e0a-e82eb5885726");
    let mut mock_member_port = MockMemberPort::new();
    mock_member_port
        .expect_get_member()
        // How many times this method is called with the expected values
        .times(1)
        // Expected values
        .with(eq(EXPECTED_MEMBER_ID))
        // Return value
        .returning(|_| Ok(Member {
            member_id: EXPECTED_MEMBER_ID,
            active_member: true,
            membership_since: Utc::now(),
        }));

    // Call it with the expected payload
    let res = mock_member_port.get_member(EXPECTED_MEMBER_ID).await;
}

Traits and the compiler

At compile-time, Rust performs monomorphization to generate different functions and structs that match concrete types. Consequently, a generic function can result in multiple functions in the final binary.

For instance, if you pass the first example in the Using traits for driven ports section into Godbolt, you will obtain generated assembly code containing multiple implementations of the to_uppercase functions with minor differences.

There are four versions of the to_uppercase function, corresponding to the four calls with different input types: at lines 2447, 2503, 2563, and 2619.

If we look at the differences between the first (for bool types) and the second (for str types) implementations, we can observe some minor dissimilarities between them. Let’s examine the initial instructions up to the first jmp:

example::to_uppercase:
  sub rsp, 88
  mov qword ptr [rsp + 24], rdi
  mov al, sil
  mov qword ptr [rsp + 32], rdi
  and al, 1
  mov byte ptr [rsp + 47], al
  lea rdi, [rsp + 48]
  lea rsi, [rsp + 47]
  call <bool as alloc::string::ToString>::to_string
  jmp .LBB65_3
example::to_uppercase:
  sub rsp, 88
  mov qword ptr [rsp + 24], rsi
  mov qword ptr [rsp + 32], rdi
  mov qword ptr [rsp + 40], rdi
  lea rdi, [rsp + 48]
  call <alloc::string::String as alloc::string::ToString>::to_string
  jmp .LBB66_3

While monomorphization ensures there is no performance overhead when using generic functions since the Rust compiler avoids adding any layer of indirection, it also means that employing numerous generics could significantly increase the resulting binary size.

Fortunately, this is usually not a concern when using traits as ports for hexagonal architectures since the resulting binaries typically utilize only one concrete type for a given port trait.

Creating fakes for driven ports

The mockall crate greatly simplified the development of our testing harness by enabling us to create mocks for traits with just a single line of code. However, while this approach may address many of our requirements for test doubles, it might not cover all scenarios. As I mentioned earlier in the section on traits for test doubles, we could employ a fake instead of a mock. In the case of a database port, a fake can prove useful for comparing the initial and final states of the storage, instead of relying solely on validating the behaviour.

At the moment, we have two main operations against the database port: retrieving the current number of accrued loyalty points, and registering a new loyalty event. For audit reasons, we should use some form of loyalty event type that will contain both the changes and reasons. Whenever we register a new event, the adapter will also update the number of points.

#[mockall::automock]
#[async_trait::async_trait]
pub trait DatabasePort {
    async fn get_loyalty_points(&self, member_id: Uuid) -> Result<Loyalty, Error>;
    async fn register_loyalty_event(
        &self,
        member_id: Uuid,
        loyalty_event: LoyaltyEvent,
    ) -> Result<Loyalty, Error>;
}

/// Loyalty data about a member
#[derive(Clone, Debug)]
pub struct Loyalty {
    pub member_id: Uuid,

    /// Current amount of loyalty points
    pub points: u32,

    /// Loyalty events for the user
    pub events: Vec<LoyaltyEvent>,
}

/// Details for a loyalty event
#[derive(Clone, Debug)]
pub struct LoyaltyEvent {
    pub event_id: Uuid,
    /// Difference in points
    /// 
    /// A positive number adds points to the current total. A negative number removes from it.
    pub delta_points: i32,
    /// Message explaining the reason for this event.
    /// 
    /// Since the reasons could evolve over time, we log this as a string instead of an enum.
    pub reason: String,
}

Even though I don’t plan to use the mock implementation, I’ve kept the mockall::automock attribute macro just in case a specific type would benefit from this implementation.

Finally, members cannot have a negative number of loyalty points. Thus if an event would cause this number to become negative, it should return a domain-level error.

#[derive(Debug, thiserror::Error)]
pub enum Error {
    /// Trying to remove too many loyalty points
    /// 
    /// This would result in a negative number of loyalty points, which is not supported.
    #[error("trying to subtract too many points: {delta_points} from {current_points}")]
    NegativePointsTotal {
        current_points: u32,
        delta_points: i32,
    },

    /// Concrete adapter errors
    /// 
    /// This could represent any errors from a concrete adapter that is not part of the domain
    /// model, such as connectivity, configuration, or permission errors.
    #[error("adapter error: {0:?}")]
    Adapter(Box<dyn std::error::Error + Send + Sync>),
}

In-memory database adapter

We can now create an in-memory database that will implement the DatabasePort trait. Since we will only use this as a fake for unit testing purposes, we do not need data persistence between runs and we can simply use a HashMap<Uuid, Loyalty> to temporarily store data. To allow internal mutability while permitting to share the data structure between threads, we should also wrap it in an Arc<Mutex<>>.

struct MemoryDatabase {
    loyalties: Arc<Mutex<HashMap<Uuid, Loyalty>>>,
}

Next, we now need to write the DatabasePort implementation for our fake:

#[async_trait::async_trait]
impl DatabasePort for MemoryDatabase {
    async fn get_loyalty_points(&self, member_id: Uuid) -> Result<Loyalty, Error> {
        let loyalty = self
            .loyalties
            .lock()?
            .get(&member_id)
            .cloned()
            .unwrap_or_else(|| Loyalty::new(member_id));

        Ok(loyalty)
    }
    async fn register_loyalty_event(
        &self,
        member_id: Uuid,
        event: LoyaltyEvent,
    ) -> Result<Loyalty, Error> {
        let loyalty = match self.loyalties.lock()?.entry(member_id) {
            // Loyalty already exists
            Entry::Occupied(mut entry) => {
                let loyalty = entry.get_mut();
                let new_points = loyalty.points as i32 + event.delta_points;
                // Return an error if this would make the number of loyalty points negative
                if new_points < 0 {
                    return Err(Error::NegativePointsTotal {
                        current_points: loyalty.points,
                        delta_points: event.delta_points,
                    });
                }

                loyalty.points = new_points as u32;
                loyalty.events.push(event);
                loyalty.clone()
            }
            // Loyalty does not exist
            Entry::Vacant(entry) => {
                let mut loyalty = Loyalty::new(member_id);
                // Return an error if this would make the number of loyalty points negative
                if event.delta_points < 0 {
                    return Err(Error::NegativePointsTotal {
                        current_points: loyalty.points,
                        delta_points: event.delta_points,
                    });
                }
                loyalty.points = event.delta_points as u32;
                loyalty.events.push(event);
                entry.insert(loyalty.clone());
                loyalty
            }
        };

        Ok(loyalty)
    }
}

Test code is code

As emphasized in the previous article, test code is code in itself. We should therefore verifythat the test code functions as intended by incorporating its own set of unit tests. To streamline the process of writing unit tests, I am incorporating a new crate called speculoos that simplifies writing assertions.

While there is a multitude of scenarios that could be tested, I have chosen to focus on the three most crucial ones:

  • Verify that storing and retrieving loyalty data behaves as expected.
  • Verify that it is not possible to register an event with a negative number of points for new loyalty entries.
  • Ensure that the fake prevents the registration of events that would cause the number of loyalty points to drop below zero.
#[cfg(test)]
mod tests {
    use super::*;
    use speculoos::prelude::*;

    /// Test that we can register and retrieve the right number of
    /// loyalty points
    #[tokio::test]
    async fn test_register_retrieve() {
        let database = MemoryDatabase::default();
        let loyalty = Loyalty::new(Uuid::new_v4());
        // Create the loyalty in the database
        let res = database
            .register_loyalty_event(
                loyalty.member_id,
                LoyaltyEvent {
                    event_id: Uuid::new_v4(),
                    delta_points: 5,
                    reason: "".to_string(),
                },
            )
            .await;
        assert_that!(res).is_ok().matches(|stored_loyalty| {
            stored_loyalty.member_id == loyalty.member_id && stored_loyalty.points == 5
        });
        // Retrieving the loyalty should return the updated total
        let res = database.get_loyalty_points(loyalty.member_id).await;
        assert_that!(res).is_ok().matches(|stored_loyalty| {
            stored_loyalty.member_id == loyalty.member_id && stored_loyalty.points == 5
        });
    }

    /// Test that we handle negative points correctly for new entries
    #[tokio::test]
    async fn test_negative_points_empty() {
        let database = MemoryDatabase::default();
        let res = database
            .register_loyalty_event(
                Uuid::new_v4(),
                LoyaltyEvent {
                    event_id: Uuid::new_v4(),
                    delta_points: -5,
                    reason: "".to_string(),
                },
            )
            .await;
        assert_that!(res)
            .is_err()
            .matches(|err| matches!(err, Error::NegativePointsTotal { .. }));
    }

    /// Test that we handle negative points correctly for existing entries
    #[tokio::test]
    async fn test_negative_points_exists() {
        let database = MemoryDatabase::default();
        let loyalty = Loyalty::new(Uuid::new_v4());
        // Create the loyalty in the database
        let res = database
            .register_loyalty_event(
                loyalty.member_id,
                LoyaltyEvent {
                    event_id: Uuid::new_v4(),
                    delta_points: 5,
                    reason: "".to_string(),
                },
            )
            .await;
        assert_that!(res).is_ok();
        // Removing the current number of points is OK
        let res = database
            .register_loyalty_event(
                loyalty.member_id,
                LoyaltyEvent {
                    event_id: Uuid::new_v4(),
                    delta_points: -5,
                    reason: "".to_string(),
                },
            )
            .await;
        assert_that!(res).is_ok();
        // This would cause the number of points to go to -1
        let res = database
            .register_loyalty_event(
                loyalty.member_id,
                LoyaltyEvent {
                    event_id: Uuid::new_v4(),
                    delta_points: -1,
                    reason: "".to_string(),
                },
            )
            .await;
        assert_that!(res)
            .is_err()
            .matches(|err| matches!(err, Error::NegativePointsTotal { .. }));
    }
}

Domain logic

Driving port Domain logic Driven port Driven port

Now that we have established a comprehensive strategy for defining driving and driven ports, along with creating test doubles for the latter, we can shift our focus towards the essential core domain logic.

As a refresher, the add points operation begins by receiving an AddPointsRequest object that contains the event triggering this operation. Our next step involves retrieving the member’s status and membership duration from the member service. This information will allow us to determine their loyalty tier. Leveraging both the event and the tier, we proceed to compute the number of points they should acquire from this operation. Finally, we update the database with the new total number of loyalty points, and respond with an AddPointsResponse.

Request Get Membership Get Loyalty Compute Points Register Event Response

While we used async_trait earlier to define the driven ports, I will use a slightly different approach that still gives us the same level of flexibility through trait implementations. While using traits for driving ports and triggering the domain logic is not necessary in the context of this article, this will become useful in a latter one when we explore writing tests for adapters.

Creating domain logic services with tower

tower is a widely adopted library renowned for its ability to build modular services. It introduces a powerful abstraction that facilitates the creation and reusability of generic components, including functionalities like timeouts, rate limiting, authorization, and observability metadata injection.

At the core of tower lies the Service trait. This trait represents a versatile component capable of handling multiple asynchronous calls. It plays a fundamental role in defining clients and servers and serves as a foundational component for several prominent asynchronous Rust libraries, such as hyper, axum, or tonic.

However, it’s worth noting a couple of current limitations when using tower::Service:

  1. It does not rely on async_trait, which means that we need to write synchronous methods that return futures.
  2. The methods within the trait require a mutable reference to self (&mut self), potentially necessitating service cloning if we need to process requests concurrently.
pub trait Service<Request> {
    type Response;
    type Error;
    type Future: Future
    where
        <Self::Future as Future>::Output == Result<Self::Response, Self::Error>;

    fn poll_ready(
        &mut self, 
        cx: &mut Context<'_>
    ) -> Poll<Result<(), Self::Error>>;
    fn call(&mut self, req: Request) -> Self::Future;
}

Let’s take some time to dive into the tower::Service trait itself. It has one generic (Request) that defines the incoming request type. For a given Request type, we can define a Response and Error associated types for return values. Since we’re implementing a synchronous function that returns a future, we also need to specify a Future associated type, which can be either a concrete type implementing the Future trait or an async {} block wrapped in a Pin<Box<>>.

This trait mandates the implementation of two methods: poll_ready and call. The purpose of poll_ready is to check if a service is prepared to handle new incoming requests, while the call method is responsible for processing incoming requests.

If we were to write a driving adapter function that needs to call our domain logic to add points for a member, we could define the function with a generic type parameter that could look like this:

async fn my_adapter<D>(domain: &mut D)
where
    D: tower::Service<AddPointsRequest, Response = AddPointsResponse, Error = Error>,
{
    todo!()
}

Let’s create a domain object that encompasses implementations for both of our driven ports. To provide flexibility, we will utilize generic types, allowing us to accommodate any type of driven adapters that satisfy the required specifications. Additionally, we will introduce a new Error type that supports transformation from the specific error types of both of those ports.

For the sake of maintaining testability in our unit tests for DomainLogic, I avoid including trait bounds at this stage. Instead, each implementation for a given driven port will enforces which traits must be implemented.

pub struct DomainLogic<D, M> {
    database: Arc<D>,
    member: Arc<M>,
}

#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[error("database port error: {0:?}")]
    Database(#[from] crate::ports::database::Error),
    #[error("member port error: {0:?}")]
    Member(#[from] crate::ports::member::Error),
}

Finally, let’s create a base implementation that will meet the add points driven port specification:

impl<D, M> Service<AddPointsRequest> for DomainLogic<D, M>
where
    D: DatabasePort + 'static,
    M: MemberPort + 'static,
{
    type Response = AddPointsResponse;
    type Error = Error;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;

    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        Poll::Ready(Ok(()))
    }

    fn call(&mut self, _req: AddPointsRequest) -> Self::Future {
        todo!()
    }
}

Domain logic architecture and the need for test doubles

Before diving into the implementation details of our business logic, it’s important to consider that not all of the logic should be confined to a single function per driven port. The same software architectural principles still apply to business logic, such as the use of reusable functions to avoid redundancy.

When it comes to unit testing, we can further enhance our approach by isolating complex logic into smaller functions that have no side effects. By decomposing intricate business logic into smaller units, we can reduce the overall number of test cases by minimizing the variables under test.

Imagine a business logic with five factors, each having five different possible outcomes. In such a case, there would be a staggering total of 5 to the power of 5 (3125) possible outcomes that would need to be tested. However, by isolating each factor into separate and testable units, we can potentially limit the total number of test cases to just 5 multiplied by 5, resulting in only 25 test cases. While it may not always be feasible to completely isolate all factors from each other, any reduction in the number of test cases required to cover all possible branches is beneficial.

In our specific scenario, we have five main factors to consider:

  1. The type of event that triggers the addition of new points (four variants of the AddPointsEvent enum).
  2. The tier level of the member (five variants of the Tier enum).
  3. The success or failure of fetching membership information (two variants of Result from the MemberPort::get_member method).
  4. The success or failure of fetching loyalty information (two variants of Result from the DatabasePort::get_loyalty_points method).
  5. The success or failure of registering a loyalty event (two variants of Result from the DatabasePort::registry_loyalty_event method).

Taking all these factors into account, we would have a total of 4 * 5 * 2 * 2 * 2 = 160 distinct scenarios to consider.

Many combinations of these factors would result in the same outcome. For example, any issue encountered while fetching membership information should lead to an error in the domain logic. However, a monolithic business logic function might have interactions that go unnoticed without comprehensive test coverage.

By splitting our business logic into testable units—even if these functions are not intended for reuse elsewhere in our code—we can ensure more effective testing and detection of potential issues.

Add Points Logic Get Membership Get Loyalty Compute Points Register Event

Testing functions with no side effects

Let’s now put this into practice by writing and adding tests for the points computation logic. In this case, we need to create a function that takes the AddPointsEvent and the current Tier as inputs and returns a LoyaltyEvent.

To simplify our implementation, we’ll begin by adding a method to the Tier struct that computes the point ratio for purchases, and a method to the AddPointsEvent struct that generates the reason for the loyalty event. Since the mapping between tiers and point accrual rates is closely tied together and the reasons depend solely on the AddPointsEvent type, it makes sense to define these methods directly on the respective structs rather than embedding that logic in our function.

impl Tier {
    pub fn ratio(&self) -> i32 {
        match self {
            Tier::None => 0,
            Tier::Basic => 10,
            Tier::Silver => 12,
            Tier::Gold => 15,
            Tier::Platinum => 20,
        }
    }
}

impl AddPointsEvent {
    pub fn reason(&self) -> Cow<'static, str> {
        match self {
            AddPointsEvent::MembershipRenewed => "Membership renewed".into(),
            AddPointsEvent::InStorePurchase { .. } => "In-store purchase".into(),
            AddPointsEvent::OnlinePurchase { .. } => "Online purchase".into(),
            AddPointsEvent::Manual { reason, .. } => reason
                .as_ref()
                .cloned()
                .map(Into::into)
                .unwrap_or("Manual addition".into()),
        }
    }
}

With these helper methods in place, we can now proceed to write the function that calculates the points and returns a LoyaltyEvent. There are two different situations to consider when calculating the number of points to add. For MembershipRenewed and Manual cases, the point addition is always static no matter the member’s tier. However, for purchases, we need to take into account their current tier to determine the new total.

fn create_event(tier: &Tier, input: &AddPointsEvent) -> LoyaltyEvent {
    const MEMBERSHIP_RENEWED_POINTS: i32 = 290;

    let delta_points = match input {
        AddPointsEvent::MembershipRenewed => MEMBERSHIP_RENEWED_POINTS,
        AddPointsEvent::InStorePurchase { purchase_amount }
        | AddPointsEvent::OnlinePurchase { purchase_amount } => {
            *purchase_amount as i32 * tier.ratio()
        }
        AddPointsEvent::Manual { loyalty_points, .. } => *loyalty_points as i32,
    };

    LoyaltyEvent {
        event_id: Uuid::new_v4(),
        delta_points,
        reason: input.reason().to_string(),
    }
}

Next, we can create a unit test function and leverage a handy crate called rstest to generate multiple test cases from just two functions. Using the #[case] attribute allows us to define multiple test cases for a single test, while the #[values] attribute enables testing various combinations of different inputs.

In this case, we can utilize both attributes to generate 20 test cases from just two test functions.

In the first test function, we can test all cases where the tier does not affect the calculated number of points. Therefore, we only need to verify that the AddPointsEvent input consistently returns the same expected number of points, regardless of the tier.

In the second test function, we can ensure that different tiers yield the correct number of points for each tier, whether it’s for in-store or online purchases.

rstest provides several other useful features for writing unit tests in Rust, such as the ability to create fixtures, test case templates, automated conversions, and more.


#[cfg(test)]
mod tests {
    use super::*;
    use rstest::*;
    use speculoos::prelude::*;

    /// Test all cases that generate a static number of points regardless of tier
    #[rstest]
    #[case(AddPointsEvent::MembershipRenewed, 290)]
    #[case(AddPointsEvent::Manual { loyalty_points: 200, reason: None }, 200)]
    fn test_create_event_static(
        #[values(Tier::None, Tier::Basic, Tier::Silver, Tier::Gold, Tier::Platinum)] tier: Tier,
        #[case] input: AddPointsEvent,
        #[case] expected: i32,
    ) {
        // GIVEN a Tier and AddPointsEvent

        // WHEN calling `create_event`
        let res = create_event(&tier, &input);

        // THEN it should return the expected points amount
        assert_that!(res.delta_points).is_equal_to(expected);
    }

    /// Test all cases that generate a different number of points based on tier
    #[rstest]
    #[case(Tier::None, 0)]
    #[case(Tier::Basic, 10)]
    #[case(Tier::Silver, 12)]
    #[case(Tier::Gold, 15)]
    #[case(Tier::Platinum, 20)]
    fn test_create_event_variable(
        #[case] tier: Tier,
        #[values(AddPointsEvent::InStorePurchase { purchase_amount: 1.5 }, AddPointsEvent::OnlinePurchase { purchase_amount: 1.5 })]
        input: AddPointsEvent,
        #[case] expected: i32,
    ) {
        // GIVEN a Tier and AddPointsEvent

        // WHEN calling `create_event`
        let res = create_event(&tier, &input);

        // THEN it should return the expected points amount
        assert_that!(res.delta_points).is_equal_to(expected);
    }
}

Sociable vs solitary tests

As we discussed earlier, breaking down monolithic business logic into smaller units enhances testability. However, this approach may overlook issues that arise at integration points. Two potential challenges might emerge:

  1. Validate accurate information flow between smaller logic components.
  2. Validate that there are no hidden interactions between business logic and driven adapters.

To address these challenges, it can be beneficial to create sociable tests that comprehensively test the unit along with its dependencies:

Add Points Logic Get Membership Member API Get Loyalty Database Compute Points Register Event

However, while sociable tests offer valuable integration validation, they are not without their own set of challenges, in contrast to the more isolated nature of solitary tests. As seen in the previous section on tests without side effects, solitary tests have their merits, particularly in focusing on individual components. Sociable tests can introduce complexity in their setup, such as when dealing with the deployment of a database or an HTTP service, and they often demand more ongoing maintenance as the interconnected units evolve.

It’s important to recognize that there’s no universal approach that fits every situation. A strategic approach is to leverage test doubles for components that are difficult to set up or costly to maintain, while maintaining comprehensive integration tests for closely interconnected units.

For the Add Points Logic, we can swap out the database and members API with their respective test double equivalents to validate the logic while minimizing the complexity of the test setup. That said, in adopting this approach, we forfeit the direct testing of potential side effects between the logic and adapters. To address this gap, we will need to compensate with integration and/or end-to-end tests.

Add Points Logic Get Membership Mock Member Get Loyalty Memory Database Compute Points Register Event

Before writing tests, let’s wrap up the implementation of the domain logic for adding points. During this phase, we can see some of the quirks of using tower. Specifically, we need to clone the member and database objects, ensuring their availability until the resolution of the future, even if the DomainLogic object itself might not persist.

impl<D, M> Service<AddPointsRequest> for DomainLogic<D, M>
where
    D: DatabasePort + 'static,
    M: MemberPort + 'static,
{
    type Response = AddPointsResponse;
    type Error = Error;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;

    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        Poll::Ready(Ok(()))
    }

    fn call(&mut self, req: AddPointsRequest) -> Self::Future {
        let member = self.member.clone();
        let database = self.database.clone();
        Box::pin(async move {
            // Fetch necessary data
            let db_member = member.get_member(req.member_id).await?;
            let loyalty = database.get_loyalty_points(db_member.member_id).await?;

            // Create a Member object
            let membership_months = if db_member.active_member {
                Some(months_since(db_member.membership_since)?)
            } else {
                None
            };
            let member = Member::new(db_member.member_id, membership_months, loyalty.points);

            // Create and store the new loyalty event
            let event = create_event(&member.tier(), &req.event);
            let updated_loyalty = database
                .register_loyalty_event(member.member_id, event)
                .await?;

            // Return the response
            Ok(AddPointsResponse {
                member_id: member.member_id,
                tier: member.tier(),
                old_loyalty_points: loyalty.points,
                new_loyalty_points: updated_loyalty.points,
            })
        })
    }
}

Having completed the implementation of the core logic, it’s time to consolidate our efforts and craft a unit test function. In this test, we will employ a mock member that expects a get_member call, along with an in-memory database containing pre-existing loyalty data for the corresponding member. Subsequently, we’ll validate that the call succeeds and returns the expected value.

    #[rstest]
    #[tokio::test]
    async fn test_call(member_id: Uuid) -> Result<(), BoxError> {
        // GIVEN
        // * a member port that returns information
        // * a database with existing loyalty data
        let mut member = MockMemberPort::new();
        member
            .expect_get_member()
            .times(1)
            .with(eq(member_id))
            .returning(move |_| {
                Ok(crate::ports::member::Member {
                    active_member: true,
                    member_id,
                    membership_since: Utc::now() - Duration::days(700),
                })
            });
        let database = MemoryDatabase::default();
        database
            .register_loyalty_event(
                member_id,
                LoyaltyEvent {
                    event_id: Uuid::new_v4(),
                    delta_points: 305,
                    reason: "SOME REASON".to_string(),
                },
            )
            .await?;

        let mut domain = DomainLogic {
            member: Arc::new(member),
            database: Arc::new(database.clone()),
        };

        // WHEN calling the service
        let req = AddPointsRequest {
            event: AddPointsEvent::InStorePurchase {
                purchase_amount: 3.65,
            },
            member_id,
        };
        let res = domain.ready().await?.call(req).await;

        // THEN
        // * It returns a valid response
        // * All ports are called
        assert_that!(res).is_ok().is_equal_to(AddPointsResponse {
            member_id,
            tier: Tier::Gold,
            old_loyalty_points: 305,
            new_loyalty_points: 350,
        });
        Arc::into_inner(domain.member).unwrap().checkpoint();

        Ok(())
    }

Next article

In this instalment, we looked at the entire process of setting up a hexagonal architecture and test doubles. We also delved into diverse methods for writing ports, such as tower::Services and async_traits. Through these, we implemented unit tests that use driving and driven ports to validate the correctness of our core domain logic.

In the next article in this series, we’ll look at how to test the remainder of our hexagonal architecture: the driving and driven adapters.

If you want to be informed when futures articles come out, you can either follow me on Twitter where I’ll post about it, or subscribe to the syndication feed of this blog.