Architektur im Detail: Modulschichten

Zweck

Dieser Abschnitt beschreibt den inneren Aufbau von src/thingsboard_frost_adapter/: die vier Schichten, die Regel, in welche Richtung Importe zeigen, und die Verträge, die die Schichten einander geben. Der Architekturüberblick beschreibt die Komponenten um den Adapter herum; hier geht es um seinen Inhalt.

Das Schichtschema gilt für src/thingsboard_frost_adapter/ und nur dort. Daneben liegt unter src/frontend/ eine zweite, eigenständige Anwendung mit eigener, flacherer Gliederung; sie greift ausschließlich über die REST‑API auf den Adapter zu und ist in Frontend (Streamlit) beschrieben.

Die vier Schichten

Schicht Besitzt Darf importieren

routers/

Die HTTP-Oberfläche: Pfade, Statuscodes, Response-Modelle, das Auslesen des Request-Bodys. Eine Route ist ein Service-Aufruf.

von den Schichten nur services/; dazu model/, exceptions/ und Geschwister aus routers/ (base_routes, errors)

services/

Die Entscheidungen: Geschäftsregeln, Reihenfolgen, Fehlerpolitik — und der Ort, an dem sich zwei Repositories treffen.

repositories/db/, repositories/external/, clients/ sowie andere Module aus services/

repositories/db/
repositories/external/

Genau einen Zugriffsweg auf genau ein System: je Modul eine Postgres-Tabelle (db/) bzw. ein Fremdsystem (external/: FROST, ThingsBoard).

clients/, db/, redis/, model/, exceptions/; in db/ zusätzlich crud

clients/

Den Aufbau einer Verbindung zu System X für Mandant Y, samt Authentifizierung und deren Zwischenspeicherung.

configuration/, model/ und Geschwister aus clients/ — sonst nichts aus dem Projekt

Der Zugehörigkeitstest, wenn bei einem neuen Modul unklar ist, wohin es gehört:

  • Brauche ich Request, einen Statuscode oder ein Response-Modell? → routers/

  • Wird hier etwas entschieden, oder treffen sich hier zwei Repositories? → services/

  • Ist das eine Anfrage an ein System, deren Antwort ohne Regelwissen zustande kommt? → repositories/

  • Ist das der Aufbau einer Verbindung oder einer Authentifizierung? → clients/

db/, redis/, model/, exceptions/, configuration/ und bootstrap/ sind keine Schichten, sondern Querschnittspakete. Sie dürfen von überall importiert werden.

Die Abhängigkeitsregel

Importe zeigen ausschließlich nach unten.

                 routers/
                    │  nur services/
                    ▼
                services/
             ┌──────┴───────┐
             ▼              │
      repositories/         │
       db/  external/       │
             │              │
             ▼              ▼
                clients/
  • routers/ importiert von den Schichten nur services/.

  • services/ importiert repositories/db/, repositories/external/ und clients/.

  • repositories/external/ importiert clients/TenantFrostRepository wird aus TenantClients konstruiert und braucht den Typ für die eigene Annotation.

  • Unterhalb von services/ importiert nichts eine Schicht über sich. Kein Repository und kein Client importiert jemals services/.

  • Ein Repository importiert kein anderes Repository. crud ist kein Repository, sondern gemeinsame Mechanik, und deshalb erlaubt.

  • Innerhalb von services/ sind Importe erlaubt und zyklenfrei; kein Modul im Baum braucht einen verzögerten Import.

bootstrap/ ist kein Router und importiert repositories/db/ direkt.

Der Paketbaum

src/thingsboard_frost_adapter/
  clients/
    frost_client.py  thingsboard_client.py  keycloak_client.py  tenant_clients.py
  repositories/
    db/
      crud.py
      observed_property_repository.py       telemetry_key_repository.py
      unit_of_measurement_repository.py     unit_of_measurement_key_repository.py
      tenant_repository.py                  tenant_observed_property_repository.py
    external/
      frost_repository.py  thingsboard_repository.py
  services/
    observed_property_service.py            telemetry_key_service.py
    unit_of_measurement_service.py          unit_of_measurement_key_service.py
    tenant_service.py                       tenant_observed_property_service.py
    device_export_service.py                tenant_healthcheck_service.py
    cross_tenant.py
  routers/          bootstrap/   main.py
  db/  redis/  model/  exceptions/  configuration/          (Querschnitt)

Wo liegt was

Die Module, deren Zuschnitt sich nicht aus dem Namen allein ergibt:

Modul Subjekt

clients/tenant_clients.py

