# How to Implement a New Hexagonal Architecture Rust Service in the Macro Monorepo

> Learn to implement a Rust service using hexagonal architecture in Macro. Structure your code into domain, inbound, and outbound layers, then integrate with Axum's State pattern in main.rs.

- Repository: [Macro/macro](https://github.com/macro-inc/macro)
- Tags: how-to-guide
- Published: 2026-08-20

---

**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`](https://github.com/macro-inc/macro/blob/main/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`](https://github.com/macro-inc/macro/blob/main/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:

```bash
cd services
mkdir my_new_service
cd my_new_service
cargo init --lib

```

Add the service to the workspace root [`Cargo.toml`](https://github.com/macro-inc/macro/blob/main/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`](https://github.com/macro-inc/macro/blob/main/src/domain/mod.rs):

```rust
/// 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`](https://github.com/macro-inc/macro/blob/main/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】:

```rust
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`](https://github.com/macro-inc/macro/blob/main/src/outbound/sql.rs) to implement the `ItemRepository` port using SQLx:

```rust
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`](https://github.com/macro-inc/macro/blob/main/main.rs)

Compose the layers in [`src/main.rs`](https://github.com/macro-inc/macro/blob/main/src/main.rs):

```rust
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`](https://github.com/macro-inc/macro/blob/main/src/domain/test.rs):

```rust
#[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`](https://github.com/macro-inc/macro/blob/main/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:

```bash
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:

```bash
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`](https://github.com/macro-inc/macro/blob/main/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/`.