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 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
- Episode 07: 🟢 Bonus

Table of Contents
Objective
We want to have one crate per component and, among others, an application crate 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
If we were in the Alps, Episode 04 would be rated as a Blue run, and that’s not for nothing. So take a step back, breathe, and look at the folder hierarchy we’re about to build. In Step 03, life was easy: most files lived in the same src/ folder, we had one subfolder for the console adapter, and everything compiled as a single crate. Convenient, sure, but not sustainable in the long run.
Think about it. As the application grows, we’d like to compile components independently: if I’m working on adapter_console, I shouldn’t have to recompile the domain every time. Same thing for tests: running cargo test -p domain and knowing that only domain tests are executed is a real productivity win. And there’s one more reason: promoting modules to crates makes it trivial to reuse them in other projects, or even publish them on crates.io someday.
Creating crates is not difficult per se. We need to be accurate with dependencies, visibility of types and methods, and the wiring in Cargo.toml. Think of it as a game. A game where we’re putting the plumbing in place to connect every room of the house. It’s not Mario Kart. Just pipes.
The figure below illustrates the build process we want to have at the end of this episode. On the left-hand side, we have the ingredients: app, whose target is a binary ([[bin]]), and then domain, adapter_console, and application, which are all Rust libraries (.rlib). The dashed arrows show the dependencies between components: app depends on application and adapter_console, while both application and adapter_console depend on domain. And domain? It depends on no one. On the right-hand side, the build system combines all these ingredients into a single, standalone executable.

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 we can run them 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 our crates 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 may 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)?;
println!("\nGoodbye!");
Ok(())
}
Points of attention:
- Above, note the
mod error;and theuse error::Result; - The
println!("\nGoodbye!");
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 not contain 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 fact that
NameReadertrait declaresfn read_name(&self), a shared, immutable reference is an error of design - We will talk about it again in Episode 06 and fix the problem in Episode 07.
Points of attention:
- The tests must run without any adapter. This is why we create reader and writer mockups.
- Since the loop run until it read “quit” (or “exit” or “q!”) 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 later 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 mockups 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() we ahd in main.rs in step_03.
use crate::error::Result;
#[derive(Default)]
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")
|| name.eq_ignore_ascii_case("q!")
{
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(())
}
}
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;
#[derive(Default)]
pub struct ConsoleOutput;
impl ConsoleOutput {
pub fn new() -> Self {
Self
}
}
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;
#[derive(Default)]
pub struct ConsoleInput;
impl ConsoleInput {
pub fn new() -> Self {
Self
}
}
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
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_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…
At this point, the organization looks like:

- Our core business, our domain, is in yellow. It depends on no one. It just do its job.
- The application crate run one use case (loop). It feeds the domain with some input data and get some output data in returns.
- 2 ports are imposed by the domain.
- They explain how the domain is willing to read/write data
- As an adapter if you do not conform to the ports you cannot pass/receive data to/from the domain. You’re useless.
- The adapter on the left pass the data from the outside world to the input port
- The second adapter pass the inner data to the outside world through the output port
- We can have more than 6 ports (here we only have 2)
- We can have more than one adapter per port.
If you read the Cargo.toml files of each crate and if you pay attention to the dependencies section you should draw a graph similar to the one below:

appdepends onapplicationandadaptersapplicationdepends ondomainadaptersdepends ondomaindomaindepends on nobody
Next Steps
Next you can read Episode 05 but I’d recommend taking a break because Episode 05 is pretty dense. Nighty-night!
- 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