How the Macro Backend Implements Hexagonal Architecture
The Macro backend structures each Rust service crate following hexagonal (ports-and-adapters) architecture, isolating domain logic from HTTP handlers and database implementations through explicit trait-based ports.
The open-source macro-inc/macro repository demonstrates enterprise-grade software design in Rust. By organizing services into independent crates with clear domain, inbound, and outbound layers, the system achieves loose coupling that allows PostgreSQL storage to be swapped for Redis, HTTP APIs to be replaced with gRPC, and core business rules to be unit-tested without real infrastructure.
Three-Layer Crate Structure
Every service in the Macro workspace follows a consistent physical layout that reflects hexagonal architecture principles. Each crate contains three logical layers that communicate through well-defined interfaces rather than concrete dependencies.
-
Domain Layer: Located at
crates/<service>/src/domain/, this layer holds pure Rust types, business rules, and port traits that declare operations the service requires. For example, incrates/projects/src/domain/ports.rs, theProjectRepotrait defines methods likeget_basic_project,create_project, andsoft_delete_projectwithout referencing concrete database types such asPgPool. -
Inbound Adapters: Found in
crates/<service>/src/inbound/, these thin controllers translate external protocols into domain operations. The Projects service exposes its HTTP API incrates/projects/src/inbound/axum_router.rs, handling validation and authentication before delegating to domain services. -
Outbound Adapters: Stored in
crates/<service>/src/outbound/, these implement domain ports against concrete infrastructure. The PostgreSQL implementation ofProjectRepolives incrates/projects/src/outbound/pg_project_repo.rs, keeping all SQL and I/O concerns outside the core logic.
Domain Ports and Traits
At the heart of the Macro backend hexagonal architecture are ports: Rust traits that describe behaviors the domain requires from the outside world. These traits deliberately contain no concrete infrastructure types, specifying only the contract expected.
In crates/projects/src/domain/ports.rs, the ProjectRepo trait defines the persistence contract:
#[cfg_attr(test, mockall::automock)]
pub trait ProjectRepo {
type Err;
async fn get_basic_project(&self, project_id: &str) -> Result<Option<BasicProject>, Self::Err>;
async fn create_project(&self, project: &NewProject) -> Result<Project, Self::Err>;
async fn soft_delete_project(&self, project_id: &str) -> Result<(), Self::Err>;
}
The #[cfg_attr(test, mockall::automock)] attribute enables automatic mock generation, allowing unit tests to verify business logic without requiring a real PostgreSQL instance. This isolation ensures that domain code remains pure and testable.
Inbound and Outbound Adapters
Adapters bridge the domain ports to the external world. Inbound adapters handle protocol translation from HTTP, gRPC, or CLI, while outbound adapters implement port traits against specific infrastructure.
The inbound HTTP adapter in crates/projects/src/inbound/axum_router.rs validates JSON payloads, extracts user IDs, and calls domain service methods. It contains no business logic—only protocol handling and authentication.
Outbound adapters like PgProjectRepo in crates/projects/src/outbound/pg_project_repo.rs implement the ProjectRepo trait using sqlx and PostgreSQL connections. Because the domain depends only on the trait, swapping PostgreSQL for another datastore requires only a new adapter without modifying domain code.
Service Layer and Dependency Injection
The service layer orchestrates business rules by composing ports. Located in the domain layer, structs like ProjectService receive trait objects implementing the required ports, ensuring they remain independent from HTTP or database specifics.
At application startup, concrete adapters are instantiated and injected into the service implementation. For example, in the service's main.rs:
let pg_repo = PgProjectRepo::new(pg_pool.clone());
let redis_repo = RedisProjectRepo::new(redis_client.clone());
let service = ProjectServiceImpl::new(
pg_repo, // primary storage
redis_repo, // cache layer (implements same ProjectRepo trait)
upload_url_port,
search_indexer,
);
The service receives only abstract port traits, guaranteeing testability and loose coupling. The domain orchestrates the flow without knowing how the database or HTTP layer works—it only knows what it can ask for.
Multi-Target Deployment with Feature Gates
The Macro backend leverages Cargo features to compile the same domain logic for different runtime environments. Crates expose optional inbound and outbound modules via features named inbound and outbound.
For deployment targets that lack upload functionality (such as AI-tool-only hosts), the system provides stub implementations like UnavailableProjectUploadUrlPort. These stubs implement the port trait but fail fast at runtime, allowing the code to compile for restricted environments while maintaining the same API surface.
End-to-End Example: Project Creation
Consider the flow when creating a new project:
- An HTTP request hits the inbound router at the
create_projectendpoint incrates/projects/src/inbound/axum_router.rs. - The router validates the payload and calls
ProjectService::create_project. ProjectServiceinvokes theProjectRepoport, delegating toPgProjectRepo::create_projectto execute a SQL transaction.- After the transaction succeeds, the service may call
ProjectUploadUrlPortto generate presigned URLs orProjectSearchIndexerto schedule index updates.
All steps are wired together without the domain knowing the concrete implementations.
Extending the Architecture
Adding new infrastructure requires only new adapters. For example, implementing a Redis cache involves creating a struct that implements the existing ProjectRepo trait:
// src/outbound/redis_cache.rs
use crate::domain::ports::ProjectRepo;
use redis::AsyncCommands;
#[derive(Clone)]
pub struct RedisProjectRepo {
client: redis::Client,
}
#[async_trait::async_trait]
impl ProjectRepo for RedisProjectRepo {
type Err = redis::RedisError;
async fn get_basic_project(&self, project_id: &str)
-> Result<Option<BasicProject>, Self::Err>
{
let mut conn = self.client.get_async_connection().await?;
let json: Option<String> = conn.get(format!("project:{project_id}")).await?;
Ok(json.map(|s| serde_json::from_str(&s).unwrap()))
}
// Additional methods delegate to PostgreSQL or are no-ops
}
This pattern repeats across the workspace. The notification crate organizes its ports in crates/notification/src/domain/ports.rs, while connection_gateway and scheduled_action follow identical structures in their respective domain/ports.rs files.
Summary
- The Macro backend organizes each service into domain, inbound, and outbound layers following hexagonal architecture principles.
- Domain ports are trait definitions in files like
crates/projects/src/domain/ports.rsthat specify behavior without concrete infrastructure dependencies. - Adapters isolate protocol handling (inbound) and persistence logic (outbound), enabling storage swaps without domain changes.
- Feature gates allow compilation for different deployment targets using stub adapters like
UnavailableProjectUploadUrlPort. - Dependency injection at startup wires concrete implementations to domain services, ensuring testability with tools like
mockall.
Frequently Asked Questions
What is the benefit of hexagonal architecture in the Macro backend?
Hexagonal architecture isolates business rules in the domain layer, allowing the Macro team to unit-test core logic with mock implementations generated by mockall. This separation enables changing PostgreSQL to Redis or adding Kafka producers without modifying domain code, reducing regression risks and accelerating feature development.
How does the Macro backend handle different deployment environments?
The Macro backend uses Cargo feature flags (inbound, outbound) to conditionally compile adapter modules. For environments lacking upload capabilities, the system compiles stub implementations like UnavailableProjectUploadUrlPort that satisfy the port trait but return errors at runtime, ensuring type safety across all deployment targets.
Where are the domain ports defined in the Macro repository?
Domain ports are defined as Rust traits in src/domain/ports.rs within each service crate. Key examples include crates/projects/src/domain/ports.rs containing the ProjectRepo trait, crates/notification/src/domain/ports.rs for notification operations, and crates/scheduled_action/src/domain/ports.rs for scheduled task management.
How does dependency injection work in the Macro hexagonal architecture?
At application startup in main.rs, concrete adapter structs like PgProjectRepo and RedisProjectRepo are instantiated with their connection pools. These concrete instances are injected into the domain service constructor (e.g., ProjectServiceImpl::new), which accepts only abstract trait objects. This inversion of control ensures domain logic remains pure and infrastructure-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 →