Hexagonal Architecture Patterns in Macro's Rust Services: A Complete Implementation Guide
Macro's Rust services implement hexagonal architecture by defining domain-centric port traits in src/domain/ports.rs, providing concrete adapter implementations in src/outbound/ modules, and wiring dependencies at the crate's composition root in src/lib.rs.
The macro-inc/macro repository demonstrates production-grade hexagonal architecture (ports and adapters) across its service crates. This pattern separates pure business logic from infrastructure concerns using Rust's trait system, enabling testable, modular services. The notification crate (crates/notification) serves as the canonical example, with consistent patterns repeated across contacts, documents, and other service crates.
Core Hexagonal Architecture Layers in Macro
Domain Layer: Pure Business Logic
The domain layer contains business rules, data structures, and use-case orchestration. It depends only on abstract port traits, never on concrete implementations.
In crates/notification/src/domain/ports.rs, the domain defines contracts for capabilities it needs:
/// Port used by the domain to send notifications.
pub trait NotificationSender: Send + Sync + 'static {
fn send(&self, notification: Notification) -> Result<(), NotificationError>;
}
/// Port used by the domain to persist notifications.
pub trait NotificationRepository: Send + Sync + 'static {
fn save(&self, notification: Notification) -> Result<(), RepositoryError>;
}
The Send + Sync + 'static bounds ensure adapters are safe for async runtimes. These traits represent outbound ports—capabilities the domain requires from the outside world.
Port Traits: Dependency Inversion Boundaries
Ports are Rust traits that describe services the domain needs. They are dependency-inverted: the domain declares the contract, and infrastructure fulfills it.
Key characteristics of Macro's port definitions:
- Located in
src/domain/ports.rswithin each crate - Use async-trait for asynchronous operations
- Include error types defined in the domain
- Remain agnostic to implementation technology
Adapter Layer: Infrastructure Implementations
Adapters live in src/outbound/ (for outbound ports) and src/inbound/ (for inbound ports). They translate between domain concepts and external systems.
In crates/notification/src/outbound/email.rs:
pub struct EmailServiceOpsImpl {
client: reqwest::Client,
config: EmailConfig,
}
#[async_trait::async_trait]
impl EmailServiceOps for EmailServiceOpsImpl {
async fn send_email(&self, payload: EmailPayload) -> Result<(), EmailError> {
// concrete logic that talks to an SMTP gateway or third‑party API
self.client.post(&self.config.endpoint)
.json(&payload)
.send()
.await?
.error_for_status()?;
Ok(())
}
}
The implementation owns infrastructure resources (reqwest::Client) while satisfying the domain's port contract.
Additional adapter examples in the notification crate:
src/outbound/websocket.rs:WebSocketGatewayOpsImplimplementsWebSocketGatewayOpssrc/outbound/sns_endpoint.rs: AWS SNS integration adapter
Composition Root: Wiring Dependencies
The composition root in crates/notification/src/lib.rs assembles the service by injecting concrete adapters:
pub fn build_notification_service(
email_impl: impl EmailServiceOps + Send + Sync + 'static,
ws_impl: impl WebSocketGatewayOps + Send + Sync + 'static,
) -> NotificationService {
NotificationService::new(
Box::new(email_impl),
Box::new(ws_impl),
// other adapters …
)
}
This function accepts impl Trait parameters for flexibility, then boxes them into trait objects for storage. The domain service receives Box<dyn PortTrait> types, remaining decoupled from specific implementations.
Hexagonal Architecture Testing Patterns
Mock Adapters for Unit Testing
Domain tests use mock adapters implementing the same ports, avoiding real infrastructure calls. In crates/notification/src/domain/test.rs:
#[derive(Default)]
struct MockNotificationSender {
pub sent: Mutex<Vec<Notification>>,
}
#[async_trait::async_trait]
impl NotificationSender for MockNotificationSender {
async fn send(&self, notification: Notification) -> Result<(), NotificationError> {
self.sent.lock().unwrap().push(notification);
Ok(())
}
}
This pattern enables:
- Deterministic, fast tests
- Verification of side effects via
sentrecordings - Testing error scenarios by configuring mocks to fail
Domain Service Testing
The src/domain/service/push_notification_event/mod.rs orchestrates use cases using injected ports. Tests pass mock implementations to verify business rules in isolation.
Consistent Patterns Across Service Crates
Macro applies these hexagonal architecture patterns uniformly:
| Crate | Domain Service | Key Ports | Notable Adapters |
|---|---|---|---|
crates/notification |
push_notification_event |
NotificationSender, NotificationRepository, EmailServiceOps, WebSocketGatewayOps |
Email service, WebSocket gateway, SNS endpoint |
crates/contacts |
Contact management | Contact persistence ports | Database repository adapters |
crates/documents |
markdown_backfill |
Document processing ports | Background job adapters, storage backends |
The crates/documents/src/domain/markdown_backfill.rs demonstrates hexagonal patterns in background processing services, using ports for CPU-intensive domain operations.
Why Hexagonal Architecture Works in Rust
Zero-cost abstractions: Trait objects (Box<dyn NotificationSender>) compile to thin v-tables with negligible overhead.
Explicit ownership: Adapters own resources (HTTP clients, database connections) while the domain remains pure.
Type-safe dependency injection: The compiler verifies that injected adapters satisfy port contracts.
Composability: impl Trait parameters in composition roots enable flexible assembly without runtime overhead.
Summary
- Define ports as traits in
src/domain/ports.rswithSend + Sync + 'staticbounds for async compatibility - Implement adapters in
src/outbound/orsrc/inbound/modules, owning infrastructure resources - Wire at composition root in
src/lib.rsusing constructor injection withimpl TraitorBox<dyn Trait> - Test with mock adapters implementing the same ports for isolated, deterministic domain tests
- Apply consistently across all service crates (notification, contacts, documents) for architectural uniformity
Frequently Asked Questions
How does Macro's hexagonal pattern handle async operations?
The codebase uses async-trait to define asynchronous port methods. Port traits include Send + Sync + 'static bounds, ensuring adapters work with Tokio and other async runtimes. Adapter implementations use .await for I/O operations while domain services remain agnostic to the executor.
Can I swap infrastructure implementations without changing domain code?
Yes. Because domain code depends only on trait definitions in src/domain/ports.rs, you can replace EmailServiceOpsImpl with a different email provider implementation without modifying NotificationService or its tests. The composition root in src/lib.rs is the sole location requiring changes.
Where should I place business logic in Macro's pattern?
Business logic belongs in domain services like push_notification_event/mod.rs, not in adapters. Domain services orchestrate use cases by calling port methods, applying business rules to the results. Keep adapters thin—they translate data formats and call external systems, containing minimal decision logic.
How does Macro handle cross-crate dependencies in this architecture?
Each crate maintains its own hexagonal boundaries with internal domain/, outbound/, and inbound/ modules. Crates expose minimal public APIs through src/lib.rs. When services need to collaborate, they depend on other crates' domain types and ports, not their implementations, preserving architectural isolation.
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 →