ADR 0002: Hexagonal Architecture (Ports & Adapters)
Status: Accepted
Date: 2025-01-20
Track: Software
Context
Property management software evolves rapidly with changing business rules, regulations (GDPR, Belgian accounting standards), and technology choices (databases, cloud providers). Traditional layered architectures tightly couple business logic to frameworks and infrastructure, making changes expensive and risky.
We need an architecture that:
Protects domain logic from external concerns (HTTP, database, UI frameworks)
Enables testability without spinning up databases or servers
Supports multiple interfaces (REST API, CLI, future GraphQL/gRPC)
Facilitates technology swaps (PostgreSQL → ScyllaDB, S3 → MinIO)
Enforces invariants (ownership percentage ≤ 100%, PCMN validation) at compile time
Decision
We adopt Hexagonal Architecture (also known as Ports & Adapters) with strict layering and dependency inversion.
Layer Structure
┌─────────────────────────────────────────────────┐
│ Infrastructure (Adapters) │
│ Web Handlers │ PostgreSQL │ S3 │ Email │ Metrics│
└───────────────┬─────────────────────────────────┘
│ implements
┌───────────────▼─────────────────────────────────┐
│ Application (Use Cases + Ports) │
│ BuildingUseCases │ ExpenseUseCases │ Ports │
└───────────────┬─────────────────────────────────┘
│ uses
┌───────────────▼─────────────────────────────────┐
│ Domain (Core Logic) │
│ Building │ Expense │ Owner │ DomainServices │
└─────────────────────────────────────────────────┘
Dependency rule: Inner layers NEVER depend on outer layers.
Layer Responsibilities
Domain (backend/src/domain/):
Pure business logic, NO external dependencies
Entities (aggregates) with invariant validation
Domain services for cross-entity logic
Example:
Building::new()validates name is non-empty
Application (backend/src/application/):
Ports: Trait definitions (interfaces) like
BuildingRepositoryUse Cases: Orchestration logic (e.g.,
create_building)DTOs: Data Transfer Objects for API contracts
Depends ONLY on Domain layer
Infrastructure (backend/src/infrastructure/):
Adapters: Implementations of ports
PostgresBuildingRepositoryimplementsBuildingRepositoryActixWebHandlersconsume use cases
Depends on Application layer (implements ports)
Example
Domain entity (pure Rust, no dependencies):
// backend/src/domain/entities/building.rs
impl Building {
pub fn new(name: String, address: String, total_units: i32) -> Result<Self, String> {
if name.is_empty() {
return Err("Building name cannot be empty".to_string());
}
if total_units < 1 {
return Err("Building must have at least 1 unit".to_string());
}
Ok(Building { /* ... */ })
}
}
Application port (trait):
// backend/src/application/ports/building_repository.rs
#[async_trait]
pub trait BuildingRepository: Send + Sync {
async fn create(&self, building: &Building) -> Result<Building, String>;
async fn find_by_id(&self, id: Uuid) -> Result<Option<Building>, String>;
}
Infrastructure adapter (PostgreSQL):
// backend/src/infrastructure/database/repositories/building_repository_impl.rs
impl BuildingRepository for PostgresBuildingRepository {
async fn create(&self, building: &Building) -> Result<Building, String> {
sqlx::query!(/* SQL INSERT */)
.fetch_one(&self.pool)
.await
.map_err(|e| e.to_string())
}
}
Consequences
Positive:
✅ Domain logic is pure: Testable without I/O, databases, or HTTP
✅ Technology agnostic: Swap PostgreSQL → ScyllaDB by implementing new adapter
✅ Enforced boundaries: Rust’s module system prevents accidental coupling
✅ Testability:
Domain: Pure unit tests (no mocks needed)
Application: Mock repositories via traits
Infrastructure: Integration tests with testcontainers
✅ Parallel development: Teams can work on layers independently
✅ Refactoring safety: Changes to infrastructure don’t affect domain
Negative:
⚠️ More boilerplate: Each concept needs entity + DTO + port + adapter
⚠️ Steeper learning curve: Developers must understand layering rules
⚠️ Indirection: Navigating code requires understanding port-adapter mapping
Measured benefits (as of November 2025):
100% domain test coverage (no database required)
Zero domain layer bugs reported in production
Storage swap (local → S3) completed in 1 day (ADR-0044)
Alternatives Considered
Traditional Layered Architecture (Controller → Service → Repository):
✅ Simpler, less boilerplate
❌ Business logic leaks into controllers/services
❌ Tight coupling to frameworks (Actix-web, sqlx)
Verdict: Rejected due to coupling and testability concerns
Clean Architecture (similar to Hexagonal):
✅ Nearly identical benefits
✅ Well-known (Uncle Bob)
❌ More prescriptive (use case per operation)
Verdict: Close second, Hexagonal chosen for flexibility
Vertical Slice Architecture:
✅ Feature-centric, reduces coupling between features
❌ Harder to share domain logic across slices
Verdict: Good for microservices, less ideal for monolith MVP
Implementation Guidelines
Adding a new feature:
Start with Domain entity/service (pure logic, tests)
Define Application port (trait)
Create Use Case (orchestration)
Implement Infrastructure adapter (PostgreSQL, HTTP)
Add Web handler (Actix-web)
Module structure:
backend/src/
├── domain/
│ ├── entities/ # Aggregates (Building, Expense)
│ └── services/ # Domain services
├── application/
│ ├── ports/ # Repository traits
│ ├── use_cases/ # Orchestration logic
│ └── dto/ # API contracts
└── infrastructure/
├── database/ # PostgreSQL adapters
├── web/ # Actix-web handlers
└── storage/ # S3/MinIO adapters
Validation
Domain invariants enforced at compile time:
✅ Quote-part validation (0.0 < p ≤ 1.0):
UnitOwner::new()✅ PCMN code format (2-6 digits):
Account::new()✅ Non-empty building names:
Building::new()✅ VAT rates (6%, 12%, 21%):
InvoiceLineItem::new()
Testability:
Unit tests:
cargo test --lib(no I/O)Integration:
cargo test --test integration(testcontainers)BDD:
cargo test --test bdd(full stack)
Next Steps
✅ Document pattern in CLAUDE.md (Done)
✅ Apply to all features (Buildings, Units, Expenses, Accounts) (Done)
⏳ Monitor maintainability as codebase grows (target: 50k LOC by 2026)
⏳ Evaluate Domain Events pattern for complex workflows
References
Original article: Alistair Cockburn (2005) - https://alistair.cockburn.us/hexagonal-architecture/
Rust implementation: https://github.com/rust-lang/api-guidelines
KoproGo codebase:
backend/src/