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 06
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 add an adapter_file crate so that our application can read names from files and write greetings into files.
If the architecture is correct we should have no or very few modification in the existing code and focus our attention only on the code of the adapter_file.
At the end of this episode, the folder hierarchy should look like this:
step_06/
│ Cargo.toml
│ input.txt
│ output.txt
├───.cargo
│ config.toml
└───crates
├───adapter_console
│ │ Cargo.toml
│ ├───src
│ │ errors.rs
│ │ input.rs
│ │ lib.rs
│ │ output.rs
│ └───tests
│ adapter_console_test.rs
├───adapter_file
│ │ Cargo.toml
│ ├───src
│ │ errors.rs
│ │ input.rs
│ │ lib.rs
│ │ output.rs
│ └───tests
│ adapter_file_test.rs
├───app
│ │ Cargo.toml
│ └───src
│ main.rs
├───application
│ │ Cargo.toml
│ ├───src
│ │ errors.rs
│ │ greeting_service.rs
│ │ lib.rs
│ └───tests
│ application_test.rs
├───domain
│ │ Cargo.toml
│ ├───src
│ │ errors.rs
│ │ greeting.rs
│ │ lib.rs
│ │ ports.rs
│ └───tests
│ domain_test.rs
└───integration_tests
│ Cargo.toml
├───src
│ lib.rs
└───tests
integration_test.rs
Points of attention:
- Obviously there is a new folder, see
adapter_file/whose organization is similar toadapter_console/
Setup
- Save your work
- Quit VSCode
- You should have a terminal open and you should be in the
step_05/folder
cd ..
# make a copy the folder step_05 and name it step_06
Copy-Item ./step_05 ./step_06 -Recurse
cd step_06
code .
Actions
Cargo.toml
[workspace]
members = [
"crates/domain",
"crates/application",
"crates/adapter_console",
"crates/adapter_file",
"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:
- We need to take into account
crates/adapter_file
The application/src/greeting_service.rs file
Is not modified.
The app/src/main.rs file
Let’s see how to use the new adapter:
use adapter_console::{ConsoleInput, ConsoleOutput};
use adapter_file::{FileInput, FileOutput};
use application::GreetingService;
use anyhow::{Context, Result};
fn main() -> Result<()> {
println!("=== Greeting Service (Step 06 - File Adapter Demo) ===");
// Dependency injection: Create file-based adapters
// let output = ConsoleOutput::new();
// let input = ConsoleInput::new();
let output = FileOutput::new("output.txt");
let input = match FileInput::new("input.txt") {
Ok(input) => input,
Err(e) => {
eprintln!("Failed to read input file: {e}");
return Ok(());
}
};
let service = GreetingService::new();
service
.run_greeting_once(&input, &output)
// .run_greeting_loop(&input, &output)
.context("Failed to run greeting service")?;
Ok(())
}
Points of attention:
- In
main()I commented the creation of the console adapters but not the associatedusestatements at the top of the code. - This will help us during our tests if we want to mix the adapters (read from a file, write to the terminal for example).
- When
inputis created, ifinput.txtfile does not exists we must handle the error. - I don’t like the way it is done here. I would prefer to simply write
let input = FileInput::new("input.txt");to keep the creation of adapters homogenous. Stop grumbling, a solution exits (see Episode 07).
The adapter_file crate
First, copy/paste/rename the adapter_console folder.
The Cargo.toml file does not change.
In lib.rs the last 2 lines change and the file looks like that:
pub mod errors;
pub mod input;
pub mod output;
pub use input::FileInput;
pub use output::FileOutput;
The errors.rs looks like this:
use domain::InfraError;
use std::any::Any;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum FileError {
#[error("File I/O error: {0}")]
Io(#[from] std::io::Error),
}
impl InfraError for FileError {
fn as_any(&self) -> &dyn Any {
self
}
}
pub(crate) fn into_infra(e: impl Into<FileError>) -> Box<dyn InfraError> {
Box::new(e.into())
}
Points of attention:
ConsoleErrorchanges inFileError
The output.rs file
use crate::errors::into_infra;
use domain::{GreetingWriter, InfraError};
use std::path::PathBuf;
pub struct FileOutput {
path: PathBuf,
}
impl FileOutput {
pub fn new(path: impl Into<PathBuf>) -> Self {
let path = path.into();
let _ = std::fs::remove_file(&path);
Self { path }
}
}
impl GreetingWriter for FileOutput {
fn write_greeting(&self, greeting: &str) -> Result<(), Box<dyn InfraError>> {
std::fs::write(&self.path, format!("{greeting}\n")).map_err(into_infra)?;
Ok(())
}
}
Points of attention:
ConsoleOutputis replaced byFileOutput- Note that any existing file is deleted when the object is created (see the
::remove_file()in.new())
The input.rs file:
use crate::errors::FileError;
use domain::{InfraError, NameReader};
use std::fs;
use std::path::PathBuf;
#[derive(Debug)]
pub struct FileInput {
name: String,
}
impl FileInput {
pub fn new(path: impl Into<PathBuf>) -> Result<Self, FileError> {
let path = path.into();
let content = fs::read_to_string(&path).map_err(FileError::from)?;
let name = content
.lines()
.next()
.unwrap_or_default()
.trim()
.to_string();
Ok(Self { name })
}
}
impl NameReader for FileInput {
fn read_name(&self) -> Result<String, Box<dyn InfraError>> {
Ok(self.name.clone())
}
}
Points of attention:
ConsoleInputis replaced byFileInput- In this version the content of the input file is loaded when the adapter is created and
nameis initialized with the content of the first line. - If the input file does not exist then an error is reported
- When
read_name()is called we simply returnname’s value.
TODO:
In a next version:
- Calling
FileInput::new("input.txt")should be similar to callingConsoleOutput::new() - We should be able to read more than one name in the input file and write more than one greeting in the output file.
- To do so we will need to modify
FileInputso that it loads the file on the first read, reports error if needed and behaves like an iterator on each reading. - Internally this requires a
vector<String>where the names are stored and anindexwhich is incremented on each read. - This means that FileInput object created in
main()MUST be mutable (which is not the case currently, check the signature). - IMPORTANT: Lesson learn: We should mimic the API of the standard library. For example, if we want
read_name()to behave likeIterator::next()it should have the same signature :read_name(&mut self) -> Result<String, Box<dyn InfraError>>and notfn read_name(&self) -> Result<String, Box<dyn InfraError>>. Don’t trust me and double check the signature of Iterator::next() for example. - Why is that? Simply because in
read_name(), if I want to increment theindexI mutate the object. If I don’t have&mut self, things become complicated withRefCelletc. - If you have any doubt about the mutability of the bindings read this page.
Build, run & test
Create an input.txt file at the root of the project. Here is an example with one empty line in the middle:
Buck
Build, run and test the application. Find below the expected output:
cargo run
warning: unused imports: `ConsoleInput` and `ConsoleOutput`
--> crates\app\src\main.rs:3:23
|
3 | use adapter_console::{ConsoleInput, ConsoleOutput};
| ^^^^^^^^^^^^ ^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
warning: `app` (bin "step_06") generated 1 warning (run `cargo fix --bin "step_06" -p app` to apply 1 suggestion)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
Running `C:/Users/phili/rust_builds/Documents/Programmation/rust/01_xp/046_modular_monolith/step_06\debug\step_06.exe`
=== Greeting Service (Step 06 - File Adapter Demo) ===
Goodbye!
Points of attention:
- Do not worry about the warnings. This is simply because we don’t use
ConsoleInputnorConsoleOutput
A new output.txt file is created. Here is its content
Hello Buck.
Points of attention:
- Only one line
- Modify
input.txtfile with 2 lines (Roberto and Buck for example). The newoutput.txtfile will have one line again.
We can “play” with app/src/main.rs and uncomment/comment the adapters we want to mix. For example, reading from a file and writing in the terminal. For example, with this setup in main.rs:
let output = ConsoleOutput::new();
// let input = ConsoleInput::new();
// let output = FileOutput::new("output.txt");
let input = match FileInput::new("input.txt") {
Ok(input) => input,
Err(e) => {
eprintln!("Failed to read input file: {e}");
return Ok(());
}
};
I get this output on the screen:
cargo run
warning: unused import: `ConsoleInput`
--> crates\app\src\main.rs:3:23
|
3 | use adapter_console::{ConsoleInput, ConsoleOutput};
| ^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
warning: unused import: `FileOutput`
--> crates\app\src\main.rs:4:31
|
4 | use adapter_file::{FileInput, FileOutput};
| ^^^^^^^^^^
warning: `app` (bin "step_06") generated 2 warnings (run `cargo fix --bin "step_06" -p app` to apply 2 suggestions)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
Running `C:/Users/phili/rust_builds/Documents/Programmation/rust/01_xp/046_modular_monolith/step_06\debug\step_06.exe`
=== Greeting Service (Step 06 - File Adapter Demo) ===
Hello Buck.
Goodbye!
Summary
What have we done so far?
- Adding a new adapter is easy and we are able to focus mostly on implementing the methods of the trait. This can be done (and tested) by someone else independently.
- Yes, we took the new adapter into account in
main.rsand inCargo.tomlbut that’s all.- Tomorrow we can write
adapter_tcp,adapter_sqlusing the same process.

Conclusion of the Series
Let’s take a step back and look at what just happened.
We started this series with everything in a single main.rs: business logic, I/O, error handling, tests… Seven steps later, we have a Cargo workspace with independent crates where the domain doesn’t know (and doesn’t care) whether it’s talking to a console, a file, or anything else. And we just proved it: adding adapter_file required zero changes to the domain, zero changes to the application layer, and a couple of lines in main.rs. That’s the whole promise of hexagonal architecture, delivered.
But here’s what I really want us to take away from this series.
The architecture is not the goal
The goal is to write software that is easy to change. The hexagonal architecture, the ports, the adapters, the crates are just tools to get there. If we find ourself spending more time drawing diagrams than writing code, something has gone wrong. The risk, especially when we discover these patterns for the first time, is to “conceptualize the concept” and never actually ship features. We should’nt fall into that trap.
My advice: start simple but start. Write code that works. Then look at it and ask yourself: “If I need to change the way I read input tomorrow, how much code do I have to touch?” If the answer is “everything,” it’s time to refactor. If the answer is “just one adapter,” you’re in good shape. Here SOLID principles can help.
A “Hello World”, really?
Yes, really. And that was the point. I deliberately used the most trivial business logic imaginable so that the architecture itself could be the focus. In a real project, replace greet() with your actual domain: pricing calculations, sensor data processing, booking workflows, whatever… The structure holds. The domain crate gets bigger, we might add more ports, more adapters, but the shape of the application remains the same.
And if one day we realize that adapter_console needs to become a gRPC service running on its own server? We already have a crate with clean boundaries and a well-defined trait interface. We can extract it, put it behind a network call and the rest of the application doesn’t even blink. That’s the “best of both worlds” we talked about in the introduction: start as a monolith, scale out only when and if, we need to.
When NOT to use this architecture ?
Typically, any kind of “Hello Word” kind of application. A small 200-line utility CLI does not need 6 crates. We are not working ESA nor NASA… To be clear: if the project fits in one file, leave it in one file.”
What Rust brings to the table
We could have done this in any language. But Rust makes some of these patterns feel remarkably natural. Traits are ports. Crates are module boundaries with enforced visibility. The compiler won’t let us accidentally depend on something we shouldn’t. Where in other languages we would need discipline and code reviews to enforce architectural boundaries, in Rust (like in C++) the compiler does it for us. For free. Every time. It’s like having an extremely picky but always-right architect sitting next to us.
One last thing
If you’ve followed along and built each step yourself (not just read the code, but actually typed it, ran cargo test, fixed the errors) then you should have a mental model that will serve you well far beyond this “Hello World” app. The next time you start a project, you’ll instinctively think about where the boundaries should be. And that’s worth more than any architecture book.
Now go build something real. And if you have time, check out the Bonus episode where we improve adapter_file to handle multiple names (there’s always one more thing to tweak).
Webliography

- The original paper about the Hexagonal Architecture from 2025.
- Read Clean Architecture
- SOLID principles in Rust: A Practical Guide
- Hexagonal Architecture in Rust: A Beginner’s Guide
- Read Growing Object-Oriented Software, Guided by Tests (still on my TODO list)
- The Rust Design Patterns Book
- Read Pragmatic programmer. It cannot hurt.
- (beginners) Would you like to check your knowledge with some flashcards? Works fine on cell phones. It is hosted for free on Heroku and may be slow to startup.
- You’re welcome to share comments or suggestions on GitHub to help improve this article.
Next Steps
Next you can read Episode 07 and you should read it now.
- 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