Macro's Hexagonal Architecture Pattern in Rust Services: A Complete Technical Deep Dive
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 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, notification/src/domain/ports.rs |
| Inbound | Driving adapters: HTTP handlers, background workers, queue consumers | src/inbound/ |
notification/src/inbound/http/mod.rs, notification/src/inbound/worker.rs |
| Outbound | Driven adapters: concrete implementations of domain ports | src/outbound/ |
notification/src/outbound/repository.rs, 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, the NotificationRepository and PushNotifier traits define contracts for persistence and delivery:
// 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, 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 demonstrates this pattern:
// 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 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 receives Axum requests and delegates to the domain service:
// 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, 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—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 demonstrates standard wiring:
// 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
MockNotificationRepositoryandMockPushNotifierimplementing 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 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 nosqlx,aws-sdk, orreqwestdependencies
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) 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 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. 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.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →