Eine gute Ordnerstruktur hilft nicht, wenn jeder alles von überall importieren darf. Boundaries sind die Regeln darüber, wer von wem abhängen darf. Sie machen die Anwendung leicht änderbar: Wenn du weißt, dass shared von keinem Feature abhängt, kannst du es ändern, ohne die Domäne zu beschädigen.
Die Abhängigkeitsmatrix
| importiert aus → Datei aus ↓ | App (Config, Routen) | core | shared | features/products | features/cart |
|---|---|---|---|---|---|
| App (Config, Routen) | |||||
| core | |||||
| shared | |||||
| features/products | |||||
| features/cart |
features → core, shared · core → shared · shared → nichts sonst. Niemals umgekehrt, und niemals direkt Feature → Feature.
Path Aliases
{
"compilerOptions": {
"paths": {
"@core/*": ["./src/app/core/*"],
"@shared/*": ["./src/app/shared/*"],
"@features/*": ["./src/app/features/*"]
}
}
}
// (genau die Konfiguration, die diese Seite verwendet)Barrel Files (index.ts)
// features/users/index.ts — die ÖFFENTLICHE API des Features (das einzige Barrel)
export { UserAvatar } from './ui/user-avatar';
export { UsersStore } from './data-access/users-store';
export type { User, Role } from './models/user';
// shared/ui/index.ts — praktisch, um mehrere UI-Komponenten auf einmal zu importieren
export { Button } from './button';
export { Card } from './card';
export const PAGE_KIT = [Button, Card] as const;Automatische Prüfung
Regeln, die nur in der Dokumentation stehen, werden in der ersten Woche gebrochen. Packe sie ins Linting:
// eslint.config.mjs — automatisch geprüfte Boundary-Regeln
export default [
{
files: ['src/app/shared/**/*.ts'],
rules: {
'no-restricted-imports': ['error', {
patterns: [
{ group: ['@features/*'], message: 'shared darf nicht von Features abhängen.' },
{ group: ['@core/*'], message: 'shared hängt nicht von core ab — es bekommt Daten über Inputs.' },
],
}],
},
},
{
files: ['src/app/features/**/*.ts'],
rules: {
'no-restricted-imports': ['error', {
patterns: [
{ group: ['@features/*/*'], message: 'Importiere ein anderes Feature nur über dessen öffentliche API (@features/x).' },
],
}],
},
},
];
// Alternativen: eslint-plugin-boundaries, Nx @nx/enforce-module-boundaries (mit Tags)Wie kommunizieren zwei Features, ohne sich gegenseitig zu importieren?
- Über die URL: Das Produkt schickt den Nutzer zu
/cart?add=42. - Über core: ein globaler Store (
CartStoreincore), den beide nutzen. - Über shared: ein gemeinsamer Typ oder eine generische Komponente.
- Über die öffentliche API: Wenn A wirklich eine Komponente aus B braucht, exportiert B sie explizit in
index.ts.
Zirkuläre Abhängigkeiten: wie du sie auflöst
Ein Zyklus entsteht, wenn A aus B importiert und B, direkt oder über andere, aus A. Die Symptome sind seltsam: Eine Klasse ist beim Start undefined, Tests laufen nur in anderer Reihenfolge durch, der Build wird langsamer. Drei Auswege, in der Reihenfolge, in der sie sich lohnen:
- Den gemeinsamen Teil nach unten schieben. Brauchen beide denselben Typ oder dieselbe Funktion, verschiebe ihn nach
sharedodercore, und der Zyklus verschwindet. - Die Abhängigkeit umkehren. Der untere Teil deklariert einen Vertrag (ein
InjectionTokenoder eine abstrakte Klasse), der obere implementiert und stellt ihn bereit. Der untere importiert nichts mehr von oben. - Ohne Import kommunizieren. Über die URL, einen Store in
coreoder ein Event, wie im Abschnitt oben.
// Vorher: core/notifier importierte für den Link aus features/orders → Zyklus. Nachher: core deklariert ein Token.
// core/notifications/notifier.ts
export const ORDER_LINK = new InjectionToken<(id: string) => string>('ORDER_LINK');
@Service()
export class Notifier {
private readonly link = inject(ORDER_LINK); // importiert nichts mehr aus features
orderShipped(id: string) {
this.show(`Die Bestellung wurde versendet`, this.link(id));
}
}
// features/orders/orders.providers.ts — das Feature stellt die Implementierung bereit
export const provideOrders = () => [
{ provide: ORDER_LINK, useValue: (id: string) => `/orders/${id}` },
];Grenzen auch für externe Bibliotheken
Dieselbe Idee gilt für Pakete in node_modules. Importieren 40 Komponenten eine Chart- oder Datumsbibliothek direkt, bedeutet ein Austausch 40 geänderte Dateien. Stell die Bibliothek hinter einen kleinen eigenen Adapter und verbiete den direkten Import per Lint.
// shared/charts/line-chart.ts — die einzige Datei, die die Bibliothek importiert
import { Chart } from 'some-chart-lib';
@Component({ selector: 'app-line-chart', template: '<canvas #c></canvas>' })
export class LineChart {
readonly points = input.required<readonly { x: string; y: number }[]>();
// der Rest der App sieht nur <app-line-chart [points]="...">
}Um dieselben Regeln zwischen Bibliotheken durchzusetzen, siehe Monorepo, Bibliotheken & Grenzen; die Zonen selbst beschreibt Shared vs. Feature.
Häufige Stolperfallen
- Ein Barrel in jedem Ordner → zirkuläre Abhängigkeiten und langsame Builds.
- Ein Alias, der Verstöße verschleiert:
@features/cart/data-access/cart-storeaus einem anderen Feature ist trotzdem ein Verstoß — deshalb gibt es die Lint-Regel. - „Nur dieses eine Mal“: Eine einzige Ausnahme wird zum Muster. Verschiebe den Code an die richtige Stelle.
Spickzettel
features → core, shared erlaubt
core → shared
shared → nichts aus der App
feature → feature nicht direkt: über URL, Store in core oder öffentliche API
Zyklus? 1) gemeinsamen Teil nach unten 2) mit Token umkehren 3) ohne Import kommunizieren
externe Bibliothek? ein Adapter in shared + no-restricted-imports
Barrel: nur eins, an der Wurzel des Features