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 01
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 split the last version of the POC among multiple files. At the end the project folder will look like this:
step_01/
│ Cargo.toml
└───src
domain.rs
error.rs
lib.rs
main.rs
Setup
- Save your work
- Quit VSCode
- You should have a terminal open and you should be in the
step_00/folder.
cd ..
# make a copy the folder step_00 and name it step_01
Copy-Item ./step_00 ./step_01 -Recurse
cd step_01
code .
- Move
examples/ex07.rsintosrc/main.rs - Delete the
examples/folder
Actions
Cargo.toml
[package]
name = "step_01"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "step_01"
path = "src/main.rs"
error.rs
Create an error.rs file and copy the Error and Result type aliases in it:
pub type Error = Box<dyn std::error::Error>;
pub type Result<T> = std::result::Result<T, Error>;
domain.rs
Extract from main.rs the greet() function and the tests then copy them in a new domain.rs file.
use crate::error::Result;
pub fn greet(name: &str) -> Result<String> {
if name.is_empty() {
return Err("Name cannot be empty".to_string().into());
}
if name == "Roberto" {
return Ok("Ciao Roberto!".to_string());
}
const MAX_LENGTH: usize = 25;
const GREETING_PREFIX: &str = "Hello ";
const GREETING_SUFFIX: &str = ".";
const TRAILER: &str = "...";
let available_for_name = MAX_LENGTH - GREETING_PREFIX.len() - GREETING_SUFFIX.len();
if name.len() <= available_for_name {
return Ok(format!("Hello {}.", name));
}
let truncate_length = MAX_LENGTH - GREETING_PREFIX.len() - TRAILER.len();
let truncated_name = &name[..truncate_length.min(name.len())];
Ok(format!("Hello {}{}", truncated_name, TRAILER))
}
#[cfg(test)]
mod tests {
// The tests are here
}
Points of attention:
- Do you see the
use crate::error::Result;statement at the top ofdomain.rs. - Is the usage of
cratehere OK for you? greet()is now public.- Since
greet()is public we could have stored the tests outside of this file to make sure they behave like any other “consumer”.
lib.rs
Create a lib.rs
pub mod domain;
pub mod error;
pub use domain::greet;
Points of attention:
- See how
greet ()is re-exported.- This allows the functions from the
domainmodule (such asgreet()) to be used directly in themain.rswithout having to writedomain::greet. - This may simplifies the import for the end-user of the lib. They can
use crate::greet;instead ofuse crate::domain::greet;. - For the code consumers, it is therefore a question of ease of use vs clarity.
- I’m not always a big fan of it and I will explain why later.
- This allows the functions from the
main.rs
The remaining of the code is the main.rs file:
use std::io::{self, Write};
use step_01::error::Result;
use step_01::greet;
fn main() -> Result<()> {
println!("=== Greeting Service (Step 01) ===");
println!("Enter a name to greet (or 'quit' to exit):\n");
loop {
print!("> ");
io::stdout().flush().map_err(|e| e.to_string())?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|e| format!("Failed to read input: {}", e))?;
let name = input.trim();
if name.eq_ignore_ascii_case("quit") || name.eq_ignore_ascii_case("exit") {
println!("\nGoodbye!");
break;
}
if name.is_empty() {
continue;
}
match greet(name) {
Ok(greeting) => println!("{}\n", greeting),
Err(e) => eprintln!("Error: {}\n", e),
}
}
Ok(())
}
Points of attention:
- See how
Resultandgreetare shortcutted with theusestatements. - Make sure to understand why here, we write
use step_01::error::Result;while indomain.rswe wroteuse crate::error::Result;.- If needed, you can read again this page.
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.13s
Running `C:/Users/phili/rust_builds/Documents/Programmation/rust/01_xp/046_modular_monolith/step_01\debug\step_01.exe`
=== Greeting Service (Step 01) ===
Enter a name to greet (or 'quit' to exit):
> quit
Goodbye!
cargo test
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.01s
Running unittests src\lib.rs (C:/Users/phili/rust_builds/Documents/Programmation/rust/01_xp/046_modular_monolith/step_01\debug\deps\step_01-83edb83cf52bf88d.exe)
running 9 tests
test domain::tests::domain_should_handle_unicode_names ... ok
test domain::tests::domain_should_not_use_special_greeting_for_similar_names ... ok
test domain::tests::test_truncation_for_long_names ... ok
test domain::tests::test_normal_greeting ... ok
test domain::tests::domain_should_truncate_long_unicode_names ... ok
test domain::tests::test_boundary_case_nineteen_chars ... ok
test domain::tests::test_greeting_length_limit ... ok
test domain::tests::test_empty_name_returns_error ... ok
test domain::tests::test_roberto_special_case ... ok
test result: ok. 9 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_01\debug\deps\step_01-a5fcc988c1d0de71.exe)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests step_01
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Points of attention:
- We can now test the domain module in isolation
cargo test domain - We can develop it independently as long as the signature of
greet()remains stable.
Summary
What have we done so far?
- Nothing change from the outside. That’s good news.
- And we have a more modular project.
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