Learning Modular Monolith Architecture with Rust
Learn Rust modular monolith: 7-step tutorial from Hello World to I/O-agnostic application with hexagonal architecture, traits and crates. For beginners, tinkerers, hobbyists, amateurs, and early-career developers…
This is Episode 03
All the examples are on GitHub
The Posts Of The Saga
- Episode 00: 🟢 Introduction + Step 00 - First prototype working
- Episode 01: 🟢 Step 01 - Split the source code in multiple files
- Episode 02: 🟢 Step 02 - Add a test folder
- Episode 03: 🟢 Step 03 - Implement Hexagonal Architecture
- Episode 04: 🔵 Step 04 - One crate per component
- Episode 05: 🔴 Step 05 - Anyhow & ThisError
- Episode 06: 🟢 Step 06 - Add new adapters + Conclusion
- Episode 07: 🟢 Bonus

Table of Contents
Objective
We want to start implementing an Hexagonal Architecture. The implementation will be finished in Episode 04 when the greeting_service.rs file and its use cases will be available.
If you have ANY doubt about Hexagonal Architecture, Ports and Adapters, before you move forward, read this dedicated page.
Anyway, at the end of Step 03, the folder hierarchy should look like:
step_03/
│ Cargo.toml
├───src
│ │ domain.rs # Business rules (isolated)
│ │ error.rs # Error and Result type alias
│ │ lib.rs # Library re-exports
│ │ main.rs # Entry point
│ │ ports.rs # Traits definition
│ └───adapters # Implementations
│ console_input.rs
│ console_output.rs
│ mod.rs
└───tests
adapters_test.rs # Adapters unit tests
domain_test.rs # Domain unit tests
integration_test.rs # Integration tests
Setup
- Save your work
- Quit VSCode
- You should have a terminal open and you should be in the
step_02/folder
cd ..
# make a copy the folder step_02 and name it step_03
Copy-Item ./step_02 ./step_03 -Recurse
cd step_03
code .
- Before to read further, feel free to read this page.
- Indeed since the previous page exists, I do no plan to explain the concepts of the Hexagonal Architecture once again.
Actions
Cargo.toml
[package]
name = "step_03"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "step_03"
path = "src/main.rs"
ports.rs
Create a ports.rs file.
use crate::error::Result;
pub trait NameReader {
fn read_name(&self) -> Result<String>;
}
pub trait GreetingWriter {
fn write_greeting(&self, greeting: &str) -> Result<()>;
}
Points of attention:
- The idea is to make sure that our application get inputs only from objects with the
NameReadertrait while it writes the greeting on objects having theGreetingWritertrait.
adapters/console_input.rs
Create an adapters/ folder and add 2 concrete implementations of the previous traits in the console_input.rs and console_output.rs files.
// console_output.rs
use crate::error::Result;
use crate::ports;
#[derive(Default)]
pub struct ConsoleOutput;
impl ConsoleOutput {
pub fn new() -> Self {
Self
}
}
impl ports::GreetingWriter for ConsoleOutput {
fn write_greeting(&self, greeting: &str) -> Result<()> {
println!("{}", greeting);
Ok(())
}
}
Points of attention:
- No surprise, since this is an implementation of the
GreetingWriterand since it is namedConsoleOutputit… Yes, it writes on the console. - Did you notice the
use crate::Result;anduse crate::ports;at the top of the file? - Did you notice that
.new()returnsSelfwith a capital ‘S’?
adapters/console_output.rs
// console_input.rs
use std::io::{self, Write};
use crate::error::Result;
use crate::ports;
#[derive(Default)]
pub struct ConsoleInput;
impl ConsoleInput {
pub fn new() -> Self {
Self
}
}
impl ports::NameReader for ConsoleInput {
fn read_name(&self) -> Result<String> {
print!("> ");
io::stdout()
.flush()
.map_err(|e| format!("Failed to flush stdout: {}", e))?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|e| format!("Failed to read from stdin: {}", e))?;
let name = input.trim().to_string();
Ok(name)
}
}
Points of attention:
- It is almost a copy and paste from
step_02/main.rs.
adapters/mod.rs
Last but not least, add a file adapters/mod.rs that “describes” the modules that make up the adapters module and re-export ConsoleInput and ConsoleOutput names.
// mode.rs
pub mod console_input;
pub mod console_output;
pub use console_input::ConsoleInput;
pub use console_output::ConsoleOutput;
lib.rs
At this point, we have the Ports (traits) and the Adapters (implementations). We need to add them to the lib.rs file so that the main.rs file can use them:
pub mod adapters;
pub mod domain;
pub mod error;
pub mod ports;
main.rs
Finally we can rewrite main.rs file as follow:
// main.rs
use step_03::adapters;
use step_03::domain;
use step_03::ports;
use step_03::error::Result;
fn main() -> Result<()> {
println!("=== Greeting Service (Step 03 - Hexagonal Architecture) ===");
println!("Enter a name to greet (or 'quit' to exit):\n");
// Dependency injection: Create adapters
let input = adapters::ConsoleInput::new();
let output = adapters::ConsoleOutput::new();
run_greeting_loop(&input, &output)?;
Ok(())
}
fn run_greeting_loop(
input: &dyn ports::NameReader,
output: &dyn ports::GreetingWriter,
) -> Result<()> {
loop {
// Read name from input adapter
let name = input.read_name()?;
if name.eq_ignore_ascii_case("quit")
|| name.eq_ignore_ascii_case("exit")
|| name.eq_ignore_ascii_case("q!")
{
println!("\nGoodbye!");
break;
}
if name.is_empty() {
continue;
}
// Call domain logic (pure business rules)
match domain::greet(&name) {
Ok(greeting) => {
// Write greeting to output adapter
output.write_greeting(&greeting)?;
}
Err(e) => {
eprintln!("Error: {}\n", e);
}
}
println!();
}
Ok(())
}
Points of attention:
- Up to now, in
main()we were callingdomain::greet()directly. - Now, we first instantiate an input and an output data types (
adapters) which implement the traits defined inports.rs- See the
let input = adapters::ConsoleInput::new();for example
- See the
- Then we call
run_greeting_loop()using references to the adapters as arguments- See the
&inputfor example
- See the
- The signature of
run_greeting_loop()shows that it accepts ANY reference to variable having the right trait- See the
input: &dyn ports::NameReader
- See the
- Here we make it works with
ConsoleInputandConsoleOutputbut it would work the same way with WebInput and StonesOutput if they have the expected traits. - In the code above look for
.read_name()and.write_greeting()and realize we don’t really know on who they will be applied.
tests/adapters_test.rs
The mock adapters used for the first time in tests/adapters_test.rs deserve some attention. Let’s think about it for a second. We just introduced the ports NameReader and GreetingWriter specifically so that the domain doesn’t depend on any concrete I/O. But if we want to test the whole flow (read a name → greet → write the greeting) without actually typing in a console… we need fake implementations of those traits. That’s what mocks are.
A mock adapter is just a struct that implements a port trait but does something trivial and predictable instead of real I/O. MockInput holds a hardcoded name and returns it when read_name() is called. MockOutput captures whatever greeting is passed to write_greeting() into an internal Vec<String> so we can inspect it afterwards.
Here is the beginning of the code:
use step_03::domain;
use step_03::ports::{GreetingWriter, NameReader};
use step_03::error::Result;
/// Mock input adapter that returns a predefined name.
struct MockInput {
name: String,
}
impl MockInput {
fn new(name: &str) -> Self {
Self {
name: name.to_string(),
}
}
}
impl NameReader for MockInput {
fn read_name(&self) -> Result<String> {
Ok(self.name.clone())
}
}
/// Mock output adapter that captures written greetings.
struct MockOutput {
written: std::cell::RefCell<Vec<String>>,
}
impl MockOutput {
fn new() -> Self {
Self {
written: std::cell::RefCell::new(Vec::new()),
}
}
fn get_written(&self) -> Vec<String> {
self.written.borrow().clone()
}
}
impl GreetingWriter for MockOutput {
fn write_greeting(&self, greeting: &str) -> Result<()> {
self.written.borrow_mut().push(greeting.to_string());
Ok(())
}
}
#[test]
fn mock_input_reader() {
let input = MockInput::new("Alice");
let name = input.read_name().unwrap();
assert_eq!(name, "Alice");
}
// Others test below
Points of attention:
-
The beautiful thing is that from the test’s perspective, there’s no difference between a mock and a real adapter. We write:
let input = MockInput::new("Alice"); let output = MockOutput::new(); let name = input.read_name().unwrap(); let greeting = domain::greet(&name).unwrap(); output.write_greeting(&greeting).unwrap(); let written = output.get_written(); assert_eq!(written[0], "Hello Alice."); - This is exactly the same sequence of calls we’d have in
main()with realConsoleInputandConsoleOutput. - The only difference is that nobody is sitting at a keyboard, and the result ends up in a vector instead of on the screen. That’s the whole point of hexagonal architecture paying off: the domain and the wiring are testable in complete isolation, no I/O involved.
- There are not shown in the code fragment above but in
tests/adapters_test.rswe also defineFailingInputandFailingOutput(mocks that always return an error). These let us verify that our error handling works without needing to unplug a cable or corrupt a file.
Build, run & test
Build, run and test the application. Find below the expected output:
cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
Running `C:/Users/phili/rust_builds/Documents/Programmation/rust/01_xp/046_modular_monolith/step_03\debug\step_03.exe`
=== Greeting Service (Step 03 - Hexagonal Architecture) ===
Enter a name to greet (or 'quit' to exit):
> Roberto
Ciao Roberto!
> exit
Goodbye!
cargo test
Compiling step_03 v0.1.0 (C:\Users\phili\OneDrive\Documents\Programmation\rust\01_xp\046_modular_monolith\step_03)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.82s
Running unittests src\lib.rs (C:/Users/phili/rust_builds/Documents/Programmation/rust/01_xp/046_modular_monolith/step_03\debug\deps\step_03-6353ffb82854041d.exe)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src\main.rs (C:/Users/phili/rust_builds/Documents/Programmation/rust/01_xp/046_modular_monolith/step_03\debug\deps\step_03-e5e90dd1ed39887f.exe)
running 1 test
test tests::greeting_loop_with_mocks ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests\adapters_test.rs (C:/Users/phili/rust_builds/Documents/Programmation/rust/01_xp/046_modular_monolith/step_03\debug\deps\adapters_test-46d627c56f70ee9b.exe)
running 9 tests
test error_propagation ... ok
test failing_input ... ok
test greeting_flow_with_roberto ... ok
test multiple_greetings ... ok
test failing_output ... ok
test mock_input_reader ... ok
test greeting_flow_with_long_name ... ok
test greeting_flow_with_mocks ... ok
test mock_output_writer ... ok
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Running tests\domain_test.rs (C:/Users/phili/rust_builds/Documents/Programmation/rust/01_xp/046_modular_monolith/step_03\debug\deps\domain_test-73a3c5e5e6aeee7f.exe)
running 9 tests
test boundary_case_nineteen_chars ... ok
test domain_should_handle_unicode_names ... ok
test domain_should_not_use_special_greeting_for_similar_names ... ok
test greeting_length_limit ... ok
test normal_greeting ... ok
test truncation_for_long_names ... ok
test empty_name_returns_error ... ok
test domain_should_truncate_long_unicode_names ... ok
test roberto_special_case ... ok
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s
Running tests\integration_test.rs (C:/Users/phili/rust_builds/Documents/Programmation/rust/01_xp/046_modular_monolith/step_03\debug\deps\integration_test-16290d5b02f48fa2.exe)
running 6 tests
test empty_name_integration ... ok
test long_name_integration ... ok
test multiple_greetings_integration ... ok
test end_to_end_with_dependency_injection ... ok
test greet_integration ... ok
test roberto_integration ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Doc-tests step_03
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Points of attention:
There are more tests.
- Indeed one test of the
greeting_loop_with_mocks()function have been added tomain.rsbecauserun_greeting_loop()is not public. - In addition, the file
test/adapters_test.rshost tests for the adapters using Mock implementations. tests/integration_test.rsnow use mock adapterstests/domain_test.rsis not modified- The source code of the test is available on GitHub on this page.
Summary
What have we done so far?
domain.rswas NOT impacted. We “just” re-arrange the machinery around it- ports = traits
- adapters = implementations
- Hexagonal Architecture is not rocket science but it really helps to name things and to describe the roles.
Next Steps
Next you can read Episode 04 but I would recommend to take a break because there is a kind of quantic jump between Episode 03 and Episode 04. Sleep well, see you tomorrow.
- Episode 00: Introduction + Step 00 - First prototype working
- Episode 01: Step 01 - Split the source code in multiple files
- Episode 02: Step 02 - Add a test folder
- Episode 03: Step 03 - Implement Hexagonal Architecture
- Episode 04: Step 04 - One crate per component
- Episode 05: Step 05 - Anyhow & ThisError
- Episode 06: Step 06 - Add new adapters + Conclusion
- Episode 07: Bonus