/

CrowdVision · source-available, not open source · © 2026 Nicolò Ghignatti

Digital Twin Service Architecture

Bounded context: Building Management · Stack: Rust / Axum · Code-level walkthrough: Digital Twin Service

The digital-twin realises the Building Management context. It is the spatial source of truth — the structural model of buildings and rooms that the live-data services decorate.


Architectural Style

Ports & Adapters (Hexagonal). The core is split in two so the Clean Architecture distinction stays visible inside the hexagon: domain/ holds the entities and the rules that are true regardless of delivery or storage, service/ holds the use cases plus the traits — the ports — they require of everything outside. adapters/driving/ calls the core; adapters/driven/ is called by it.

Hexagonal rather than Clean is a deliberate choice, and the two are not far apart here: the work is identical up to the last increment, and that increment (splitting entities from use cases) is cheap enough that it was kept. What was not built is the rest of Clean’s ceremony — there are no output ports and no presenters, and the inbound side has no port at all: a handler calls the use case’s concrete struct directly, because there is exactly one real caller per route. That is the same house rule the Go services follow.

Identity and authorization arrive in-process, never as a call to another service. GatewayClaims is a domain type; the Axum FromRequestParts extractor that decodes the mesh-injected x-gateway-claims header into one lives in adapters/driving/http_api/claims.rs, so decoding happens before the handler body runs with no network call to claims-gateway or tenancy. Cedar (service/authz.rs) sits inside the core rather than in adapters/driven/: whether a caller may edit a building is a rule, and Cedar is a pure evaluator with no I/O, running against a policy bundle compiled into the binary via include_str!.

One diagram trying to show every adapter and every core file at once is what made the previous version unreadable regardless of size: five stacked layers of dependency squeeze mermaid’s own auto-fit scaling down hard on a tall diagram. Splitting it in two — the hexagon on its own, then the adapters around it — keeps each one shallow enough to render at full width.

%%{init: {'themeVariables': {'fontSize': '28px'}, 'flowchart': {'nodeSpacing': 55, 'rankSpacing': 65, 'padding': 20}}}%%
graph TB
    subgraph pt["Ports — service/ports.rs"]
        PSTORE{{"service/ports.rs\nBuildingStore"}}:::port
        PQUEUE{{"service/ports.rs\nUploadQueue"}}:::port
        PSYNC{{"service/ports.rs\nDownstreamSync"}}:::port
        PEVENTS{{"service/ports.rs\nRegistrationEvents"}}:::port
    end
    subgraph uc["Use cases — service/"]
        UCB{{"service/buildings.rs\nread · list · count · update · rooms"}}:::usecase
        UCP{{"service/provisioning.rs\naccept · provision_next · status"}}:::usecase
        UCA{{"service/authz.rs\nCedar Read/Edit rules"}}:::usecase
    end
    subgraph ent["Entities — domain/"]
        ENTB{{"domain/building.rs"}}:::entity
        ENTI{{"domain/identity.rs"}}:::entity
        ENTU{{"domain/upload.rs"}}:::entity
        ENTE{{"domain/error.rs"}}:::entity
    end

    UCB --> UCA
    UCB --> ENTB
    UCB --> ENTE
    UCP --> ENTU
    UCP --> ENTE
    UCA --> ENTI
    UCB --> PSTORE
    UCB --> PSYNC
    UCP --> PSTORE
    UCP --> PQUEUE
    UCP --> PSYNC
    UCP --> PEVENTS

    classDef entity fill:#cfe8ff,stroke:#1b6ec2,stroke-width:2px,color:#003366
    classDef usecase fill:#d9f2d9,stroke:#2e7d32,stroke-width:2px,color:#1b4d1b
    classDef port fill:#fff3cd,stroke:#b8860b,stroke-width:2px,color:#5c4400

The adapters live outside that boundary, in and out through the ports:

%%{init: {'themeVariables': {'fontSize': '28px'}, 'flowchart': {'nodeSpacing': 55, 'rankSpacing': 70, 'padding': 20}}}%%
graph LR
    subgraph driving["DRIVING ADAPTERS"]
        EXTRACT["adapters/driving/http_api/claims.rs\nx-gateway-claims extractor"]:::driving
        HTTP["adapters/driving/http_api/controllers.rs\nHTTP handlers"]:::driving
        PRESENT["adapters/driving/http_api/exceptions.rs\nDomainError to HTTP"]:::driving
        LOOP["adapters/driving/worker.rs\nprovisioning loop"]:::driving
        KCONS["adapters/driving/kafka_consumer.rs\nregistration-completed consumer"]:::driving
    end

    HEX{{"THE HEXAGON"}}:::hexref

    subgraph driven["DRIVEN ADAPTERS"]
        ADB["adapters/driven/persistence/db.rs\nMongoBuildings"]:::driven
        AJOB["adapters/driven/persistence/jobs.rs\nMongoUploadQueue"]:::driven
        AOUT["adapters/driven/outbound.rs\nOutboundConfig"]:::driven
        AKPROD["adapters/driven/kafka_producer.rs\nKafkaEventProducer"]:::driven
        AFAKE["service/fakes.rs\nin-memory, cfg(test)"]:::driven
    end

    EXTRACT --> HTTP
    HTTP --> HEX
    LOOP --> HEX
    KCONS --> HEX
    HTTP --> PRESENT

    ADB -.->|implements| HEX
    AJOB -.->|implements| HEX
    AOUT -.->|implements| HEX
    AKPROD -.->|implements| HEX
    AFAKE -.->|implements| HEX

    classDef driving fill:#ffe0cc,stroke:#d2691e,stroke-width:2px,color:#7a3300
    classDef driven fill:#e0dcf5,stroke:#5b4b8a,stroke-width:2px,color:#2e2560
    classDef hexref fill:#f5faff,stroke:#333333,stroke-width:3px,color:#000000
    style driving fill:#fff9f4,stroke:#d2691e,stroke-width:1px
    style driven fill:#f7f5fc,stroke:#5b4b8a,stroke-width:1px

Driving adapters (orange) call the core; driven adapters (purple) are called by it, each implementing exactly one port from the diagram above. Every arrow into the hexagon points inwards, and every arrow out of it stops at a port; main.rs is the only file that knows a BuildingStore is really MongoDB, and swapping in service/fakes.rs is what lets the whole core suite run with no database and no network.

Ports and adapters

A port exists for the use case that declares it, not for whoever ends up implementing it. The use case’s author writes the trait signature (service/ports.rs) to fit their own logic, and everyone downstream must conform to it.

The layering rules above are test-enforced, not just described: tests/architecture_fitness.rs fails the build if domain/ or service/ import a framework/adapter/each other’s wrong direction, or if adapters/driving reaches into adapters/driven.

Components & Connectors diagram

Digital Twin Service C&C diagram

Key Architectural Decisions


Testing

Integration

For the registration flow, name-normalisation rules, synchronisation details, and the API, see the Digital Twin Service internals page.