CrowdVision · source-available, not open source · © 2026 Nicolò Ghignatti
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.
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:#5c4400The 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:1pxDriving 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.
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.
Driving Adapters: These originate requests for the core.
adapters/driving/http_api/claims.rs: Extracts x-gateway-claims from incoming HTTP requests, transforming raw header data into the domain’s GatewayClaims.adapters/driving/http_api/controllers.rs: HTTP handlers that translate external POST /register requests into calls to the core’s use cases.adapters/driving/http_api/exceptions.rs: Transforms domain-specific errors (DomainError) into appropriate HTTP responses.adapters/driving/worker.rs: A provisioning loop that wakes up periodically to process pending uploads, calling the same service::provisioning use case as the HTTP handlers.adapters/driving/kafka_consumer.rs: Consumes building-registration-completed and resolves the matching upload — the third entry point into service::provisioning, alongside the HTTP handler and the worker.Driven Adapters: These implement the capabilities (ports) that the core requires.
adapters/driven/persistence/db.rs: Implements the BuildingStore port, providing persistence for building data using MongoDB.adapters/driven/persistence/jobs.rs: Implements the UploadQueue port, handling durable queuing of upload tasks with MongoDB.adapters/driven/outbound.rs: Implements the DownstreamSync port — outbound REST calls to dashboard and the seed-only calls to telemetry; no longer used for registration (see below).adapters/driven/kafka_producer.rs: Implements the RegistrationEvents port, publishing building-registration-requested instead of calling telemetry directly.service/fakes.rs: An in-memory adapter for all four ports (BuildingStore, UploadQueue, DownstreamSync, RegistrationEvents), used exclusively by the test suite to enable testing the core without a database or network.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.
POST /register validates synchronously and answers 202 with a tracking handle; the twin itself is built by an in-process worker reading a durable queue. Validation stays ahead of the acknowledgement — a description the service would refuse must never be accepted — while everything that can only fail for reasons the caller cannot fix moves behind it. The caller polls the handle; delivery is at-least-once, so provisioning is written to converge on a second run rather than duplicate.service::provisioning module. None of them owns the behaviour, which is what stops the three paths drifting apart.building-registration-requested on Kafka and returns. Publishing is correlated, not blocking — telemetry builds its own model independently and reports back on building-registration-completed, which is what actually resolves the upload to ready or failed. A publish failure still fails the job the same way a REST failure used to; what changed is that a successful publish is no longer treated as the outcome, only the completion event is (see Communication & Data Flow for the sequence).DownstreamSync) is intentionally non-transactional — a failed seed is logged and swallowed. This no longer includes the sensor threshold clone, which moved to the Kafka flow above and fails the job instead.400 on a malformed room rather than storing bad data or surfacing an opaque 500.src/: unit tests only (cargo test --lib / just test twin) — real logic against fakes/wiremock, no database, no broker.tests/*.rs: integration tests — real MongoDB, no mocks at the boundary. docker-compose.test.yml runs the test process and Mongo on the same Docker network (just test twin-integration), so it never depends on a host-published port.twin-db; no other service reads it.building-registration-requested/-completed); calls dashboard directly over REST — see Communication & Data Flow.For the registration flow, name-normalisation rules, synchronisation details, and the API, see the Digital Twin Service internals page.