TenantClients und clients_for(tenant) — der Einstieg zu allen Verbindungen eines Mandanten. Der Cache ist über einen Fingerprint der Mandantenzeile geschlüsselt, damit ein PATCH in allen Workern greift und nicht nur in dem, der ihn bedient hat. Er prüft zusätzlich, ob die ThingsBoard‑Sitzung des Paares abläuft, und baut es dann neu — beides dieselbe Frage („ist dieses Paar noch brauchbar?“), deshalb dieselbe Stelle.

clients/thingsboard_client.py

build_thingsboard_client(tenant) — der einzige Ort, an dem die ThingsBoard‑Edition eine Rolle spielt: Sie entscheidet Client‑Klasse und Zugangsdaten zugleich. Alles darüber sieht nur RestClientBase, und das ist die ehrliche Angabe: Jede vom Adapter benutzte Methode steht auf der Basisklasse, PE und CE deklarieren die wenigen, die sie wiederholen, unverändert.

clients/frost_client.py

build_frost_client(tenant) und PooledSensorThingsService. Die Unterklasse existiert für eine Sache: SensorThingsService.execute ruft das modulweite requests.request auf, das pro Aufruf eine Session samt TCP‑Verbindung öffnet und wieder schließt — bei vier FROST‑Aufrufen je Telemetriemeldung vier Verbindungen pro Meldung. Die Unterklasse schickt dieselben Aufrufe über eine langlebige Session je Mandant. Auth‑Header und Proxies bleiben dabei pro Anfrage, weil der Keycloak‑Handler sein Token bei Ablauf erneuert; langlebig ist allein der Verbindungspool.

repositories/db/crud.py

Fünf generische Funktionen (get_all, get_by_id, create, update, delete_by_id), die die sechs Repositories typisiert weiterreichen. Kein Service importiert crud direkt — wer das täte, griffe an seinem Repository vorbei.

repositories/external/frost_repository.py

TenantFrostRepository — der einzige Ort, an dem der FROST-Client, sein Query-Builder und seine OData-Filter vorkommen. Konstruiert über frost_repository_for(tenant).

services/device_export_service.py

Die ThingsBoard-Geräte eines Mandanten, deren Assets und deren Messwerte in den FROST dieses Mandanten zu schieben. Der heiße Pfad ist export_telemetry.

services/tenant_observed_property_service.py

Die Kopien der globalen Observed Properties im FROST eines Mandanten — auflösen, anlegen, aktualisieren. Nicht zu verwechseln mit tenant_observed_property_repository, das die Zuordnungstabelle in Postgres hält.

services/observed_property_service.py

Die kanonische Observed-Property-Zeile in Postgres und die Schleife über alle Mandanten.

services/cross_tenant.py

Die beiden mandantenübergreifenden Regeln: push_to_every_tenant verteilt best-effort, reject_if_any_tenant_uses beweist fehl-geschlossen. Importiert nur tenant_repository.

services/tenant_service.py

Mandantenzeile und Lebenszyklus in einem Modul: onboard, amend, offboard neben encrypt_and_create und den Weiterleitungen auf tenant_repository.

services/tenant_healthcheck_service.py

Die Erreichbarkeitsprüfung eines Mandanten. Kein Repository, sondern Konfigurationsvalidierung — sie gattert einen Schreibvorgang, bevor überhaupt eine Mandantenzeile entsteht.

Namenskonventionen

Jedes Modul, das ein Subjekt besitzt, trägt seine Schicht als Suffix_client, _repository, _service, _router —, auch wenn das Paket sie bereits nennt. Dieselben Substantive (tenant, frost, observed_property) wiederholen sich über alle vier Schichten; ohne Suffix hätte man drei Editor-Tabs namens tenant.py.

Ein Modul ohne eigenes Subjekt trägt kein Suffix. Davon gibt es genau zwei: crud.py, benannt nach einem Mechanismus, und cross_tenant.py, benannt nach einer Beziehung. Das fehlende Suffix sagt: Das ist ein Helfer, kein Einstiegspunkt.

Bindung: an einen Mandanten gebunden → Objekt, konstruiert aus TenantClients. Global → Modul von Funktionen. Postgres ist eine Engine für den ganzen Prozess, also ist repositories/db/ ein Modul von Funktionen; FROST und ThingsBoard gibt es je Mandant, also ist repositories/external/ objektbasiert. Innerhalb von services/ und repositories/db/ gibt es keine Klassen und keine Modul-Singletons.

Zu jeder mandantengebundenen Klasse steht eine Fabrik <name>_for(tenant) neben ihr. Es gibt drei:

frost_repository_for(tenant)                  # repositories/external/frost_repository.py
device_export_service_for(tenant)             # services/device_export_service.py
tenant_observed_property_service_for(tenant)  # services/tenant_observed_property_service.py

