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:

  1. Protects domain logic from external concerns (HTTP, database, UI frameworks)

  2. Enables testability without spinning up databases or servers

  3. Supports multiple interfaces (REST API, CLI, future GraphQL/gRPC)

  4. Facilitates technology swaps (PostgreSQL → ScyllaDB, S3 → MinIO)

  5. 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 BuildingRepository

  • Use 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

    • PostgresBuildingRepository implements BuildingRepository

    • ActixWebHandlers consume 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

  1. 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

  2. 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

  3. 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:

  1. Start with Domain entity/service (pure logic, tests)

  2. Define Application port (trait)

  3. Create Use Case (orchestration)

  4. Implement Infrastructure adapter (PostgreSQL, HTTP)

  5. 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/


Amendment 2026-05-19 — Domain-typed error pattern (#433 umbrella)

Pattern formalisé via 5 entités domaine livrées dans l’umbrella #433 (JournalEntryError, ChargeDistributionError, EtatDateError, OwnerContributionError, CallForFundsError).

Règle

Chaque entité de domaine qui retourne une erreur expose son propre enum typé pur (zéro dépendance actix_web/sqlx/AppError), pas Result<_, String>. L’application mappe vers AppError à la frontière.

Mécanique

Domaine (backend/src/domain/entities/<x>.rs) :

#[derive(Debug, Clone, PartialEq)]
pub enum ChargeDistributionError {
    QuotaOutOfRange(Decimal),
    NegativeTotalAmount,
    QuotaSumExceeds { total_quota: Decimal },
}

impl std::fmt::Display for ChargeDistributionError { /* ... */ }
impl std::error::Error for ChargeDistributionError {}

// Pont : les use-cases qui restent en `Result<_, String>` compilent
// inchangés via `e?` (cascade String→AppError = slice large différée).
impl From<ChargeDistributionError> for String {
    fn from(e: ChargeDistributionError) -> String { e.to_string() }
}

Application (backend/src/application/error.rs) :

impl From<crate::domain::entities::ChargeDistributionError> for AppError {
    fn from(e: crate::domain::entities::ChargeDistributionError) -> Self {
        AppError::Validation(e.to_string())  // 400, jamais 500
    }
}

Garanties

  • Pureté domaine : le domain/ n’importe rien d’actix_web/sqlx/ AppError. Conserve les invariants hexagonaux (cf. décision principale ci-dessus).

  • 400, jamais 500 : une donnée d’entrée invalide (quote-part hors bornes, montant négatif, transition workflow interdite) renvoie AppError::Validation (HTTP 400) — pas Internal (500). L’utilisateur voit un message actionnable, le log SRE ne contient pas un faux 500.

  • From<_> for String = pont temporaire : permet de typer les entités sans casser la cascade port/use-case/handler en Result<_, String>. La migration de cette cascade vers AppError est une slice séparée (différée, tracée dans les agent-activity WBS-A4/A5/A6).

  • Tests 4-cat RED-first in-module : happy_*, edge_*, negative_*, security_* testent l’erreur typée via matches!(err, …), pas via .contains("…") (les messages peuvent évoluer sans casser les tests).

Exemple complet d’application

Voir backend/src/domain/entities/charge_distribution.rs (entité avec les 3 variantes ci-dessus + tests 4-cat) et backend/src/application/error.rs §”Domain typed-error → AppError mappings” (bloc From par WP, ajouté en fin de section pour minimiser les conflits de merge inter-WP concurrents).

Cf. également ADR-0008 §Amendment 2026-05-19 pour la liste fermée des carve-outs f64 autorisés malgré la règle Decimal (interaction entre typage erreur et typage monétaire).