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.toml defines a workspace with multiple independent crates
  • Each component can be developed and tested independently
  • As we will see, the app crate has a [[bin]] section in its Cargo.toml, enabling cargo run (in addition to cargo 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 Error and Result<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.rs file
  • As for now, all these error.rs files look the same but tomorrow, one crate may start using thiserror while 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 the use 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::Result is provided by domain crate. Indeed in the implementations of the mock reader (respectively the mock writer), the function read_name() (write_greeting) returns a domain::Result<()>. If tomorrow we change the kind of error in the domain crate, with thiserror for example, then the integration_tests crate will NOT be impacted which a good thing.

Points of attention:

  • Why above, in MockNameReader, index is std::cell::Cell<usize>?
  • NameReader trait declares fn read_name(&self), a shared, immutable reference.
  • Inside read_name, we need to increment index to return the next name on each call. But &self forbids mutating struct fields directly. If index is a plain usize, the compiler rejects self.index += 1 because you don’t have &mut self. Trust me, I tried.
  • Cell<usize> provides interior mutability. This allows us to modify a value behind a &self reference in a safe way (for Copy types like usize).
  • The same logic applies to greetings: RefCell<Vec<String>> in the writer. Indeed a Vec<String> does not have the Copy trait, so it needs RefCell instead of Cell.

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 (or exit) 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.rs we create a reader, a writer, a GreetingService and 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 &self as 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 Result and Error
  • 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

Back to top

Published on: Jan 29 2026 at 03:00 PM | Last updated: Feb 3 2026 at 08:00 AM

Copyright © 1964-2026 - 40tude