A good folder structure doesn’t help if anyone can import anything from anywhere. Boundaries are the rules about who is allowed to depend on whom. They make the application easy to change: when you know that shared doesn’t depend on any feature, you know you can change it without breaking the domain.
The dependency matrix
| imports from → file from ↓ | app (config, routes) | core | shared | features/products | features/cart |
|---|---|---|---|---|---|
| app (config, routes) | |||||
| core | |||||
| shared | |||||
| features/products | |||||
| features/cart |
features → core, shared · core → shared · shared → nothing else. Never the other way round, and never feature → feature directly.
Path aliases
{
"compilerOptions": {
"paths": {
"@core/*": ["./src/app/core/*"],
"@shared/*": ["./src/app/shared/*"],
"@features/*": ["./src/app/features/*"]
}
}
}
// (exactly the configuration used by this site)Barrel files (index.ts)
// features/users/index.ts — the PUBLIC API of the feature (the only 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 — a convenience for importing several UI components at once
export { Button } from './button';
export { Card } from './card';
export const PAGE_KIT = [Button, Card] as const;Automatic verification
Rules written only in documentation get broken in the first week. Put them in lint:
// eslint.config.mjs — boundary rules checked automatically
export default [
{
files: ['src/app/shared/**/*.ts'],
rules: {
'no-restricted-imports': ['error', {
patterns: [
{ group: ['@features/*'], message: 'shared must not depend on features.' },
{ group: ['@core/*'], message: 'shared does not depend on core — it receives data through inputs.' },
],
}],
},
},
{
files: ['src/app/features/**/*.ts'],
rules: {
'no-restricted-imports': ['error', {
patterns: [
{ group: ['@features/*/*'], message: 'Import another feature only through its public API (@features/x).' },
],
}],
},
},
];
// Alternatives: eslint-plugin-boundaries, Nx @nx/enforce-module-boundaries (with tags)How do two features communicate without importing each other?
- Through the URL: the product sends the user to
/cart?add=42. - Through core: a global store (
CartStoreincore) that both use. - Through shared: a common type or generic component.
- Through the public API: if A really needs a component from B, B exports it explicitly in
index.ts.
Circular dependencies: how to break them
A cycle appears when A imports from B and B, directly or through others, imports from A. The symptoms are odd: a class is undefined at startup, tests only pass in a different order, the build slows down. Three ways out, in the order worth trying:
- Move the shared part down. If both need the same type or function, move it to
sharedorcore, and the cycle disappears. - Invert the dependency. The lower one declares a contract (an
InjectionTokenor an abstract class), and the upper one implements and provides it. The lower one no longer imports anything from above. - Communicate without imports. Through the URL, a store in
coreor an event, as in the section above.
// Before: core/notifier imported from features/orders for the link → cycle. After: core declares a 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); // no longer imports anything from features
orderShipped(id: string) {
this.show(`The order has shipped`, this.link(id));
}
}
// features/orders/orders.providers.ts — the feature provides the implementation
export const provideOrders = () => [
{ provide: ORDER_LINK, useValue: (id: string) => `/orders/${id}` },
];Boundaries for external libraries too
The same idea applies to packages in node_modules. If 40 components import a chart or date library directly, replacing it means changing 40 files. Put the library behind a small adapter of your own, and forbid the direct import in lint.
// shared/charts/line-chart.ts — the only file that imports the library
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 }[]>();
// the rest of the app only sees <app-line-chart [points]="...">
}To enforce the same rules between libraries, see Monorepo, libraries & boundaries; the zones themselves are described in Shared vs feature.
Common pitfalls
- A barrel in every folder → circular dependencies and slow builds.
- An alias that hides violations:
@features/cart/data-access/cart-storefrom another feature is still a violation — that is why there is a lint rule. - “Just this once”: a single exception becomes a pattern. Move the code to the right place.
Cheat sheet
features → core, shared allowed
core → shared
shared → nothing in the app
feature → feature not directly: through the URL, a store in core or a public API
cycle? 1) move the shared part down 2) invert with a token 3) communicate without imports
external library? an adapter in shared + no-restricted-imports
barrel: just one, at the root of the feature