Aus der Werkstatt
Wie wir unser CRM gebaut haben: modularer Monolith in Go, Frontend in Angular
Wir setzen für unsere Kunden Fachanwendungen um und wollten dieselben Entscheidungen erst an uns selbst treffen. Also haben wir unser CRM gebaut, statt eines zu mieten. Was dabei entstanden ist, zeigt ganz gut, wie wir über Architektur denken.
Die Ausgangsentscheidung: ein Monolith, aber ein modularer
Microservices lösen ein organisatorisches Problem: mehrere Teams, die unabhängig voneinander ausliefern wollen. Dieses Problem hatten wir nicht. Was wir hatten, war ein überschaubares Team und der Wunsch, Fachbereiche sauber voneinander zu trennen.
Ein modularer Monolith gibt genau das: Ein Prozess, eine Datenbank, ein Deployment — aber intern klar geschnittene Module mit eigenen Modellen, eigener Datenzugriffsschicht und eigenen Endpunkten. Kein Netzwerkaufruf zwischen Fachbereichen, keine verteilten Transaktionen, keine Frage, welche Version von welchem Dienst gerade läuft.
Der Preis ist Disziplin. Weil nichts technisch verhindert, dass ein Modul direkt in die Tabellen eines anderen greift, muss man es sich verbieten. Wir haben das über den Aufbau erzwungen: Jedes Modul liegt in einem eigenen Verzeichnis mit Modell, Repository, Service und Handler und wird zentral registriert. Was ein Modul nach außen anbietet, ist genau das, was in seiner Registrierung steht.
Der Modulschnitt
Wir haben nach Fachbereichen geschnitten, nicht nach technischen Schichten. Kontakte und Organisationen, Projekte, Rechnungen, Mahnwesen, Lieferanten, Zeiterfassung, Akquise, Dokumentvorlagen, Einstellungen — jedes davon ist ein Modul mit einem klaren Zweck.
Die Probe aufs Exempel: Wenn ein Modul beschrieben werden kann, ohne ein anderes zu erwähnen, stimmt der Schnitt. Wo das nicht ging, war der Schnitt falsch, und wir haben nachgezogen. Das Rechnungsmodul kennt Projekte, weil Rechnungen zu Projekten gehören — aber das Projektmodul weiß nichts von Mahnstufen.
Ein Nebeneffekt, den wir unterschätzt hatten: Diese Größe ist auch die Größe, in der ein Mensch — oder ein KI-Assistent — den Code noch vollständig im Kopf hat. Änderungen bleiben lokal, und das Vertrauen in eigene Eingriffe steigt spürbar.
Rollen und Rechte als eigene Schicht
Rechte nachträglich einzubauen ist eine der teuersten Nachrüstungen überhaupt, weil sie jede Route und jede Ansicht berührt. Wir haben deshalb früh ein Modell gewählt, das pro Fachbereich eine von drei Stufen kennt: kein Zugriff, lesen, schreiben.
Jede Rolle ist damit eine Abbildung von Bereich auf Stufe, jede Route hängt hinter genau einem Bereich. Das ist bewusst grob — feinere Modelle mit Rechten pro Datensatz wären mächtiger, aber niemand pflegt sie zuverlässig, und was niemand pflegt, wird stillschweigend zu „alle dürfen alles".
Eine Feinheit, die sich bewährt hat: Ein technischer Zugang, der nur Akquise-Funde einliefern darf, hängt hinter einem eigenen Bereich statt hinter dem allgemeinen Akquise-Bereich. So bekommt ein automatisierter Prozess genau ein Recht und nicht versehentlich das Recht, Datensätze zu löschen.
Tests: schnell im Alltag, ehrlich vor dem Merge
Wir fahren zwei Stufen. Reine Unit-Tests laufen ohne Datenbank in Sekunden — sie prüfen die Fachlogik gegen erzeugte Test-Doubles der Datenzugriffsschicht. Integrationstests laufen gegen eine echte PostgreSQL-Instanz und liegen hinter einem Build-Tag, damit sie den schnellen Durchlauf nicht ausbremsen.
Damit die Integrationstests parallel laufen können, legt sich jedes Testpaket ein eigenes Datenbankschema an. Das war eine der lohnendsten Investitionen: Die vollständige Testsuite braucht dadurch weniger als eine Minute statt mehrerer.
Die Falle dabei, an der wir uns einmal die Finger verbrannt haben: Ein Build-Tag versteckt Code auch vor dem Compiler und den statischen Prüfern. Ein kaputter Integrationstest fällt im Standarddurchlauf schlicht nicht auf. Seitdem gehört der Durchlauf mit gesetztem Tag fest zur Routine vor jedem Merge.
Das Frontend: Angular mit Signals, ohne Zone.js
Das Frontend ist eine Angular-Anwendung mit eigenständigen Komponenten und signalbasiertem Zustand, die ohne Zone.js läuft. Der Gewinn ist weniger unnötige Neuberechnung und ein vorhersagbareres Änderungsverhalten.
Der Preis steht in der Praxis an einer bestimmten Stelle: Wird ein einfaches, nicht-signalbasiertes Feld asynchron zugewiesen — etwa das Formularobjekt nach dem Laden vom Server — bemerkt das niemand von allein. Man muss explizit eine Prüfung anstoßen, sonst erscheinen die geladenen Werte erst nach einem vollständigen Neuladen der Seite. Das ist genau die Art Detail, die man einmal debuggt und danach für immer weiß.
Was wir anders machen würden
Zwei Dinge. Erstens: Wir hätten Listenendpunkte von Beginn an paginiert. Nachträglich die Antwortform von einer Liste auf ein Objekt mit Einträgen und Gesamtzahl umzustellen, berührt Backend, Frontend, Tests und Dokumentation gleichzeitig — für ein paar Zeilen mehr am Anfang wäre das erspart geblieben.
Zweitens: Wir hätten Filterparameter früher zu einem eigenen Typ zusammengefasst, statt sie einzeln durch die Schichten zu reichen. Ab dem fünften Parameter ist eine Signatur mit lauter gleichartigen Argumenten eine Fehlerquelle — und der Umbau ist später mühsamer als die Vorsicht am Anfang.
Häufige Fragen
Warum Go und nicht Node oder Java?
Go liefert eine einzelne Binärdatei ohne Laufzeitumgebung, startet in Millisekunden und bleibt beim Speicherverbrauch berechenbar — das macht den Betrieb einfach und günstig. Dazu kommt eine Standardbibliothek, die für Webdienste fast alles mitbringt, sodass die Abhängigkeitsliste kurz bleibt.
Lässt sich ein modularer Monolith später aufteilen?
Ja, und das ist einer seiner Vorzüge. Wenn ein Modul tatsächlich eigene Skalierung braucht, ist der Schnitt bereits da — man löst ein Verzeichnis heraus statt einen Knoten. Der umgekehrte Weg, verteilte Dienste wieder zusammenzuführen, ist deutlich unangenehmer.
Bauen Sie so etwas auch für uns?
Ja, CRM- und Fachanwendungsentwicklung ist eine unserer Leistungen. Der beschriebene Aufbau ist unser Standardvorgehen für Anwendungen dieser Größenordnung.
