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 post is under construction 🚧
This is Episode 05
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 include anyhow and thiserror crates. At the end of this episode, the folder hierarchy should look like this:
step_05
│ Cargo.toml
└───crates
├───adapter_console
│ │ Cargo.toml
│ ├───src
│ │ input.rs
│ │ lib.rs
│ │ output.rs
│ └───tests
│ adapter_console_test.rs
├───app
│ │ Cargo.toml
│ └───src
│ main.rs
├───application
│ │ Cargo.toml
│ ├───src
│ │ error.rs
│ │ greeting_service.rs
│ │ lib.rs
│ └───tests
│ application_test.rs
├───domain
│ │ Cargo.toml
│ ├───src
│ │ error.rs
│ │ greeting.rs
│ │ lib.rs
│ │ ports.rs
│ └───tests
│ domain_test.rs
└───integration_tests
│ Cargo.toml
├───src
│ lib.rs
└───tests
integration_test.rs
Setup
- Save your work
- Quit VSCode
- You should have a terminal open and you should be in the
step_04/folder
cd ..
# make a copy the folder step_05 and name it step_06
Copy-Item ./step_04 ./step_05 -Recurse
cd step_05
code .
- If you have ANY doubt about
anyhoworthiserrorbefore you move forward, read this dedicated page.
Actions
Cargo.toml
[workspace]
members = [
"crates/domain",
"crates/application",
"crates/adapter_console",
"crates/app",
"crates/integration_tests",
]
resolver = "3"
[workspace.package]
version = "0.1.0"
edition = "2024"
license = "MIT"
[workspace.dependencies]
thiserror = "2.0"
anyhow = "1.0"
Points of attention:
- Obviously,
thiserrorandanyhoware listed
The domain crate
Here is Cargo.toml:
[package]
name = "domain"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
thiserror.workspace = true
Points of attention:
thiserroris added
Let’s first update port.rs.
pub type PortError = Box<dyn std::error::Error + Send + Sync>;
pub type Result<T> = std::result::Result<T, PortError>;
pub trait NameReader {
fn read_name(&self) -> Result<String>;
}
pub trait GreetingWriter {
fn write_greeting(&self, greeting: &str) -> Result<()>;
}
Points of attention:
- The signatures of both traits are unchanged.
- Pay attention. Indeed in the traits we need to indicate that
read_nameandwrite_greetingmay return errors due to the port, NOT to the business logic. Think about the case where you will send a name via RS-232, UDP, carrier pigeon… This is why in the port module we type aliasResult<T>asResult<T, PortError>and we type aliasPortErroras aBox<dyn std::error::Error>.- Why do we need
Box <dyn>? Because there are multiple possible source or error that we don’t know yet at compile time: I/O, Network… - Why do we need
Send + Sync? For thread safety. Not useful here but we never know.
- Why do we need
- The point to keep in mind is:
- The business logic may return an error if the parameter is empty
Portsare part of the domain. They may report specific I/O error.
This is what is shown in lib.rs file where Error, Result and PortError are exported:
pub mod error;
pub mod greeting;
pub mod ports;
pub use error::{Error, Result};
pub use greeting::greet;
pub use ports::{GreetingWriter, NameReader, PortError};
Now we can look at error.rs
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Name cannot be empty")]
EmptyName,
}
pub type Result<T> = std::result::Result<T, Error>;
Points of attention:
- The type alias
Resultremains unchanged - The definition of
Erroris totally different but this does not have any impact onResulttype alias
Only the beginning of greeting.rs is updated because now, it returns a specific error when the name is empty:
use crate::error::{Error, Result};
pub fn greet(name: &str) -> Result<String> {
if name.is_empty() {
return Err(Error::EmptyName);
}
// Rest of the code unmodified
}
The app crate
[package]
name = "app"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "step_05"
path = "src/main.rs"
[dependencies]
application = { path = "../application" }
adapter_console = { path = "../adapter_console" }
anyhow.workspace = true
Points of attention:
anyhowis in the dependencies
The error.rs file has been deleted, only remains main.rs:
use adapter_console::{ConsoleInput, ConsoleOutput};
use application::GreetingService;
use anyhow::{Context, Result};
fn main() -> Result<()> {
println!("=== Greeting Service (Step 05 - Modular Monolith & Hexagonal Architecture) ===");
println!("Enter a name to greet (or 'quit' to exit):\n");
// Dependency injection: Create adapters
let input = ConsoleInput::new();
let output = ConsoleOutput::new();
// Create application service and run
let service = GreetingService::new();
service
.run_greeting_loop(&input, &output)
.context("Failed to run interactive loop")?;
Ok(())
}
Points of attention:
- Check the
.context()thatanyhowprovides.
The application crate
[package]
name = "application"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
domain = { path = "../domain" }
thiserror.workspace = true
Points of attention:
thiserroris added
As before (check above, the error.rs file of the domain crate), in the error.rs file only the Error is updated:
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Domain(#[from] domain::Error),
#[error(transparent)]
Adapter(Box<dyn std::error::Error + Send + Sync>),
}
pub type Result<T> = std::result::Result<T, Error>;
Now let see the core of the application crate AKA greeting_service.rs:
use crate::error::{Error, Result};
pub struct GreetingService;
impl GreetingService {
pub fn new() -> Self {
Self
}
pub fn run_greeting_loop(
&self,
input: &dyn domain::NameReader,
output: &dyn domain::GreetingWriter,
) -> Result<()> {
loop {
let name = input.read_name().map_err(Error::Adapter)?;
if name.eq_ignore_ascii_case("quit") || name.eq_ignore_ascii_case("exit") {
println!("\nGoodbye!");
break;
}
if name.is_empty() {
continue;
}
let greeting = domain::greet(&name)?;
output.write_greeting(&greeting).map_err(Error::Adapter)?;
println!();
}
Ok(())
}
}
impl Default for GreetingService {
fn default() -> Self {
Self::new()
}
}
Points of attention:
- Have you seen the
.map_err(Error::Adapter) - This is the line
let greeting = domain::greet(&name)?;which force us to haveDomain(#[from] domain::Error)in theerror.rsfile
The adapter_console crate
[package]
name = "adapter_console"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
domain = { path = "../domain" }
thiserror.workspace = true
Points of attention:
thiserroris added
The error.rs file has been deleted then in output.rs and input.rs the line use crate::error::Result; has been replaced by use domain::ports::Result;. See below output.rs for example:
use domain::GreetingWriter;
use domain::ports::Result;
pub struct ConsoleOutput;
impl ConsoleOutput {
pub fn new() -> Self {
Self
}
}
impl Default for ConsoleOutput {
fn default() -> Self {
Self::new()
}
}
impl GreetingWriter for ConsoleOutput {
fn write_greeting(&self, greeting: &str) -> Result<()> {
println!("{greeting}");
Ok(())
}
}
Points of attention:
- It is important to understand that in the code above
write_greeting()returns astd::result::Result<T, PortError>. - The code becomes easier to read. In step_04 we had
impl NameReader for ConsoleInput { fn read_name(&self) -> Result<String> { // Prompt for input print!("> "); io::stdout() .flush() .map_err(|e| format!("Failed to flush stdout: {e}"))?; // Read user input 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) } }Now we can write
impl NameReader for ConsoleInput { fn read_name(&self) -> Result<String> { // Prompt for input print!("> "); io::stdout().flush()?; // Read user input let mut input = String::new(); io::stdin().read_line(&mut input)?; let name = input.trim().to_string(); Ok(name) } }
The integration_tests crate
Only one change in integration_test.rs where the line
use domain::{GreetingWriter, NameReader, error::Result};
becomes:
use domain::{GreetingWriter, NameReader, ports::Result};
Build, run & test
Build, run and test the application. Try this:
cargo test -p adapter_console
cargo test -p adapter_console --test adapter_console_test
cargo test -p adapter_console --test adapter_console_test console # any test containing "console"
cargo test -p application
cargo test -p domain --test domain_test
cargo test -p integration_tests
cargo run -p app
cargo run
Build, run and test the application. Find below the expected output:
cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.04s
Running `C:/Users/phili/rust_builds/Documents/Programmation/rust/01_xp/046_modular_monolith/step_05\debug\step_05.exe`
=== Greeting Service (Step 05 - Modular Monolith & Hexagonal Architecture) ===
Enter a name to greet (or 'quit' to exit):
> Marcel
Hello Marcel.
> exit
Goodbye!
cargo test -p domain
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.03s
Running unittests src\lib.rs (C:/Users/phili/rust_builds/Documents/Programmation/rust/01_xp/046_modular_monolith/step_05\debug\deps\domain-812d4dc27a84c7ce.exe)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests\domain_test.rs (C:/Users/phili/rust_builds/Documents/Programmation/rust/01_xp/046_modular_monolith/step_05\debug\deps\domain_test-11a40ea11f17113d.exe)
running 9 tests
test domain_should_handle_unicode_names ... ok
test domain_should_truncate_long_unicode_names ... ok
test normal_greeting ... ok
test domain_should_not_use_special_greeting_for_similar_names ... ok
test boundary_case_nineteen_chars ... ok
test empty_name_returns_error ... ok
test greeting_length_limit ... ok
test roberto_special_case ... ok
test truncation_for_long_names ... ok
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Doc-tests domain
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Summary
What have we done so far?
anyhowandthiserrorare now integrated- the impact of the transition is minimal thanks to the way
ResultandErrorwere initially defined- I suspect, but have no evidence, that a more experienced developer would have integrated
anyhowandthiserrorwith even fewer modifications.
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