Hexagonal Architecture with Rust & AWS Lambda

A look at how the principles of hexagonal architecture and domain driven design can be applied to AWS Lambda functions written in Rust.

I’ve spent a good portion of 2023 learning the basic principles of Rust. Fascinated by it’s performance, especially within the context of serverless systems. I dove deep, putting my object oriented, 10 years of .NET development to the side and attempted to learn Rust. And it’s been quite the journey.

Early in my career, Domain Driven Design changed everything for me. Up until that point, I was more of a script kiddy than a software development. I look at times in my life as before the big blue book, and after. It sounds hyperbolic, but reading that book changed the trajectory of my career.

Of course, domain driven design is something you apply at the design phase of your system. And once you’ve identified the core domain, you focus all of your efforts into building software in this area. As you move into the development phase, one of the best techniques I’ve found for actually writing code is clean architecture.

Clean architecture, very similar to hexagonal architecture, ports and adapters and onion architecture is a way of structuring your code to separate the business logic from the code that deals with ‘infrastructure’ concerns.

In a more traditional application context, this separates the outer layers of your application like API’s, databases and message buses from your actual business logic. Using AWS Lambda, this would separate the code that handles the Lamdba event from the actual business logic.

I couldn’t quite get rid of all my .NET thinking, so I went ahead trying to bring this kind of programming model to Rust.

In .NET, or other object oriented languages, you have things like encapsulation, inheritance, abstract classes and interfaces. You might define an interface as an IToDoRepository and then implement that interface with a DynamoDbTodoRepository that contains the logic specific to interface with DynamoDB.

Let’s start at the more fundamental level. One of my favourite ideas from clean architecture is hiding certain pieces of functionality inside the domain. Let’s take the example of a ToDo application.

A ToDo is quite a simple thing:

/// Represents the structure of an incomplete ToDo
#[non_exhaustive]
#[derive(Deserialize, Serialize)]
pub struct ToDoItem {
    pub id: String,
    pub title: String,
    pub is_complete: bool,
    pub completed_on: String,
}

It has a title and if it has been completed or not. But actually, there might be quite a lot of business logic that goes into the management of a ToDo record. Off the top of my head, things like:

  • A title cant be changed once a ToDo is completed
  • A ToDo can’t be ‘uncompleted’

The business logic for these 2 things, are related to the ToDo itself and therefore the ToDo should own the logic. There should be no way to update a title, or the completion status without calling a function on the ToDo itself.

#[non_exhaustive]
pub enum ToDo {
    /// Represents an incomplete ToDo item
    Incomplete(IncompleteToDo),
    /// Represents a complete ToDo item
    Complete(CompleteToDo),
}

impl ToDo {
    /// Update the title of the existing ToDo.
    /// If the ToDo is already completed then the title cannot be updated.
    /// Returns a new ToDo
    pub(crate) fn update_title(self, new_title: String) -> Result<ToDo, ValidationError> {
        let new_title_value = Title::new(new_title);

        if new_title_value.is_err() {
            return Err(new_title_value.err().unwrap());
        }

        let response = match &self {
            ToDo::Incomplete(incomplete) => ToDo::Incomplete(IncompleteToDo {
                to_do_id: incomplete.to_do_id.clone(),
                title: new_title_value.unwrap(),
                owner: OwnerId::new(incomplete.owner.to_string()).unwrap(),
            }),
            ToDo::Complete(complete) => ToDo::Complete(CompleteToDo {
                to_do_id: complete.to_do_id.clone(),
                title: Title::new(complete.title.to_string()).unwrap(),
                owner: OwnerId::new(complete.owner.to_string()).unwrap(),
                completed_on: complete.completed_on,
            }),
        };

        Ok(response)
    }

    /// Set the ToDo as completed
    pub(crate) fn set_completed(self) -> ToDo {
        match &self {
            ToDo::Incomplete(incomplete) => ToDo::Complete(CompleteToDo {
                to_do_id: incomplete.to_do_id.clone(),
                title: Title::new(incomplete.title.to_string()).unwrap(),
                owner: OwnerId::new(incomplete.owner.to_string()).unwrap(),
                completed_on: DateTime::parse_from_rfc3339(&Utc::now().to_rfc3339()).unwrap(),
            }),
            ToDo::Complete(complete) => ToDo::Complete(CompleteToDo {
                to_do_id: complete.to_do_id.clone(),
                title: Title::new(complete.title.to_string()).unwrap(),
                owner: OwnerId::new(complete.owner.to_string()).unwrap(),
                completed_on: complete.completed_on,
            }),
        }
    }
}

Now, if any part of the application wants to update the title or the status they must use these two methods. The logic is encapsulated, and not spread all over your application.

Enums are one of the features I love most about Rust. You can add implementations to an Enum, and then one of the options of the Enum can have a struct. The implemented methods on the enum can then do different things based on the enum options.

As far as your application is concerned, you’re just calling methods on a ToDo. Inside your domain, the underlying logic differs depending on the type of ToDo you’re using.

Another interesting idea of clean architecture, is separating infrastructure from application code. In .NET, that is typically done using interfaces and the implementation of them interfaces.

Rust doesn’t have that luxury, interfaces aren’t a thing. However, you do have traits. Traits allow you to implement similar functionality to interfaces in Rust.

#[async_trait]
pub trait ToDoRepo
{
    async fn list(&self, user_id: &str) -> Result<Vec<ToDo>, RepositoryError>;

    async fn create(&self, to_do: &ToDo) -> Result<(), RepositoryError>;

    async fn get (&self, user_id: &str, todo_id: &str) -> Result<ToDo, RepositoryError>;
}

This is an implementation of a ToDoRepo, for interacting with a persistence layer. This could equally be a MessageBus.

Code in your actual application then only makes method calls on the ToDoRepo trait, notice the use of the dyn keyword. The implementation of a ToDoRepo is passed in at runtime.

pub async fn list_todos(owner: &String, client: &Arc<dyn ToDoRepo + Send + Sync>) -> Result<Vec<ToDoItem>, ()> {
    let query_res = client.list(owner).await;

    match query_res {
        Ok(todos) => {
            let mut to_do_items: Vec<ToDoItem> = Vec::new();

            for todo in todos {
                to_do_items.push(todo.into_dto());
            }

            Ok(to_do_items)
        }
        Err(_) => Err(()),
    }
}

At the outermost layer of your application, in this case an Axum web APIm, the implementation of the ToDoRepo is defined and passed into your application state. Notice the use of a DynamoDbToDoRepo, that uses a DynamoDbClient from the newly GA AWS SDK for Rust.

let shared_state = Arc::new(AppState {
    todo_repo: Arc::new(DynamoDbToDoRepo::new(
        dynamodb_client.clone(),
        table_name.clone(),
    )),
});

let app = app(shared_state);

let listener = tokio::net::TcpListener::bind("127.0.0.1:8080")
    .await
    .unwrap();

axum::serve(listener, app).await.unwrap();

Early days in my exploration of the Rust language, but it’s been an interesting journey trying to marry together the two different programming models I hold in my head. The design principles of .NET, with the syntax and idioms of Rust.

If you’re interested in the code from this article, check out the GitHub repo.

For anyone reading this who is a more experience Rust developer, I’d love to hear if this is the right thing to do or if I’m heading down a long and lonely road.

Until next time, James

Edit this page

Next
Previous

Related