# Macro's Hexagonal Architecture Pattern in Rust Services: A Complete Technical Deep Dive

> Explore Macro's Rust services and their hexagonal architecture. Learn about the domain, inbound, and outbound crates for clean, testable microservices.

- Repository: [Macro/macro](https://github.com/macro-inc/macro)
- Tags: deep-dive
- Published: 2026-08-16

---

**Macro's Rust microservices implement hexagonal (ports-and-adapters) architecture through a strict three-layer crate structure: `domain/` for pure business logic and traits, `inbound/` for driving adapters (HTTP, workers), and `outbound/` for driven adapters (databases, external APIs).**

The [macro-inc/macro](https://github.com/macro-inc/macro) codebase applies the **hexagonal architecture pattern** consistently across 80+ crate-based services. Each crate—whether `notification`, `teams`, `projects`, or others—follows identical structural conventions that enforce dependency direction inward toward the domain core. This article examines the concrete implementation, file organization, and wiring patterns that make this architecture work in production Rust code.

## Hexagonal Architecture Layers in Macro's Rust Services

Macro's hexagonal architecture organizes each crate into three distinct layers. The terminology aligns with classic ports-and-adapters: **driving adapters** initiate action, **driven adapters** react to domain requests, and **ports** are the abstract interfaces connecting them to the domain.

| Layer | Purpose | Directory Convention | Example Files |
|-------|---------|---------------------|---------------|
| **Domain** | Pure business logic, models, and **port traits** | `src/domain/` | [`notification/src/domain/models.rs`](https://github.com/macro-inc/macro/blob/main/notification/src/domain/models.rs), [`notification/src/domain/ports.rs`](https://github.com/macro-inc/macro/blob/main/notification/src/domain/ports.rs) |
| **Inbound** | Driving adapters: HTTP handlers, background workers, queue consumers | `src/inbound/` | [`notification/src/inbound/http/mod.rs`](https://github.com/macro-inc/macro/blob/main/notification/src/inbound/http/mod.rs), [`notification/src/inbound/worker.rs`](https://github.com/macro-inc/macro/blob/main/notification/src/inbound/worker.rs) |
| **Outbound** | Driven adapters: concrete implementations of domain ports | `src/outbound/` | [`notification/src/outbound/repository.rs`](https://github.com/macro-inc/macro/blob/main/notification/src/outbound/repository.rs), [`notification/src/outbound/kafka_notification_realtime.rs`](https://github.com/macro-inc/macro/blob/main/notification/src/outbound/kafka_notification_realtime.rs) |

This structure appears in every service crate, enabling developers to navigate any service with zero learning friction.

## Defining Domain Ports: The Core Abstraction

The **domain layer** contains no external dependencies. It declares what capabilities the business logic requires through Rust traits, not what infrastructure provides them.

In [`crates/notification/src/domain/ports.rs`](https://github.com/macro-inc/macro/blob/main/crates/notification/src/domain/ports.rs), the `NotificationRepository` and `PushNotifier` traits define contracts for persistence and delivery:

```rust
// crates/notification/src/domain/ports.rs
pub trait NotificationRepository: Send + Sync {
    fn store(&self, event: NotificationEvent) -> Result<()>;
}

pub trait PushNotifier: Send + Sync {
    fn send(&self, payload: PushPayload) -> Result<()>;
}

```

These traits are **pure abstractions**—no `async_trait`, no database types, no HTTP clients. The `Send + Sync` bounds enable thread-safe implementations, but nothing else leaks infrastructure concerns into the domain. The `NotificationEvent` and `PushPayload` types are defined in [`notification/src/domain/models.rs`](https://github.com/macro-inc/macro/blob/main/notification/src/domain/models.rs), keeping all domain vocabulary centralized.

## Implementing Outbound Adapters: Concrete Infrastructure

**Outbound adapters** live in `src/outbound/` and provide concrete implementations of domain port traits. They contain all infrastructure-specific code: SQL queries, AWS SDK calls, Redis operations, third-party API clients.

The PostgreSQL repository implementation in [`notification/src/outbound/repository.rs`](https://github.com/macro-inc/macro/blob/main/notification/src/outbound/repository.rs) demonstrates this pattern:

```rust
// crates/notification/src/outbound/repository.rs
pub struct PostgresNotificationRepository {
    pool: PgPool,
}

#[async_trait::async_trait]
impl NotificationRepository for PostgresNotificationRepository {
    async fn store(&self, event: NotificationEvent) -> Result<()> {
        sqlx::query!("INSERT INTO notifications (id, payload) VALUES ($1, $2)",
                     event.id, event.payload)
            .execute(&self.pool)
            .await?;
        Ok(())
    }
}

```

The `PgPool` from `sqlx` never appears in the domain layer—it is encapsulated entirely within the outbound adapter. Swapping to DynamoDB would require only a new `DynamoNotificationRepository` implementing the same `NotificationRepository` trait; the domain remains untouched.

Other outbound adapters in the same crate include [`kafka_notification_realtime.rs`](https://github.com/macro-inc/macro/blob/main/kafka_notification_realtime.rs) for streaming events, following identical conventions.

## Building Inbound Adapters: HTTP Handlers and Workers

**Inbound adapters** in `src/inbound/` translate external stimuli into domain operations. They depend on the domain but not on outbound implementations—dependency injection provides concrete port implementations at runtime.

The HTTP handler in [`notification/src/inbound/http/preferences.rs`](https://github.com/macro-inc/macro/blob/main/notification/src/inbound/http/preferences.rs) receives Axum requests and delegates to the domain service:

```rust
// crates/notification/src/inbound/http/preferences.rs
#[axum::debug_handler]
async fn set_preferences(
    State(state): State<AppState>,
    Json(req): Json<SetPreferencesRequest>,
) -> Result<StatusCode, AppError> {
    state.notification_service
        .update_preferences(req.user_id, req.prefs)
        .await?;
    Ok(StatusCode::NO_CONTENT)
}

```

The handler performs no business logic—it validates input shape (via Axum's extractor), calls the injected `notification_service`, and maps domain results to HTTP responses. The `AppState` contains the domain service pre-configured with its outbound adapters.

Background workers follow the same pattern. In [`notification/src/inbound/worker.rs`](https://github.com/macro-inc/macro/blob/main/notification/src/inbound/worker.rs), queue consumers transform message payloads into domain calls without knowing which database ultimately stores results.

## Wiring Dependencies: Composition in lib.rs

Each crate exposes a **composition root**—typically in [`src/lib.rs`](https://github.com/macro-inc/macro/blob/main/src/lib.rs)—that constructs the complete adapter chain and injects it into domain services. This is the sole location where concrete types from outbound adapters meet the domain.

The builder function in [`notification/src/lib.rs`](https://github.com/macro-inc/macro/blob/main/notification/src/lib.rs) demonstrates standard wiring:

```rust
// crates/notification/src/lib.rs
pub fn build_service(pool: PgPool) -> NotificationService {
    let repo = PostgresNotificationRepository { pool };
    let notifier = AwsSnsPushNotifier::new();
    NotificationService::new(Box::new(repo), Box::new(notifier))
}

```

The `NotificationService` (domain layer) receives trait objects (`Box<dyn NotificationRepository>`, `Box<dyn PushNotifier>`), enabling runtime polymorphism without domain knowledge of implementing types. For services with many ports, constructor injection scales naturally—each additional port adds one parameter.

## Testability and Adapter Swapping

The hexagonal architecture pattern in Macro's Rust services delivers concrete engineering benefits through strict port abstraction:

- **Unit testing without infrastructure** – Tests inject `MockNotificationRepository` and `MockPushNotifier` implementing the port traits, exercising domain logic with predictable, fast in-memory implementations
- **Production-like integration testing** – Test suites swap outbound adapters for containerized PostgreSQL or localstack AWS services without code changes in domain or inbound layers
- **Infrastructure migration** – The production switch from Postgres to DynamoDB requires only a new outbound adapter and composition root change; zero domain code modifications
- **Parallel development** – Domain engineers define ports and implement business logic while infrastructure engineers build adapters against those contracts

## Pattern Consistency Across the Macro Workspace

The hexagonal architecture implementation is **uniform across all 80+ crates**. The `teams` and `projects` crates replicate the identical `domain/`, `inbound/`, `outbound/` structure, with [`src/lib.rs`](https://github.com/macro-inc/macro/blob/main/src/lib.rs) serving as the composition root.

This consistency enables:
- **Cross-team mobility** – Engineers transfer between services with immediate productivity
- **Architectural enforcement** – Code review requires only verifying directory placement; violations are visually obvious
- **Shared tooling** – Workspace-level lints can enforce that `domain/` contains no `sqlx`, `aws-sdk`, or `reqwest` dependencies

## Summary

Macro's hexagonal architecture pattern in Rust services achieves clean separation through disciplined directory structure and trait-based ports:

- **Domain layer** (`src/domain/`) owns pure business logic and port trait definitions—no external dependencies
- **Inbound adapters** (`src/inbound/`) translate HTTP, workers, and queues into domain operations without infrastructure knowledge
- **Outbound adapters** (`src/outbound/`) provide concrete port implementations with full infrastructure coupling isolated from the core
- **Composition root** ([`src/lib.rs`](https://github.com/macro-inc/macro/blob/main/src/lib.rs)) wires concrete adapters into domain services, the single point where layers connect

This structure maximizes testability by enabling mock implementations, preserves flexibility for infrastructure changes, and maintains consistent organization across the entire Macro workspace.

## Frequently Asked Questions

### How does Macro's hexagonal architecture differ from standard layered architecture?

Standard layered architecture typically allows bidirectional dependencies and places business logic adjacent to data access. Macro's hexagonal architecture enforces **unidirectional dependencies inward**—inbound and outbound layers depend on domain, never the reverse. The domain knows nothing of HTTP routes, SQL, or external APIs; it only knows trait contracts. This inversion of dependencies is the defining characteristic that enables testability and infrastructure swapping.

### Why does Macro use Box<dyn Trait> instead of generics for dependency injection?

`Box<dyn Trait>` enables **erased types** at the composition root, allowing the same domain service struct to work with any port implementation without generic parameters propagating through the entire call stack. This trades a small heap allocation for significantly simpler type signatures. For hot paths where allocation overhead matters, generic implementations could replace boxed traits, but Macro's services prioritize compile-time simplicity and binary size over micro-optimizations of dependency injection.

### Can outbound adapters depend on other outbound adapters?

Yes—outbound adapters may compose infrastructure concerns. For example, a caching repository adapter might wrap another `NotificationRepository` implementation, adding Redis caching transparently to the domain. The domain sees only the outer adapter's trait implementation. This decorator pattern remains within the outbound layer, preserving the domain's ignorance of caching strategy.

### How are domain errors handled across adapter boundaries?

Domain errors are defined as enums in the domain layer, typically in [`src/domain/errors.rs`](https://github.com/macro-inc/macro/blob/main/src/domain/errors.rs). Inbound adapters map these to appropriate HTTP status codes or retry policies; outbound adapters map infrastructure failures (connection timeouts, AWS API errors) into domain error variants. This ensures the domain expresses failures in business terms while adapters handle protocol-specific translation.