Hexagonale Architektur in NestJS: Struktur, Regeln und wann man sie weglässt
Wie Ports und Adapter auf ein NestJS-Projekt passen: modulbasierte Ordner, die wichtige Abhängigkeitsregel, Nest-DI-Verdrahtung und wann man es lässt.
Ein typisches NestJS-Projekt beginnt mit der Struktur, die die CLI erzeugt: ein Modul mit Controller, Service und Entity. Das funktioniert lange gut. Dann importiert der Service das ORM direkt, die Entity dient zugleich als API-Antwort, Geschäftsregeln verteilen sich auf Controller und Abfragen, und um eine Regel zu testen, muss eine Datenbank starten.
Die hexagonale Architektur, auch Ports und Adapter genannt, verhindert diese Entwicklung. Alistair Cockburn hat sie 2005 beschrieben. Dieser Artikel zeigt, wie sie auf NestJS passt und, mindestens ebenso wichtig, wann sich der Aufwand nicht lohnt. Er bleibt bewusst bei Konzept und Struktur und verzichtet auf ein vollständiges Beispiel.
Die Idee in drei Teilen
- Der Kern enthält die Geschäftsregeln: das Domänenmodell (Entities, Value Objects) und die Use Cases, die sie koordinieren. Er weiß nichts über HTTP, SQL, Queues oder Nest.
- Ports sind Schnittstellen, die dem Kern gehören. Ein eingehender Port beschreibt, was die Außenwelt vom Kern verlangen kann (
PlaceOrderUseCase). Ein ausgehender Port beschreibt, was der Kern von außen braucht (OrderRepository,PaymentGateway). Ausgehende Ports werden meist in der Domäne deklariert, weil sie in deren Begriffen formuliert sind; eingehende Ports gehören zur Application-Schicht, die die Use Cases koordiniert. Manche Teams legen alle Ports in die Application-Schicht, was genauso gültig ist, solange der Kern sie besitzt und nicht die Adapter. - Adapter implementieren oder rufen Ports. Alles Externe (Datenbank, Zahlungsanbieter, Mail-Dienst) wird nur über einen Adapter erreicht. Ein treibender Adapter (ein HTTP-Controller, ein CLI-Befehl, ein Queue-Consumer) ruft einen eingehenden Port. Ein getriebener Adapter (ein TypeORM-Repository, ein Client für die Zahlungs-API) implementiert einen ausgehenden Port.
Zusammengehalten wird das Ganze von einer Regel zur Abhängigkeitsrichtung: Adapter hängen vom Kern ab, der Kern hängt nie von Adaptern ab. Der Kern definiert als Schnittstelle, was er braucht, und ein Adapter erfüllt sie. Deshalb können Sie die Datenbank austauschen oder einen Use Case ohne Datenbank testen.
Eine Ordnerstruktur: Module plus AOP-Ordner
Gruppieren Sie zuerst nach Modul (ein Ordner pro fachlicher Fähigkeit unter modules/), innerhalb des Moduls dann nach Rolle. Querschnittsthemen wie Logging liegen außerhalb der Module in einem Ordner aop/:
src/
aop/
decorators/
filters/
domain-exception.filter.ts
guards/
interceptors/
logging.interceptor.ts
modules/
orders/
domain/
order.ts
order-line.ts
order-status.ts
ports/
order.repository.ts
payment.gateway.ts
application/
ports/
place-order.use-case.ts
place-order.service.ts
adapters/
in/
http/
orders.controller.ts
place-order.request.ts
out/
persistence/
order.entity.ts
order.mapper.ts
typeorm-order.repository.ts
payment/
http-payment.gateway.ts
orders.module.ts
app.module.ts
main.ts
Was wohin gehört:
domain/enthält reine TypeScript-Klassen mit den Geschäftsregeln („eine Bestellung ohne Positionen kann nicht aufgegeben werden“) sowie die ausgehenden Ports, die die Domäne braucht: die Repository-Schnittstelle und die Schnittstelle zum Zahlungsdienst. Das sind reine Schnittstellen in der Sprache der Domäne. Keine Dekoratoren von Nest oder dem ORM.application/enthält die Use Cases und die eingehenden Ports (was die Außenwelt verlangen darf). Ein Use Case lädt, was er braucht, über die ausgehenden Ports, ruft die Domäne auf und speichert das Ergebnis darüber.adapters/in/ist der Ort für Request-Validierung und DTOs. Ein Controller übersetzt HTTP in einen Use-Case-Aufruf und das Ergebnis zurück in eine Antwort.adapters/out/enthält die Implementierungen der ausgehenden Ports: alles, was tatsächlich eine Datenbank oder einen externen Dienst berührt, auch die ORM-Entity.http-payment.gateway.tsist der Adapter, der den Zahlungsanbieter aufruft;payment.gateway.tsin der Domäne ist nur die Schnittstelle, die er erfüllt. Der Mapper wandelt zwischen ORM-Entity und Domänenobjekt um.orders.module.tsist die einzige Stelle, die weiß, welcher Adapter welchen Port umsetzt.aop/enthält Querschnittsthemen, die für viele Module gelten: Logging-Interceptors, Exception-Filter, die Domänenfehler auf HTTP-Antworten abbilden, Guards und gemeinsame Dekoratoren. Hier gehören keine Geschäftsregeln hinein, und der Ordner darf nichts ausmodules/importieren. Module nutzen ihn über Nests Mechanismen (globale Registrierung oder Dekoratoren), nie umgekehrt.
Auch Module bleiben voneinander getrennt. Braucht shipping etwas aus orders, geht das über einen exportierten Port oder Use Case dieses Moduls, nicht über dessen Adapter oder Domänenklassen.
Verdrahtung mit Nests Dependency Injection
TypeScript-Interfaces gibt es zur Laufzeit nicht, deshalb kann Nest sie nicht als Injection-Token verwenden. Nutzen Sie ein Symbol oder eine String-Konstante neben dem Interface und binden Sie sie im Modul:
// domain/ports/order.repository.ts
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | undefined>;
}
export const ORDER_REPOSITORY = Symbol("ORDER_REPOSITORY");
// orders.module.ts
@Module({
controllers: [OrdersController],
providers: [
PlaceOrderService,
{ provide: ORDER_REPOSITORY, useClass: TypeOrmOrderRepository },
],
})
export class OrdersModule {}
Der Use Case fordert das Token mit @Inject(ORDER_REPOSITORY) an. Im Test stellen Sie unter demselben Token eine In-Memory-Implementierung bereit, und der Use Case merkt keinen Unterschied. Auch eine abstrakte Klasse taugt als Token und spart die zusätzliche Konstante, dafür ist der Port dann eine Klasse.
Ein pragmatischer Punkt: Der Use-Case-Service darf Nests @Injectable() tragen. Dekoratoren aus der Domäne herauszuhalten ist wichtig, sie auch aus der Application-Schicht herauszuhalten lohnt sich selten.
Die Regel durchsetzen
Eine Abhängigkeitsregel, die nur in einem Dokument steht, wird innerhalb eines Monats gebrochen. Prüfen Sie sie in der CI. Fallow kann das: Die Architektur-Grenzen beschreiben Zonen (Mengen von Dateien) und welche Zone aus welcher importieren darf, und fallow architecture meldet jeden Import, der eine verbotene Grenze überschreitet. Eine Konfiguration für die obige Struktur, in .fallowrc.json:
{
"boundaries": {
"zones": [
{ "name": "domain", "patterns": ["src/modules/*/domain/**"] },
{ "name": "application", "patterns": ["src/modules/*/application/**"] },
{ "name": "adapters", "patterns": ["src/modules/*/adapters/**"] },
{ "name": "aop", "patterns": ["src/aop/**"] }
],
"rules": [
{ "from": "domain", "allow": [] },
{ "from": "application", "allow": ["domain"] },
{ "from": "adapters", "allow": ["application", "domain"] },
{ "from": "aop", "allow": [] }
]
}
}
Eine leere allow-Liste isoliert eine Zone. Hier importiert die Domäne nichts von außerhalb, die Application-Schicht darf die Domäne nutzen, und Adapter dürfen beides nutzen. aop/ darf keinen Modulcode importieren. Die *.module.ts-Dateien und app.module.ts gehören zu keiner Zone und bleiben deshalb unbeschränkt, was richtig ist, weil dort alles verdrahtet wird. Mit fallow list --boundaries sehen Sie, welche Dateien jede Zone erfasst hat, bevor Sie sich darauf verlassen.
Zwei Grenzen sollten Sie kennen. Die Regeln arbeiten auf Ordnern, sie sagen also nicht „die Application-Schicht darf @nestjs/typeorm nicht importieren“; das braucht weiterhin eine Lint-Regel oder Review. Und diese Konfiguration verhindert nicht, dass ein Modul die Adapter eines anderen Moduls importiert; dafür lohnt sich eine eigene Regel, sobald es mehr als ein oder zwei Module gibt. In einer bestehenden Codebasis meldet fallow audit nur die Befunde, die eine Änderung neu einführt, sodass Sie es einschalten können, ohne zuerst den ganzen Rückstand zu beheben. Fallow meldet außerdem ungenutzte Exporte und zirkuläre Abhängigkeiten, was hilft, Ports zu finden, die niemand mehr nutzt.
Schreiben Sie die Regel einmal, und sie zahlt sich beim ersten Mal aus, wenn ein müder Kollege eine Abkürzung nimmt.
Was man gewinnt
- Schnelle Tests für die Logik, auf die es ankommt. Use Cases laufen gegen In-Memory-Ports in Millisekunden, ohne Datenbank und ohne laufende Nest-Anwendung.
- Austauschbare Infrastruktur. Einen anderen Zahlungsanbieter anzubinden heißt, einen Adapter zu schreiben. Der Use Case bleibt unverändert.
- Ein sichtbarer Platz für jede Art von Code. Neue Entwickler:innen finden, wohin Validierung, Geschäftsregeln und Abfragen gehören.
Was es kostet
- Mehr Dateien und mehr Mapping. Ein einfaches Feld existiert nun im Request, im Domänenobjekt, in der ORM-Entity und in der Antwort. Das ist Boilerplate, die Sie oft schreiben werden.
- Umwege beim Lesen. Um zu sehen, was bei
POST /orderspassiert, folgen Sie dem Controller, einem Interface und einer Modul-Bindung. - Die Gefahr von Ports, die das ORM spiegeln. Ein
OrderRepositorymitfindByQuery(query: SelectQueryBuilder)ist ein Adapter in Verkleidung. Ein Port sollte die Sprache der Domäne sprechen:findOpenOrdersOf(customerId).
Wann man es weglässt
Für einen CRUD-Dienst, der vor allem Daten speichert und zurückgibt, bringt die hexagonale Struktur Zeremonie, aber keinen Schutz, weil es keine Regel gibt, die sich zu isolieren lohnt. Ein einzelnes Modul mit Controller, Service und Repository hat die richtige Größe.
Ein pragmatischer Weg für viele Teams: einfach anfangen und einen Port herauslösen, sobald eines davon auftritt: eine zweite Implementierung (ein anderer Anbieter, ein Fake für Tests), eine Geschäftsregel, die sich schwer testen lässt, oder ein externer Dienst, bei dem man nicht darauf vertraut, dass er gleich bleibt. Einen einzelnen Port später herauszulösen ist ein kleines Refactoring. Jeden Port vorab für Anforderungen zu bauen, die nie kommen, ist es nicht.
Ist die Wahl nicht offensichtlich, halten Sie sie schriftlich fest: Genau dafür sind Architecture Decision Records da.