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 State per 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →