Das Produkt und sein Marktkontext#
Eine modulare B2B-Plattform für das Supply-Chain-Management liefern — eine, bei der sich jedes Modul herauslösen und an ein beim Kunden bestehendes System anbinden lässt. Statt ein „Rip and Replace" zu erzwingen, bindet MTCT ein, was der Kunde bereits hat — Fakturownia für Rechnungen, externes WMS, ERP, HR — und schließt mit nativen Modulen die Lücken dort, wo sie fehlen. Architektur: hexagonal + CQRS + Domain Events, jede externe Integration hinter einem Port, jedes Modul auf drei Ebenen getestet (Unit, BDD, Integration mit Testcontainers).
Was wir lösen mussten#
Eine multimodulare Plattform ergibt nur dann Sinn, wenn ihre Module wirklich austauschbar sind — und nicht erst dann, wenn „wir sagen, dass sie es sind". Das verlangte frühe, disziplinierende Architekturentscheidungen. Jeder der zwölf Punkte unten ist eine konkrete Herausforderung, auf die wir eine Antwort brauchten, bevor wir den ersten Controller schrieben.
Plug-and-play-Module
Jeder der 8 Bounded Contexts muss eigenständig laufen oder sich an das externe System des Kunden anbinden. Keine Magie — Ports und Behavioral Contracts.
Kommunikation ohne direkte Imports
Module importieren sich nie gegenseitig. Sie kommunizieren über QueryBus (read), CommandBus (write) und Domain Events (Reaktivität). Grenzen sind erzwungen, nicht vereinbart.
Modularer Monolith mit Microservices-Plan
CQRS-Busse sind eine Transportschicht. Microservices-Migration = In-Memory-Bus gegen HTTP/gRPC oder RabbitMQ tauschen. Kein Domain-Rewrite.
Datenkonsistenz
ACID innerhalb eines Aggregats (ein Modul, eine Transaktion). Eventual Consistency zwischen Modulen — Domain Events propagieren Änderungen asynchron.
Durchgängige Typsicherheit end-to-end
OpenAPI → Kubb → TanStack Query Hooks. Eine Änderung im NestJS-Controller wird zum Compile-Error in React, nicht zum stillen Runtime-Crash.
Domain Driven Design in der Praxis
Aggregate mit Methoden, Value Objects (Money, GoodDimensions), Domain Errors. DDD als Design-Disziplin, nicht nur als Ordnername.
Tests auf drei Ebenen
Unit (Domain-Logik, ~513 Tests) + BDD-Handler (CQRS mit In-Memory-Repos) + Integration (Testcontainers mit echtem PostgreSQL).
Autorisierung als Port
Module wissen nichts von Rollen. Sie fragen AuthorizationPort.canDo(action, user). Migration auf Keycloak / Auth0 = Austausch einer einzigen Implementierung.
Integrationen mit Kundensystemen
Fakturownia.pl, externes WMS, ERP, HR, TMS — jede Integration hinter einem Port mit Behavioral Contract. Den Adapter schreiben wir, wenn wir die echte API kennen.
CLI für Operatoren
nest-commander teilt sich die CQRS-Busse mit HTTP — eine Logik, zwei Einstiegspunkte. Der Operator macht im Terminal dasselbe wie der Nutzer im UI.
ESM-natives TypeScript 6
Gesamter Stack auf ESM, nodenext-Resolution, keine CJS-only-Abhängigkeiten. Ein Stack, der für die nächsten 5 Jahre bereit ist, statt technischer Schulden ab Tag eins.
Mehrsprachiges UI ab Tag eins
Inlang Paraglide — kompilierte Übersetzungen, typsichere Keys, null Runtime-Overhead. PL und EN sind First-Class-Citizens, kein Nachgedanke.
Tests sind keine optionale Nacharbeit — sie sind Teil des Features. Ohne sie gibt es kein „fertig".
Ein ähnlich komplexes Projekt? Sprechen wir darüber.
Module, Rollen, Integrationen in bestehende Systeme und Skalierung — wir wissen, wie daraus ein stimmiges Produkt wird.
Wie wir den Produktkern bauen #
Drei Phasen, in denen ein MTCT-Modul entsteht. Dasselbe Muster für alle acht Bounded Contexts — Vorhersehbarkeit ist ein Produktmerkmal, keine Bürokratie.
Bounded Context und Verträge
Wir schneiden das Modul mit eigener Domänensprache, eigenen Ports und eigenen Tabellen heraus (Präfix `warehouse_`, `sales_`, …). Wir entscheiden, was wir als Domain Event veröffentlichen und was intern bleibt. Keine physischen Foreign Keys zwischen Modulen — logische Referenzen (UUID).
Domain-First-Implementierung
Aggregate mit Methoden, Command Handler, Query Handler, Domain Events. Controller sind eine dünne HTTP-Schicht, das ORM ist ein Infrastrukturdetail. Jeder Test wird zusammen mit dem Feature geschrieben — nie danach.
Behavioral Contracts für Adapter
Statt spekulativer Adapter für „irgendwelche" externen Systeme — definieren wir den Port und eine Reihe von Szenarien „was der Adapter können muss". Erster Kunde = erster Adapter, aber Interface und Tests stehen bereit.
Volle technische Eigenverantwortung
Ein quelloffener, ESM-nativer Stack: PostgreSQL, NestJS, MikroORM, Vitest. Null Vendor-Lock-ins. Code bereit für Audit, für die Übergabe an das Team des Kunden, für die Migration auf eigene Infrastruktur. Architektur vorbereitet für die Extraktion in Microservices, ohne die Domäne neu zu schreiben.
Wie das Produkt in der Praxis aussieht#
Acht Ansichten aus der Referenzinstanz von MTCT — vom operativen Zentrum bis zum Rechnungsmodul. In einer realen Einführung passen wir Layout, Farbe und Modulumfang an Branding und Bedarf des Kunden an.
Die Screens stammen aus der Referenzinstanz von MTCT mit geseedeten Demodaten. In einer realen Einführung passen wir Farbe, Layout und Modulumfang an Branding und Bedürfnisse des Kunden an.
Was in der Box steckt#
Zahlen aus dem aktuellen MTCT-Kern. Jede ist die Konsequenz einer konkreten Architekturentscheidung, kein Nebeneffekt.
Drei Entscheidungen, die das Produkt definieren#
Plug-and-play kommt nicht aus dem Marketing. Es kommt aus drei konkreten Architekturentscheidungen, getroffen bevor wir das erste Modul schrieben. Jede hat ihren eigenen ADR — mit Begründung, Konsequenzen und verworfenen Alternativen.
CQRS-Bus als einziger Vertrag zwischen Bounded Contexts
Ein modularer Monolith, in dem sich Module gegenseitig importieren, ist ein Monolith, der vorgibt, mehr zu sein. Der erste Lazy Import bricht die Isolation, der zweite erzeugt eine zyklische Abhängigkeit, der dritte macht aus dem „austauschbaren Modul" eine Fiktion.
In MTCT importieren sich Module nie gegenseitig. Sie kommunizieren ausschließlich über drei Busse: QueryBus (modulübergreifende Reads — z. B. fragt die Spedition das CRM nach den Verfügbarkeitszeiten des Kunden), CommandBus (Writes — eine Änderung in einem anderen Modul erzwingen), EventBus (Reaktivität — der Vertrieb publiziert OrderPlaced, das Lager reserviert die Ware, die Spedition reiht sie in die Planung ein).
Konsequenz: die Migration auf Microservices ist ein Austausch der Bus-Implementierung, kein Domain-Rewrite. In-Memory-Bus → HTTP/gRPC für Reads, RabbitMQ/Kafka für Events. Domäne, Handler und Aggregate bleiben unverändert. Das ist kein Versprechen — es ist eine architektonische Garantie, erzwungen durch das Verbot direkter Imports.
Standalone-first — den Adapter schreiben wir, wenn die echte API bekannt ist
Integrations-Frameworks locken mit „universellen Adaptern" für gängige Systeme — WMS, ERP, Rechnungswesen. Klingt pragmatisch, in der Praxis aber: Jede solche Integration beruht darauf zu raten, wie die API „aussehen sollte". Der erste echte Kunde mit System v3 statt v4 = ein Rewrite von Grund auf.
In MTCT sitzt jedes externe System hinter einem Port (z. B. InvoicePort, WarehouseSyncPort). Bevor wir einen Kunden haben, definieren wir nur das Interface und einen Behavioral Test Contract — eine Reihe von Szenarien „was der Adapter können muss". Den Adapter selbst schreiben wir erst, wenn wir die konkrete API des Kunden kennen.
Konsequenz: null spekulativer Code, null tote Integrationen. Der Kern ist fertig, Adapter schreiben wir pro Einführung dazu — und jeder ist ab dem ersten Tag eine präzise Passung an die echte API, kein Kompromiss.
Autorisierung als Port — Module wissen nichts von Rollen
Kunden haben unterschiedliche Autorisierungsanforderungen. Einem kleinen Betrieb genügen Rollen in der Datenbank. Ein mittelständisches Unternehmen will LDAP. Ein Konzern verlangt SSO über Keycloak oder Azure AD. Liegt die Autorisierungslogik über die Module verstreut, ist jede dieser Varianten ein Rewrite von acht Bounded Contexts.
In MTCT prüfen Module nie direkt Rollen. Sie fragen AuthorizationPort.canDo(action, user, context). Mehr noch — das Modul System (Identität: E-Mail, Name, Rollen) ist physisch vom Modul Auth (Credentials: passwordHash, Refresh Tokens) getrennt. Das erlaubt, das eine auszutauschen, ohne das andere anzufassen.
Konsequenz: die Migration von lokalem RBAC auf Keycloak = das Schreiben eines einzigen KeycloakAuthorization-Adapters. Der Rest des Produkts merkt davon nichts. Dasselbe gilt für Auth0, ein eigenes OIDC oder jede neue Lösung, die sich der Kunde ausdenkt.
Tech-Stack#
Die Bibliothekswahl rund um die drei Entscheidungen oben. Standard, gut gepflegt, quelloffen, ESM-nativ. Kein Verwaisungsrisiko, keine Vendor-Lock-ins.
SPRACHE & RUNTIME
BACKEND-FRAMEWORK
ORM & DATENBANK
VALIDIERUNG
TESTING
AUTH & SICHERHEIT
FRONTEND
API & CLIENT
Umgebungen und Einführungswege#
Vier Umgebungen — vom lokalen Dev bis zur Single-Tenant-Produktion beim Kunden. Je näher am Kunden, desto strenger die Kriterien und desto größer der Anteil an Customizing.
Plug-and-play-Module — ein Port und drei Implementierungen #
Jedes Modul läuft solo. Oder bindet sich an das an, was der Kunde bereits hat.
Plug-and-play klingt wie ein Slogan. Darunter steckt eine sehr konkrete Einschränkung: ein Modul darf nicht wissen, wer die Implementierung liefert. Es darf nicht annehmen, dass Rollen in der Datenbank liegen, es darf kein bestimmtes SDK aufrufen, es darf den Keycloak-Client nicht als Runtime-Dependency installieren.
Die Standardantwort — „alles hinter einem Interface" — reicht nicht. Ein Interface ohne Behavioral Contract ist ein Versprechen ohne Durchsetzung. Der erste Adapter, der in einem unerwarteten Szenario null statt eines leeren Arrays zurückgibt, bricht das ganze Modul — und niemand merkt es bis zur Produktion.
In MTCT entsteht neben jedem Port ein Satz von Verhaltensszenarien — Tests, die jeder Adapter bestehen muss, unabhängig vom Backend (Fake, lokales RBAC, Keycloak, Auth0). Damit wird der Austausch einer Implementierung zur mechanischen Operation, nicht zum Research-Projekt. Der Port definiert das „Was", Contract Tests definieren das „Wie", und der Adapter liefert das „Womit".
// Kern: Module sprechen mit dem Port, nicht mit der Implementierung interface AuthorizationPort { canDo(action: Action, user: UserCtx): Promise<boolean>; } // drei austauschbare Implementierungen, ein Behavioral Contract: class FakeAuthorization implements AuthorizationPort { /* für Tests */ } class RoleBasedAuthorization implements AuthorizationPort { /* lokales RBAC */ } class KeycloakAuthorization implements AuthorizationPort { /* Kunde mit SSO */ }