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

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 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 traits. 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, 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 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:

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, the Email adapter implements the NotificationSender port:

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 demonstrates how inbound adapters consume ports:

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 connects the concrete adapter to the HTTP framework:

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 file codifies hexagonal boundaries by mandating specific module layouts: inbound adapters belong under service/ directories, ports live in domain/ports.rs, and outbound adapters reside in infra/ modules. Individual crates declare their architectural intent explicitly—crates/notification/src/lib.rs describes itself as a "hexagonal architecture-based notification system," while 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 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 conventions and module structure requirements. The style guide mandates that ports live in 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 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.

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 →