Die Verträge zwischen den Schichten

Ein Repository, eine Tabelle

Ein Repository setzt select() nur gegen seine eigene Tabelle ab und schreibt nur seine eigene Tabelle. Einer Relationship zum Lesen zu folgen ist erlaubt — das ist das Lesen des Modells. Die Zuweisung an eine Relationship-Collection ist ein Schreibzugriff auf die andere Tabelle und verboten.

Ein tabellenübergreifender Lesezugriff liegt im Repository der referenzierenden Tabelle, etwa telemetry_key_repository.find_by_observed_property_id. Wer die Abfrage aufruft, ist keine Eigenschaft der Abfrage.

Die Session gehört dem Repository

Jede Repository-Funktion öffnet ihre eigene Session, committet und schließt sie. services/ öffnet nie eine Session und bekommt nie eine — kein Session-Parameter oberhalb von repositories/.

Ein Repository gibt ein vollständig geladenes Objekt zurück, weil die Zeile nach dem Block abgelöst ist. Zwei Wege dahin: lazy=False auf TelemetryKey.default_uom, TelemetryKey.observed_property und UnitOfMeasurementKey.uom, sowie das Berühren des Attributs innerhalb der Session für die beiden Lazy-Collections (_ = uom.telemetry_keys). Die Tabellen und Beziehungen dahinter stehen in Datenbankschema des Adapters.

Kein fsc-Typ oberhalb von services/

fsc-Entitäten (Thing, Datastream, Sensor, …) überqueren die Grenze zwischen repositories/external/ und services/ frei in beide Richtungen. Was das Repository kapselt, ist der ClientSensorThingsService, sein Query-Builder, seine OData-Filter und seine 404er.

Oberhalb von services/ erscheint kein fsc-Typ. Deshalb ist /telemetry ein einziger export_telemetry-Aufruf: Die Zweischritt-Form hielte ein fsc.Thing in einer Routenvariablen.

Abwesenheit an der FROST-Grenze

find_ liefert None, wenn die Entität fehlt, und wirft, wenn das Fremdsystem krank ist.* _is_not_found bleibt privat im FROST-Repository und verlässt das Modul nie. Die Heil-Politik liest sich dadurch als Politik (if copy is None: heal) statt als Exception-Klempnerei.

Sperr-Reihenfolge

Sperren werden in beiden Schichten genommen:

Sperre genommen in

THING

services/device_export_service.py (get_or_create_device, create_observation, update_device) und repositories/external/frost_repository.py (_rewrite_datastreams)

LOCATION

repositories/external/frost_repository.py (get_or_create_location) und services/device_export_service.py (update_location)

SENSOR

repositories/external/frost_repository.py (get_or_create_sensor)

OBSERVED_PROPERTY

services/tenant_observed_property_service.py (_create_and_link)

Werden zwei Sperren gleichzeitig gehalten, ist THING immer die äußere. LOCATION, SENSOR und OBSERVED_PROPERTY liegen dann innerhalb von THING, niemals umgekehrt. Jede darf für sich allein auch ohne THING genommen werden — update_location tut genau das —; verboten ist nur die umgekehrte Verschachtelung. Die Sperre in _create_and_link ist über die kanonische Id geschlüsselt, nicht über den Namen.

Die Fehlergrenze

„Nur routers/ spricht HTTP" gilt hier nicht: HTTPException ist in tenant_ingest_router.reported() tragendes Transportmittel — fangen, .status_code lesen, mit angereichertem Detail erneut werfen —, und from fastapi import HTTPException zeigt seitwärts auf das Framework, nicht nach oben.

Ursprung wirft wird zu

services/tenant_service.py — Anmeldedatenprüfung fehlgeschlagen

HTTPException(400)

400 mit Nennung der fehlgeschlagenen Prüfung

services/tenant_service.py — FROST hält noch Things / ist nicht erreichbar

HTTPException(409)

409

repositories/external/thingsboard_repository.py — kein Asset / kein building-Asset

HTTPException(404)

404 — siehe Die benannten Ausnahmen

Stammdaten- und Fan-out-Wächter in services/

BusinessRuleViolationError

422 über den Handler in main.py

repositories/external/frost_repository.py — teilweise Umbenennung

PartialPushError

500 über den Handler in main.py

unbekannte Id

Nonerouters/errors.or_404

404, im Router

Zielsystem nicht erreichbar

requests.RequestException

502 über den Handler in main.py

Die benannten Ausnahmen

Vier Stellen brechen eine der obigen Regeln. Alle vier sind bewusst und abschließend:

Stelle Ausnahme

repositories/external/thingsboard_repository.py

