How to Implement a New Hexagonal Architecture Rust Service in the Macro Monorepo
To implement a new hexagonal architecture Rust service in Macro, create three layers—domain (pure business logic), inbound adapters (HTTP handlers), and outbound adapters (database implementations)—then wire them together in main.rs using Axum's State pattern.
All Macro backend services follow the ports-and-adapters (hexagonal) pattern. This architecture isolates business logic from external concerns, making services testable and maintainable. The approach is documented in the Search Processing Service README and enforced by the repository's Style Guide.
Hexagonal Architecture Layers in Macro
Macro services split cleanly into three layers:
| Layer | Responsibility | Location |
|---|---|---|
| Domain | Pure business logic, models, and port traits | services/<new-service>/src/domain/ |
| Inbound adapters | HTTP handlers, Kafka consumers, other transport code | services/<new-service>/src/api/ or src/inbound/ |
| Outbound adapters | Port implementations (SQLx, S3, external services) | services/<new-service>/src/outbound/ |
The Search Processing Service demonstrates this layout with src/domain, src/outbound, and inbound entry points under src/api/internal and src/inbound/kafka_consumer.rs【/cache/repos/github.com/macro-inc/macro/main/services/search_processing_service/README.md#architecture】.
Step 1: Create the Service Directory and Cargo Manifest
Start in the services/ directory and initialize a new library crate:
cd services
mkdir my_new_service
cd my_new_service
cargo init --lib
Add the service to the workspace root Cargo.toml, following the pattern used for existing services like document_storage_service【/cache/repos/github.com/macro-inc/macro/main/Cargo.toml】.
Step 2: Scaffold the Domain Layer
The domain layer contains only pure Rust code—no Axum, SQLx, or AWS types. Create src/domain/mod.rs:
/// Business models
pub mod models {
#[derive(Debug, Clone)]
pub struct Item {
pub id: uuid::Uuid,
pub name: String,
}
}
/// Ports that the domain needs
pub mod ports {
use super::models::Item;
#[async_trait::async_trait]
pub trait ItemRepository: Send + Sync {
async fn get(&self, id: uuid::Uuid) -> anyhow::Result<Option<Item>>;
async fn save(&self, item: Item) -> anyhow::Result<()>;
}
}
/// Service orchestrator – pure Rust, no external deps
pub mod service {
use super::ports::ItemRepository;
use super::models::Item;
#[derive(Clone)]
pub struct ItemService<R: ItemRepository> {
repo: R,
}
impl<R: ItemRepository> ItemService<R> {
pub fn new(repo: R) -> Self { Self { repo } }
pub async fn create_item(&self, name: String) -> anyhow::Result<Item> {
let item = Item { id: uuid::Uuid::new_v4(), name };
self.repo.save(item.clone()).await?;
Ok(item)
}
pub async fn get_item(&self, id: uuid::Uuid) -> anyhow::Result<Option<Item>> {
self.repo.get(id).await
}
}
}
The ItemRepository trait is the critical abstraction. It describes what the domain needs, not how it's implemented.
Step 3: Add Inbound Adapters (HTTP)
Create src/api/item.rs for Axum handlers. Per CS-30 in the Style Guide, handlers must receive services via State, not Extension【/cache/repos/github.com/macro-inc/macro/main/docs/STYLE_GUIDE.md#CS-30】:
use axum::{
extract::{Path, State},
Json,
};
use uuid::Uuid;
use crate::domain::service::ItemService;
pub async fn create_item(
State(service): State<ItemService<impl crate::outbound::ItemRepository>>,
Json(payload): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, axum::http::StatusCode> {
let name = payload["name"]
.as_str()
.ok_or(axum::http::StatusCode::BAD_REQUEST)?;
let item = service.create_item(name.to_string()).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(serde_json::json!({ "id": item.id, "name": item.name })))
}
pub async fn get_item(
State(service): State<ItemService<impl crate::outbound::ItemRepository>>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, axum::http::StatusCode> {
let item = service.get_item(id).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
match item {
Some(i) => Ok(Json(serde_json::json!({ "id": i.id, "name": i.name }))),
None => Err(axum::http::StatusCode::NOT_FOUND),
}
}
Handlers contain no direct database or S3 calls—they delegate entirely to the domain orchestrator.
Step 4: Implement Outbound Adapters
Create src/outbound/sql.rs to implement the ItemRepository port using SQLx:
use async_trait::async_trait;
use crate::domain::ports::ItemRepository;
use crate::domain::models::Item;
use sqlx::PgPool;
#[derive(Clone)]
pub struct SqlItemRepo {
pool: PgPool,
}
impl SqlItemRepo {
pub fn new(pool: PgPool) -> Self { Self { pool } }
}
#[async_trait]
impl ItemRepository for SqlItemRepo {
async fn get(&self, id: uuid::Uuid) -> anyhow::Result<Option<Item>> {
let rec = sqlx::query_as!(
Item,
r#"SELECT id, name FROM items WHERE id = $1"#,
id
)
.fetch_optional(&self.pool)
.await?;
Ok(rec)
}
async fn save(&self, item: Item) -> anyhow::Result<()> {
sqlx::query!(
r#"INSERT INTO items (id, name) VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name"#,
item.id,
item.name
)
.execute(&self.pool)
.await?;
Ok(())
}
}
The SqlItemRepo struct implements the domain's ItemRepository trait, keeping SQLx details isolated from business logic.
Step 5: Wire Everything Together in main.rs
Compose the layers in src/main.rs:
use axum::{routing::post, Router};
use macro_env_var::macro_env_var;
use macro_entrypoint::entrypoint;
use crate::domain::service::ItemService;
use crate::outbound::SqlItemRepo;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1. Load config / env vars (see CS-14 / CS-15)
let db_url = macro_env_var!("DATABASE_URL");
let pool = sqlx::PgPool::connect(&db_url).await?;
// 2. Build outbound implementation
let repo = SqlItemRepo::new(pool);
// 3. Build domain service
let item_service = ItemService::new(repo);
// 4. Build Axum router using State
let app = Router::new()
.route("/items", post(crate::api::item::create_item))
.route("/items/:id", axum::routing::get(crate::api::item::get_item))
.with_state(item_service);
// 5. Run the HTTP server
entrypoint(app).await
}
The composition order matters: outbound adapter → domain service → inbound adapter. This dependency graph enforces the hexagonal boundary.
Step 6: Write Unit and Integration Tests
Domain tests use in-memory repository implementations. Create src/domain/test.rs:
#[cfg(test)]
mod test {
use super::service::ItemService;
use super::ports::ItemRepository;
use async_trait::async_trait;
use uuid::Uuid;
struct InMemoryRepo;
#[async_trait]
impl ItemRepository for InMemoryRepo {
async fn get(&self, _: Uuid) -> anyhow::Result<Option<super::models::Item>> { Ok(None) }
async fn save(&self, _: super::models::Item) -> anyhow::Result<()> { Ok(()) }
}
#[tokio::test]
async fn creates_item() {
let svc = ItemService::new(InMemoryRepo);
let item = svc.create_item("test".into()).await.unwrap();
assert_eq!(item.name, "test");
}
}
Per CS-49 in the Style Guide, integration tests for HTTP handlers belong in sibling test.rs files adjacent to the code they exercise【/cache/repos/github.com/macro-inc/macro/main/docs/STYLE_GUIDE.md#CS-49】.
Step 7: Update Workspace and Prepare Database
If you added SQL queries, create migrations under crates/<service>_db_client/migrations/ and run:
just prepare_db
This updates the .sqlx cache for compile-time query checking (CS-08/09).
Step 8: Verify Build and Tests
Run the repository's standard checks:
just check # cargo fmt + clippy + type-check
cargo test # all unit & integration tests
Summary
- Domain layer contains pure business logic and trait-based ports—no external dependencies
- Inbound adapters (Axum handlers) receive the domain service via
Stateper CS-30 - Outbound adapters implement domain ports with concrete infrastructure (SQLx, S3, etc.)
- Composition happens in
main.rs, wiring concrete implementations to abstractions - Testing uses in-memory repositories for domain logic, full HTTP stack for integration
Frequently Asked Questions
What is the hexagonal architecture pattern used in Macro?
Hexagonal architecture (ports-and-adapters) separates core business logic from external concerns through well-defined interfaces called ports. The domain depends only on these abstractions; concrete implementations (adapters) are injected at the application boundary. This allows swapping PostgreSQL for DynamoDB or HTTP for gRPC without touching business logic.
Why must Axum handlers use State instead of Extension?
The Macro Style Guide CS-30 mandates State for dependency injection because it provides compile-time type safety and clearer dependencies【/cache/repos/github.com/macro-inc/macro/main/docs/STYLE_GUIDE.md#CS-30】. Extension uses runtime type maps that can fail silently if misconfigured.
How do database migrations work in new Macro services?
SQLx queries are validated at compile time against a query cache. Running just prepare_db updates this cache after you add migrations. Each service that needs database access typically has a corresponding *_db_client crate under crates/ with its own migration directory.
Can I use a different web framework instead of Axum?
The Macro monorepo standardizes on Axum for all HTTP services. While the hexagonal pattern theoretically permits swapping frameworks, doing so would break consistency with existing services and CI/CD pipelines. Non-HTTP transports (Kafka, SQS) are supported through additional inbound adapters in src/inbound/.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →