# Hexagonal Architecture Patterns in Macro’s Rust Services: A Complete Guide

> Explore hexagonal architecture patterns in Macro's Rust services. Learn how three layers isolate business logic from I/O for robust, maintainable code.

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

---

**Macro’s Rust services implement strict ports-and-adapters (hexagonal) architecture through three distinct layers—inbound adapters, a domain core with traits as ports, and outbound adapters—ensuring business logic remains isolated from I/O concerns.**

The `macro-inc/macro` repository demonstrates production-grade hexagonal architecture patterns in Rust, organizing backend services into strictly separated layers that protect domain logic from external dependencies. By treating Rust traits as ports and concrete structs as adapters, the codebase achieves high testability and clear architectural boundaries between infrastructure concerns and business rules.

## Core Principles of Macro’s Hexagonal Implementation

### The Three-Layer Separation

As documented in the repository’s top-level [`README.md`](https://github.com/macro-inc/macro/blob/main/README.md) at lines 26-27, every service follows a hexagonal layout consisting of:

1. **Inbound adapters** – Entry points that receive external requests via HTTP APIs (Axum), WebSocket listeners, or SQS/Lambda handlers.
2. **Domain core** – The pure business-logic layer containing use-case services and domain models, completely independent of I/O concerns.
3. **Outbound adapters** – Concrete implementations of the ports defined by the domain core, such as database clients, S3 storage, Redis caches, and external API clients.

### Ports as Rust Traits

The domain core connects to the outside world exclusively through **ports**, which are declared as Rust `trait`s. Inbound adapters call domain services via these traits, while outbound adapters implement them. This abstraction allows the core to remain testable without mocking entire HTTP servers or databases.

## Inbound Adapters: Converting External Protocols

Inbound adapters translate external protocols into domain operations. According to the [`docs/STYLE_GUIDE.md`](https://github.com/macro-inc/macro/blob/main/docs/STYLE_GUIDE.md), these adapters reside in `service/` modules and handle protocol-specific concerns like JSON deserialization and status code mapping before invoking pure domain functions.

Typical implementations include:

- **Axum handlers** for REST API endpoints
- **Lambda entry points** for serverless functions
- **SQS consumers** for asynchronous message processing

These components depend on the domain ports rather than concrete infrastructure, allowing the same business logic to serve multiple protocols.

## Domain Core: Isolated Business Logic

At the center of the hexagon, the domain layer holds business rules, aggregates, and service functions. This layer declares abstract interfaces in [`domain/ports.rs`](https://github.com/macro-inc/macro/blob/main/domain/ports.rs) files, ensuring the core knows *what* operations are possible without knowing *how* they execute.

For example, the notification crate defines a port for sending notifications in [`crates/notification/src/domain/ports.rs`](https://github.com/macro-inc/macro/blob/main/crates/notification/src/domain/ports.rs):

```rust
pub trait NotificationSender {
    /// Sends a notification to the appropriate channel.
    fn send(&self, payload: NotificationPayload) -> Result<(), NotificationError>;
}

```

The domain core remains completely synchronous and free of external crate dependencies like `tokio` or `aws_sdk`, focusing solely on business invariants and use-case orchestration.

## Outbound Adapters: Concrete Infrastructure

Outbound adapters provide the concrete I/O implementations that satisfy the domain's trait requirements. These structs encapsulate database connections, HTTP clients, or cloud service SDKs, transforming domain requests into external API calls.

In [`crates/notification/src/infra/email_sender.rs`](https://github.com/macro-inc/macro/blob/main/crates/notification/src/infra/email_sender.rs), the Email adapter implements the `NotificationSender` port:

```rust
pub struct EmailSender {
    smtp_client: SmtpClient,
}

impl NotificationSender for EmailSender {
    fn send(&self, payload: NotificationPayload) -> Result<(), NotificationError> {
        self.smtp_client
            .send_email(payload.to_email())
            .map_err(|e| NotificationError::DeliveryFailed(e.into()))
    }
}

```

This pattern allows the infrastructure to change—from SMTP to SendGrid API—without modifying the domain core, as long as the new adapter implements the `NotificationSender` trait.

## Wiring Dependencies in Application Layers

The service startup code resolves dependencies by instantiating outbound adapters and injecting them into inbound handlers. This composition occurs in the main application entry point without leaking infrastructure details into the domain.

An Axum handler in [`services/notification_service/src/service/mod.rs`](https://github.com/macro-inc/macro/blob/main/services/notification_service/src/service/mod.rs) demonstrates how inbound adapters consume ports:

```rust
use super::domain::ports::NotificationSender;

pub async fn handle_notify(
    Json(payload): Json<NotificationPayload>,
    sender: impl NotificationSender,
) -> impl IntoResponse {
    sender.send(payload).map(|_| StatusCode::OK)
}

```

The wire-up in [`services/notification_service/src/main.rs`](https://github.com/macro-inc/macro/blob/main/services/notification_service/src/main.rs) connects the concrete adapter to the HTTP framework:

```rust
use notification::infra::email_sender::EmailSender;
use notification::service::mod::handle_notify;

#[tokio::main]
async fn main() {
    let smtp = SmtpClient::new(/* ... */);
    let email_sender = EmailSender { smtp_client: smtp };

    let app = axum::Router::new()
        .route("/notify", axum::routing::post(handle_notify))
        .layer(axum::AddExtensionLayer::new(email_sender));

    axum::Server::bind(&"0.0.0.0:8080".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

```

## Enforcing Architecture Through Conventions

The [`docs/STYLE_GUIDE.md`](https://github.com/macro-inc/macro/blob/main/docs/STYLE_GUIDE.md) file codifies hexagonal boundaries by mandating specific module layouts: inbound adapters belong under `service/` directories, ports live in [`domain/ports.rs`](https://github.com/macro-inc/macro/blob/main/domain/ports.rs), and outbound adapters reside in `infra/` modules. Individual crates declare their architectural intent explicitly—[`crates/notification/src/lib.rs`](https://github.com/macro-inc/macro/blob/main/crates/notification/src/lib.rs) describes itself as a "hexagonal architecture-based notification system," while [`crates/teams/src/lib.rs`](https://github.com/macro-inc/macro/blob/main/crates/teams/src/lib.rs) identifies as a "Teams hexagonal architecture crate."

## Summary

- **Three-layer separation** ensures inbound adapters, domain core, and outbound adapters remain distinct, with dependencies pointing inward toward the domain.
- **Traits as ports** enable the domain core to define required capabilities without coupling to specific infrastructure implementations.
- **Concrete adapters** in `infra/` directories implement domain traits, wrapping external services like SMTP, PostgreSQL, or AWS S3.
- **Style guide enforcement** through [`docs/STYLE_GUIDE.md`](https://github.com/macro-inc/macro/blob/main/docs/STYLE_GUIDE.md) and module-level documentation maintains architectural consistency across all services.
- **Dependency injection** occurs at the application entry point, allowing the same domain logic to run with different infrastructure in test versus production environments.

## Frequently Asked Questions

### What is hexagonal architecture in Rust?

Hexagonal architecture in Rust organizes code into a central domain core surrounded by adapter layers, using traits as ports to define boundaries. In the Macro repository, this means business logic lives in pure Rust modules without async runtimes or database drivers, while infrastructure-specific code in `infra/` modules implements the domain's trait interfaces.

### How does Macro enforce hexagonal boundaries?

Macro enforces boundaries through the [`docs/STYLE_GUIDE.md`](https://github.com/macro-inc/macro/blob/main/docs/STYLE_GUIDE.md) conventions and module structure requirements. The style guide mandates that ports live in [`domain/ports.rs`](https://github.com/macro-inc/macro/blob/main/domain/ports.rs), inbound adapters reside in `service/` modules, and outbound adapters belong in `infra/` directories. Module-level documentation in crates like `notification` and `teams` explicitly states the hexagonal intent, making architectural violations obvious during code review.

### Why use traits as ports in Rust?

Traits provide zero-cost abstractions that allow the domain core to remain pure while enabling different implementations for testing and production. By depending on `impl NotificationSender` rather than concrete `EmailSender` or `SmsSender` structs, domain code becomes testable with mock implementations without requiring complex dependency injection frameworks.

### How are dependencies wired in Macro’s services?

Dependencies are wired in the service's [`main.rs`](https://github.com/macro-inc/macro/blob/main/main.rs) file using Axum's extension layers or constructor injection. The application bootstrap code instantiates concrete outbound adapters (like `EmailSender`) and passes them to inbound handlers (like `handle_notify`) through Axum's `AddExtensionLayer` or direct parameters, keeping the domain layer completely unaware of the HTTP framework or specific infrastructure.