wirft HTTPException(404) und wählt damit als einziges Repository das Urteil seines Aufrufers. Geduldet, nicht gebilligt — jeder heutige Aufrufer ist eine HTTP-Route und will genau diese 404. Neu zu bewerten, sobald ein Aufrufer ein nicht zugeordnetes Gerät überspringen statt ablehnen will.

services/tenant_healthcheck_service.py

macht als einziges Modul in services/ rohe requests-Aufrufe: Es umgeht die Client-Abstraktion, weil es die Abstraktionen prüft. Es kann außerdem kein aus TenantClients konstruiertes Objekt sein, weil es auf einem Kandidaten-Tenant läuft, bevor eine Zeile existiert.

repositories/db/*.get_or_create (vier Seeder)

nehmen eine Session vom Aufrufer entgegen und committen nicht, weil bootstrap/ alle Vorgaben in einer Transaktion schreibt. Diese Form ist ausschließlich für bootstrap/.

services/unit_of_measurement_key_service.py

besteht aus fünf einzeiligen Weiterleitungen und sonst nichts. Es existiert trotzdem, damit „Router importieren nur `services/`" ein Satz ohne Ausnahme bleibt.

Die Teststruktur

Die oberste Achse von tests/ ist die Abhängigkeit (unit / integration), das Schichtschema ist der Baum darunter.

tests/
  unit/          clients/  configuration/  model/  redis/
  integration/   bootstrap/  clients/  routers/  services/
                 test_tenancy.py  test_thingsboard_fixture.py
                 test_thingsboard_provisioning.py  test_staging_happy_path.py
                 test_frost_housekeeping.py
                 conftest.py  rows.py  tenants.py
                 tenant_registry_data.py  thingsboard_data.py
                 frost_housekeeping.py

Die Spiegelregel, die der Docstring in tests/unit/init.py hält:

  1. Ein Test mit einem Modul unter Test liegt in dessen Schicht und ist nach ihm benannt — aufgelöst über das Modul unter Test, nicht über den obersten Import in der Datei.

  2. Ein Test, dessen Subjekt eine Eigenschaft des Gesamtsystems ist, bleibt ungespiegelt in der Wurzel von tests/integration/.

repositories/ erscheint in keinem der beiden Bäume: Nichts ruft ein Repository als seinen Einstiegspunkt auf. Die Repository-Primitive sind durch den Service abgedeckt.

tests/integration/conftest.py gibt je Mandant ein Fixture pro Subjekt heraus, statt eines Bündels, das alle Handles auf einmal trägt:

Fixture liefert

tenant_a / tenant_b

die Tenant-Zeile

tenant_a_frost / tenant_b_frost

den rohen fsc-Client, für Assertions und Teardown

tenant_a_frost_repo / tenant_b_frost_repo

ein TenantFrostRepository

tenant_a_export / tenant_b_export

einen DeviceExportService

tenant_a_observed_properties / tenant_b_observed_properties

einen TenantObservedPropertyService

Durchsetzung

Die Schichtregel wird ausschließlich in Prosa durchgesetzt. Es gibt keine Werkzeugkette dafür — kein import-linter, keine Tidy-Imports-Regel, keine Lint- oder Pre-Commit-Stufe — und es ist auch keine gewünscht. Prosa kann man widersprechen, einem Grep nur genügen. Das Thema kehrt als eigenes Vorhaben zurück, falls sich zeigt, dass die Regel verrottet ist.

Drei prüfbare Formen dienen als Leitfaden für das Code-Review — das, worauf man beim Lesen eines Diffs achtet:

prüfbare Form was sie schützt

kein .query(), kein .filter( und kein blankes self.frost außerhalb von repositories/external/frost_repository.py

„Was das Repository kapselt, ist der Client" — der Query-Builder und die OData-Filter bleiben drin.

kein fsc-Typ oberhalb von services/

die obere Grenze der FROST-Entitäten; der Grund, warum /telemetry ein einziger Aufruf ist.

kein rohes requests. in services/ außer in tenant_healthcheck_service.py

die eine begründete Ausnahme bleibt die eine, siehe Die benannten Ausnahmen.

Die erste Form ist als Lesehilfe formuliert und nicht als Grep: self.frost benennt eine Schicht tiefer den rohen SensorThingsService und in DeviceExportService das TenantFrostRepository. Geprüft wird der Typ hinter dem Attribut — greift dieser Zugriff am Repository vorbei auf den Client?

Alle drei binden src/ und nicht tests/. Das ist Absicht: Tests lesen FROST bewusst roh, damit sie einen Schreibvorgang unabhängig von dem Code prüfen können, der ihn ausgelöst hat. Ein Test, der dieselbe Abstraktion benutzt wie der Code unter Test, prüft sie nicht.