Learning Modular Monolith Architecture with Rust
An 7-project progression from Hello World to a fully decoupled, I/O-agnostic application using traits and crates
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

Table of Contents
Objective
We want to start implementing an Hexagonal Architecture. The implementation will be finished when the application.rs and its use cases will be available. 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 .
- If you have ANY doubt about Hexagonal Architecture, Ports and Adapters before you move forward, read this dedicated page.
- Indeed since the previous page exists, I do no plan to explain these concepts one more time.
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;
pub struct ConsoleOutput;
impl ConsoleOutput {
pub fn new() -> Self {
Self
}
}
impl Default for ConsoleOutput {
fn default() -> Self {
Self::new()
}
}
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()and.default()returnsSelfwith a capital ‘S’?
adapters/console_output.rs
// console_input.rs
use std::io::{self, Write};
use crate::error::Result;
use crate::ports;
pub struct ConsoleInput;
impl ConsoleInput {
pub fn new() -> Self {
Self
}
}
impl Default for ConsoleInput {
fn default() -> Self {
Self::new()
}
}
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") {
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 thelet input = adapters::ConsoleInput::new();for example) - Then we call
run_greeting_loop()using references to the adapters (see the&inputfor example) as arguments - The signature of
run_greeting_loop()shows that it accepts ANY reference to variable having the right trait (see theinput: &dyn ports::NameReader). - Here we make it works with
ConsoleInputandConsoleOutputbut it would work the same way with WebInput and StonesOutput if they had 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.
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
- Indeed one test of the
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
- 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