How Macro's Rust Backend Implements the Hexagonal Architecture Pattern
Macro's Rust backend enforces hexagonal architecture by isolating domain logic in trait-based ports and implementing concrete adapters in separate modules, ensuring business rules remain independent of HTTP, database, and messaging concerns.
The macro-inc/macro repository demonstrates a rigorous application of hexagonal architecture—also known as "ports and adapters"—across its Rust crate ecosystem. This pattern decouples core business logic from infrastructure concerns like PostgreSQL, WebSocket gateways, and HTTP servers. By defining pure traits in the domain layer and providing technology-specific implementations elsewhere, the codebase achieves testable, replaceable components without polluting business rules with external dependencies.
Core Architectural Layers
The hexagonal pattern in Macro's backend organizes code into distinct concentric layers, each with specific responsibilities and dependency rules.
Domain Layer (Core Business Logic)
At the center resides the domain layer, containing pure business rules, data models, and use-case functions. This layer declares ports—Rust traits that define required behaviors—but never imports concrete implementations. According to the source code in crates/notification/src/domain/ports.rs, ports are async-friendly yet runtime-agnostic, depending only on standard library and error-handling types:
/// Port for notification persistence operations.
pub trait NotificationRepository: Send + Sync + 'static {
fn get_muted_users<'a>(
&self,
user_ids: &[MacroUserIdStr<'a>],
) -> impl Future<Output = Result<HashSet<MacroUserIdStr<'static>>, Report>> + Send;
// … other CRUD methods …
}
Notice the trait bounds: Send + Sync + 'static ensure thread-safety across async boundaries while maintaining zero dependencies on sqlx, axum, or other external crates.
Ports as Interface Contracts
Ports are abstract trait definitions describing how the domain communicates with the outside world. In crates/notification/src/domain/ports.rs, you'll find multiple port definitions including NotificationRepository for persistence and NotificationRealtimePublisher for real-time messaging. These traits use Rust's impl Future return type to accommodate async operations without tying the domain to a specific runtime like Tokio or async-std.
Adapter Implementations
Adapters bridge the domain's abstract ports to concrete technologies. The Macro backend strictly separates inbound adapters (handling external requests) from outbound adapters (talking to external services).
Outbound Adapters (External Services)
Outbound adapters implement domain ports using specific technologies. In crates/notification/src/outbound/websocket.rs, the WebSocketGatewayAdapter implements the NotificationRealtimePublisher port by wrapping a WebSocket client:
pub struct WebSocketGatewayAdapter<W> {
pub gateway: W,
}
impl<W> NotificationRealtimePublisher for WebSocketGatewayAdapter<W>
where
W: WebSocketGatewayOps + Clone + Send + Sync + 'static,
{
fn publish_updates<'a>(
&self,
payload: &NotificationStatusPayload<'_>,
) -> impl Future<Output = Result<HashSet<MacroUserIdStr<'static>>, Report>> + Send {
// Calls the underlying WebSocket gateway client.
}
}
Similarly, crates/notification/src/outbound/repository.rs provides NotificationRepositoryImpl, which satisfies the NotificationRepository port using sqlx for PostgreSQL access:
pub struct NotificationRepositoryImpl {
pool: sqlx::Pool<Postgres>,
}
impl NotificationRepository for NotificationRepositoryImpl {
fn get_muted_users<'a>( &self, user_ids: &[MacroUserIdStr<'a>] ) -> ... {
// Executes a SQL query via `sqlx`.
}
// … other methods …
}
Inbound Adapters (Protocol Handlers)
Inbound adapters translate external protocols into domain operations. HTTP handlers in services/notification_handler/src/handler.rs (using Axum) parse incoming requests, validate input, and invoke domain services without exposing HTTP details to the business logic. These handlers depend only on domain traits, not concrete implementations, allowing the same domain code to serve HTTP requests, Lambda events, or WebSocket messages interchangeably.
Wiring the Application (Composition Root)
The composition root—typically located in services/*/src/main.rs—is the only place where concrete adapters are instantiated and injected into domain services. This keeps the domain layer pristine while allowing the application to assemble its dependency graph at startup.
In services/notification_handler/src/main.rs, the wiring looks like this:
fn build_notification_service() -> NotificationService {
let db_pool = create_pg_pool();
let repo = NotificationRepositoryImpl { pool: db_pool };
let ws_client = ConnectionGatewayClient::new(auth_key, gateway_url);
let realtime = WebSocketGatewayAdapter { gateway: ws_client };
NotificationService::new(repo, realtime)
}
This construction pattern ensures that NotificationService receives trait objects (NotificationRepository and NotificationRealtimePublisher) while remaining ignorant of whether it's talking to PostgreSQL, Redis, or an in-memory store for testing.
Practical Implementation Workflow
When adding new features to Macro's backend, developers follow a consistent four-step pattern that preserves architectural boundaries:
1. Define the port in the domain crate:
pub trait UserPresenceChecker: Send + Sync {
fn is_user_online<'a>(&self, user_id: MacroUserIdStr<'a>)
-> impl Future<Output = Result<bool, Report>> + Send;
}
2. Implement an outbound adapter:
pub struct ConnectionGatewayPresenceAdapter {
client: ConnectionGatewayClient,
}
impl UserPresenceChecker for ConnectionGatewayPresenceAdapter {
fn is_user_online<'a>(&self, user_id: MacroUserIdStr<'a>) -> ... {
// Calls `client.check_online(user_id)` and returns the result.
}
}
3. Wire the adapter at the composition root:
fn build_user_service() -> UserService {
let presence_adapter = ConnectionGatewayPresenceAdapter {
client: ConnectionGatewayClient::new(auth_key, gateway_url),
};
UserService::new(presence_adapter)
}
4. Test with mock implementations:
struct MockPresenceChecker;
impl UserPresenceChecker for MockPresenceChecker {
fn is_user_online<'a>(&self, _: MacroUserIdStr<'a>) -> ... {
async { Ok(true) }
}
}
Key Benefits of Hexagonal Architecture in Macro
The strict separation enforced by Macro's Rust backend delivers three critical advantages:
- Isolation: Business rules in
crates/*/src/domain/never importsqlx,reqwest, oraxum. Domain code remains pure, focusing exclusively on logic and entity relationships. - Testability: Because domain services depend only on trait bounds (ports), unit tests supply mock implementations without spinning up databases or external services. The mock example above demonstrates how tests can verify business logic in complete isolation.
- Replaceability: Swapping PostgreSQL for another datastore, or WebSocket for Server-Sent Events, requires only creating a new adapter implementing the existing port. The domain layer remains untouched during infrastructure migrations.
Summary
Macro's Rust backend exemplifies hexagonal architecture through rigorous trait-based design and strict module boundaries:
- Domain crates (
crates/*/src/domain/) define pure business logic and port traits without external dependencies - Outbound adapters (
crates/*/src/outbound/) implement ports using concrete technologies like PostgreSQL viasqlxor WebSocket gateways - Inbound adapters (
services/*/src/handler.rs) translate HTTP and other protocols into domain operations - Composition roots (
services/*/src/main.rs) wire concrete implementations into domain services at application startup - Test mocks replace adapters with stub implementations for isolated unit testing
This structure ensures that infrastructure concerns never leak into business rules, enabling the system to evolve by swapping adapters rather than refactoring core logic.
Frequently Asked Questions
What is a "port" in the context of Macro's Rust backend?
In Macro's backend, a port is a Rust trait defined in the domain layer that describes a capability the business logic requires without specifying how it's implemented. For example, NotificationRepository in crates/notification/src/domain/ports.rs defines methods for persistence operations, but the domain code doesn't know whether the implementation uses PostgreSQL, Redis, or a mock for testing.
How does Macro's backend prevent database code from leaking into domain logic?
The backend enforces architectural boundaries through Rust's module system and visibility rules. Domain crates contain only trait definitions (ports) and business logic, while database-specific code lives in separate outbound/ modules within adapter crates. The domain never imports sqlx::Pool or SQL queries; it only calls methods on trait objects injected at the composition root in services/*/src/main.rs.
Can the same domain code handle HTTP requests and Lambda events in Macro's architecture?
Yes. Because domain services depend only on abstract ports, you can create different inbound adapters for different protocols. The domain logic remains identical whether invoked by an Axum HTTP handler in services/notification_handler/src/handler.rs or a Lambda event processor. Only the adapter layer changes to parse the specific input format.
Why does Macro use impl Future in port traits instead of async fn?
The codebase uses impl Future return types in port definitions to maintain flexibility across async runtimes while ensuring Send bounds. This approach, visible in crates/notification/src/domain/ports.rs, allows the traits to specify that futures must be thread-safe (Send) without tying the domain to a specific executor like Tokio, keeping the architectural boundary clean and runtime-agnostic.
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 →