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 04
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 have one crate per component an application in charge of the use cases. At the end of Step 04 folder hierarchy should look like this:
step_04
│ Cargo.toml
└───crates
├───adapter_console # depends on domain
│ │ Cargo.toml
│ ├───src
│ │ error.rs # errors are crate specific
│ │ input.rs
│ │ lib.rs
│ │ output.rs
│ └───tests
│ adapter_console_test.rs # tests are crate specific
├───app # depends application + adapter_console
│ │ Cargo.toml
│ └───src
│ error.rs # errors are crate specific
│ main.rs
├───application # depends on domain
│ │ Cargo.toml
│ ├───src
│ │ error.rs # errors are crate specific
│ │ greeting_service.rs
│ │ lib.rs
│ └───tests
│ application_test.rs # tests are crate specific
├───domain # doesn't depend on anyone
│ │ Cargo.toml
│ ├───src
│ │ error.rs # errors are crate specific
│ │ greeting.rs
│ │ lib.rs
│ │ ports.rs
│ └───tests
│ domain_test.rs # tests are crate specific
└───integration_tests # dedicated crate for integration testing
│ Cargo.toml # depends on domain + application + adapter_console
├───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_03/folder
cd ..
# make a copy the folder step_03 and name it step_04
Copy-Item ./step_03 ./step_04 -Recurse
cd step_04
code .
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"
Points of attention:
- The root
Cargo.tomldefines a workspace with multiple independent crates - Each component can be developed and tested independently
- As we will see, the
appcrate has a[[bin]]section in itsCargo.toml, enablingcargo run(in addition tocargo run -p app) - Integration tests have their own crate for separation of concerns and are run with
cargo test -p integration_tests
Points of attention:
- Up to now it was Ok to share
ErrorandResult<T>between the modules. - This is no longer the case. Indeed each crate must be independent, and we should not be surprised if some of them develop their own error code/message
- This is why in most of the crates there is an
error.rsfile - As for now, all these
error.rsfiles look the same but tomorrow, one crate may start usingthiserrorwhile the others keep their error schema.
The app crate
Here is Cargo.toml:
[package]
name = "app"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "step_04"
path = "src/main.rs"
[dependencies]
application = { path = "../application" }
adapter_console = { path = "../adapter_console" }
The src/main.rs is now very short. Indeed the run_greeting_loop() function call in now a method that belongs to a GreetingService structure (hosted in the application crate).
use adapter_console::{ConsoleInput, ConsoleOutput};
use application::GreetingService;
mod error;
use error::Result;
fn main() -> Result<()> {
println!("=== Greeting Service (Step 04 - 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)?;
Ok(())
}
Points of attention:
- Above, note the
mod error;and theuse error::Result;
Find below the error.rs file. At this point, no matter the crate, ALL the error.rs files look the same:
pub type Error = Box<dyn std::error::Error>;
pub type Result<T> = std::result::Result<T, Error>;
The integration_tests crate
Here is Cargo.toml:
[package]
[package]
name = "integration_tests"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
domain = { path = "../domain" }
application = { path = "../application" }
adapter_console = { path = "../adapter_console" }
This crate is really a place holder for the tests. Indeed it does contains any code which is executed at runtime. Just tests. This is why src/lib.rs is empty.
use application::GreetingService;
use domain::{GreetingWriter, NameReader, error::Result};
struct MockNameReader {
names: Vec<String>,
index: std::cell::Cell<usize>,
}
impl MockNameReader {
fn new(names: Vec<&str>) -> Self {
Self {
names: names.into_iter().map(String::from).collect(),
index: std::cell::Cell::new(0),
}
}
}
impl NameReader for MockNameReader {
fn read_name(&self) -> Result<String> {
let idx = self.index.get();
if idx < self.names.len() {
self.index.set(idx + 1);
Ok(self.names[idx].clone())
} else {
Ok("quit".to_owned())
}
}
}
struct MockGreetingWriter {
greetings: std::cell::RefCell<Vec<String>>,
}
impl MockGreetingWriter {
fn new() -> Self {
Self {
greetings: std::cell::RefCell::new(Vec::new()),
}
}
fn greetings(&self) -> Vec<String> {
self.greetings.borrow().clone()
}
}
impl GreetingWriter for MockGreetingWriter {
fn write_greeting(&self, greeting: &str) -> Result<()> {
self.greetings.borrow_mut().push(greeting.to_owned());
Ok(())
}
}
#[test]
fn domain_greet_function() {
// Arrange
let reader = MockNameReader::new(vec!["Alice", "Bob", "quit"]);
// let reader = MockNameReader::new(vec!["Alice", "Bob"]);
let writer = MockGreetingWriter::new();
let service = GreetingService::new();
// Act
let result = service.run_greeting_loop(&reader, &writer);
// Assert
assert!(result.is_ok());
let greetings = writer.greetings();
assert_eq!(greetings.len(), 2);
assert!(greetings[0].contains("Alice"));
assert!(greetings[1].contains("Bob"));
}
// Other tests follow here
Points of attention:
- Note that
error::Resultis provided bydomaincrate. Indeed in the implementations of the mock reader (respectively the mock writer), the functionread_name()(write_greeting) returns adomain::Result<()>. If tomorrow we change the kind of error in thedomaincrate, withthiserrorfor example, then theintegration_testscrate will NOT be impacted which a good thing.
Points of attention:
- Why above, in
MockNameReader,indexisstd::cell::Cell<usize>? NameReadertrait declaresfn read_name(&self), a shared, immutable reference.- Inside
read_name, we need to incrementindexto return the next name on each call. But&selfforbids mutating struct fields directly. Ifindexis a plainusize, the compiler rejectsself.index += 1because you don’t have&mut self. Trust me, I tried. Cell<usize>provides interior mutability. This allows us to modify a value behind a&selfreference in a safe way (for Copy types likeusize).- The same logic applies to
greetings: RefCell<Vec<String>>in the writer. Indeed aVec<String>does not have the Copy trait, so it needsRefCellinstead ofCell.
Points of attention:
- The test must run without any adapter. This is why we create reader and writer mockup
- Since the loop run until it read
quit(orexit) the reader own a vector of words. - In the implementation we fasten our seat belt and if we reach the end of the vector then we simulate the reading of the word
quit. - Again, because of the loop, the mock writer have a vector of greetings which will be analyzed in the tests.
- Here, only one test is shown. As in
main.rswe create areader, awriter, aGreetingServiceand invoke the.run_greeting_loop()method - Thanks to the mockup we don’t have to wait the availability of real adapters to test the behavior of the overall application.
The application crate
Here is Cargo.toml:
[package]
[package]
name = "application"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
domain = { path = "../domain" }
application/src/error.rs is the same while application/src/lib.rs is minimal:
pub mod error;
pub mod greeting_service;
pub use greeting_service::GreetingService;
The code of application/src/greeting_service.rs is almost a copy paste from the run_greeting_loop() of step_04.
use crate::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()?;
if name.eq_ignore_ascii_case("quit") || name.eq_ignore_ascii_case("exit") {
println!("\nGoodbye!");
break;
}
if name.is_empty() {
continue;
}
match domain::greet(&name) {
Ok(greeting) => {
output.write_greeting(&greeting)?;
}
Err(e) => {
eprintln!("Error: {}\n", e);
}
}
println!(); // Extra newline for readability
}
Ok(())
}
}
impl Default for GreetingService {
fn default() -> Self {
Self::new()
}
}
Points of attention:
- Make sure to understand the line
use crate::error::Result; - Did you notice the
&selfas a first parameter of.run_greeting_loop()?
The domain crate
Here is Cargo.toml:
[package]
name = "domain"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
Here is domain/src/lib.rs:
pub mod error;
pub mod greeting;
pub mod ports;
pub use greeting::greet;
pub use ports::{GreetingWriter, NameReader};
error.rs, domain/src/ports.rs do not change. domain/src/greeting.rs remains unchanged. Yes, regarding the latter, the name of the file has changed but the signature of the greet() function is the same.
The adapter_console crate
Here is Cargo.toml:
[package]
name = "adapter_console"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
domain = { path = "../domain" }
Here is adapter_console/src/lib.rs:
// lib.rs
pub mod error;
pub mod input;
pub mod output;
pub use input::ConsoleInput;
pub use output::ConsoleOutput;
The previous adapters/ folder is renamed adapter_console and the crate contains both, input and output console adapters modules in the same crate. Below input.rs and output.rs contain respectively a large part of console_input.rs dans console.output.rs from the previous project step_03.
// output.rs
use crate::error::Result;
use domain::GreetingWriter;
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(())
}
}
// input.rs
use std::io::{self, Write};
use domain::NameReader;
use crate::error::Result;
pub struct ConsoleInput;
impl ConsoleInput {
pub fn new() -> Self {
Self
}
}
impl Default for ConsoleInput {
fn default() -> Self {
Self::new()
}
}
impl 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)
}
}
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
Find below 2 examples of expected outputs:
cargo test -p integration_tests
Compiling application v0.1.0 (C:\Users\phili\OneDrive\Documents\Programmation\rust\01_xp\046_modular_monolith\step_05\crates\application)
Compiling integration_tests v0.1.0 (C:\Users\phili\OneDrive\Documents\Programmation\rust\01_xp\046_modular_monolith\step_05\crates\integration_tests)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.86s
Running unittests src\lib.rs (C:/Users/phili/rust_builds/Documents/Programmation/rust/01_xp/046_modular_monolith/step_05\debug\deps\integration_tests-d9b20696f8963066.exe)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running tests\integration_test.rs (C:/Users/phili/rust_builds/Documents/Programmation/rust/01_xp/046_modular_monolith/step_05\debug\deps\integration_test-92fdd84cf9e6dd41.exe)
running 5 tests
test complete_flow_long_name ... ok
test service_with_mocks ... ok
test domain_greet_function ... ok
test complete_flow_normal_greeting ... ok
test empty_name_error_handling ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
Doc-tests integration_tests
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
cargo run
Compiling domain v0.1.0 (C:\Users\phili\OneDrive\Documents\Programmation\rust\01_xp\046_modular_monolith\step_04\crates\domain)
Compiling adapter_console v0.1.0 (C:\Users\phili\OneDrive\Documents\Programmation\rust\01_xp\046_modular_monolith\step_04\crates\adapter_console)
Compiling application v0.1.0 (C:\Users\phili\OneDrive\Documents\Programmation\rust\01_xp\046_modular_monolith\step_04\crates\application)
Compiling integration_tests v0.1.0 (C:\Users\phili\OneDrive\Documents\Programmation\rust\01_xp\046_modular_monolith\step_04\crates\integration_tests)
Compiling app v0.1.0 (C:\Users\phili\OneDrive\Documents\Programmation\rust\01_xp\046_modular_monolith\step_04\crates\app)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.04s
Running `C:/Users/phili/rust_builds/Documents/Programmation/rust/01_xp/046_modular_monolith/step_04\debug\step_04.exe`
=== Greeting Service (Step 04 - Modular Monolith & Hexagonal Architecture) ===
Enter a name to greet (or 'quit' to exit):
> James HOLDEN
Hello James HOLDEN.
> quit
Goodbye!
Summary
What have we done so far?
- Every component is now in its own crate
- Each crate has its own
ResultandError- Each crate has its own set of tests
- Development and testing can be done independently, per crate, in parallel, at different speed, by different teams…
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