Boundaries in Large Angular Apps: Feature Folders, Facades and Layers
In a large Angular app you should organize code by feature, split each feature into ui, state, data and domain layers, and allow cross-feature imports only through a public API (index.ts) with rules enforced by lint; the compiler does not know these boundaries. With real output we show three shortcuts passing ng build and being caught by eslint-plugin-boundaries, and cover the facade service and the API client layer.
Version note: The examples in this post were tested with Angular 22 and TypeScript.
Guess First
The Bookstore's Angular app has grown and there are two teams: one owns Catalog (book list, book details), the other owns Orders (cart, checkout). Two small requests arrive within one sprint. The Catalog team wants to show a "buy 3" price on the list page and imports the pricing rule from Orders directly:
import { Component, inject } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { toSignal } from '@angular/core/rxjs-interop';
import { priceCart } from '../../orders/domain/pricing';
import type { Book } from '../domain/book';
import { CatalogLookup } from '../state/catalog-lookup';
@Component({
selector: 'app-book-list-page',
imports: [CurrencyPipe],
template: `
@for (book of books(); track book.id) {
<p>{{ book.title }} · 3 copies: {{ threeCopies(book) | currency: 'EUR' }}</p>
}
`,
})
export class BookListPage {
protected readonly books = toSignal(inject(CatalogLookup).listBooks(), { initialValue: [] });
// Catalog uses Orders' pricing rule from the inside
protected threeCopies(book: Book): number {
return priceCart([{ bookId: book.id, title: book.title, unitPrice: book.price, quantity: 3 }]).total.amount;
}
}
The Orders team adds a "You may also like" list to the checkout page; to do that it calls Catalog's HTTP (Hypertext Transfer Protocol) client from the inside, and for a quick fix it also sends the order straight from the API (application programming interface) client, skipping the facade:
export class CheckoutPage {
protected readonly facade = inject(CheckoutFacade);
// Shortcut 1: Catalog's internal API client
protected readonly suggestions = toSignal(inject(CatalogApi).getBooks(), { initialValue: [] });
// Shortcut 2: skipping the facade and going straight to HTTP
private readonly api = inject(OrdersApi);
protected placeOrder(): void {
this.api.placeOrder(this.facade.cartLines()).subscribe(id => console.log(id));
}
}
There are three shortcuts: Catalog reaching into Orders, Orders reaching into Catalog, and a component skipping the state layer of its own feature. What does ng build say about this code? Who catches these three lines in the pull request? And six months from now, how does the Orders team that wants to change the pricing rule find out that the rule is also used in Catalog?
The Compiler Does Not Know Your Architecture
The actual output:
$ npx ng build
| Initial total | 254.26 kB | 69.70 kB
Application bundle generation complete. [0.774 seconds] - 2026-11-08T00:56:39.898Z
The build succeeds. For TypeScript and the Angular compiler, an import is a question of whether the file exists and whether the types match; which folder may depend on which is not a compiler concept. In C# you at least have project references and the internal access modifier; in the Modular Monolith series we blocked outside access at compile time by making the modules' inner classes internal. In TypeScript, everything that is exported is open to anyone who writes the right relative path.
That is why all three shortcuts work today and all three pile up debt. Catalog using Orders' pricing rule creates a two-way dependency between two features: Orders already gets book data from Catalog, and now Catalog depends on Orders too. Orders using Catalog's HTTP client moves the places that will break, when the Catalog team changes an endpoint in the backend, outside Catalog's own feature. A component skipping the facade means an app whose state is changed from two places: the order was sent, but the facade's status is still editing.
Organizing by Feature
The Angular Style Guide gives a clear recommendation for project structure: split code into subfolders by the app's features, and do not create folders by the type of code, such as components, services or directives. Martin Fowler's 2015 post "Presentation Domain Data Layering" says the same thing from the angle of layers: in small systems it is acceptable to split the top level by technical layers, but as any layer grows, you should split the top level into domain-oriented modules and layer each one internally. This is the frontend counterpart of the "by feature, not by layer" idea from the Vertical Slice Architecture series and the module boundaries from the Modular Monolith series on the backend.
The structure of the sample app (spec files excluded):
src/app/
catalog/
data/
catalog-api.ts
domain/
book.ts
state/
catalog-lookup.ts
ui/
book-list-page.ts
catalog.routes.ts
index.ts
orders/
data/
orders-api.ts
domain/
pricing.ts
state/
checkout-facade.ts
ui/
checkout-page.ts
index.ts
orders.routes.ts
shared/
util/
money.ts
app.config.ts
app.routes.ts
app.ts
Every feature has four folders inside, each with one responsibility:
| Folder | What it holds | What it may import |
|---|---|---|
ui | Pages and components (container and presentational) | The ui, state and domain folders of the same feature |
state | Facades and services that hold signals | The state, data and domain folders of the same feature; the public API of allowed features |
data | API clients, DTO (Data Transfer Object) types and their conversions | The data and domain folders of the same feature |
domain | Plain types and business rules | Only the domain of the same feature |
| Feature root | index.ts and the route definition: the feature's public API | The feature's own folders |
Everything a feature exposes lives in index.ts:
// Public API of the Catalog feature: the outside imports only from here
export { catalogRoutes } from './catalog.routes';
export type { Book } from './domain/book';
export { CatalogLookup } from './state/catalog-lookup';
The app's root routes load features only through this public API, with lazy loading. The Orders route definition provides the facade at the route level; when you leave the page, the cart state goes with it:
import type { Routes } from '@angular/router';
export const routes: Routes = [
{ path: 'catalog', loadChildren: () => import('./catalog').then(m => m.catalogRoutes) },
{ path: 'checkout', loadChildren: () => import('./orders').then(m => m.ordersRoutes) },
{ path: '', pathMatch: 'full', redirectTo: 'catalog' },
];
import type { Routes } from '@angular/router';
import { CheckoutFacade } from './state/checkout-facade';
export const ordersRoutes: Routes = [
{
path: '',
// Facade at route level: the state goes away when you leave the page
providers: [CheckoutFacade],
loadComponent: () => import('./ui/checkout-page').then(m => m.CheckoutPage),
},
];
index.ts is the lightweight frontend counterpart of the Contracts project we saw in the Modular Monolith series. But on its own it enforces nothing; the compiler accepts the path ../../catalog/data/catalog-api just as happily. The rule needs a tool.
Enforcing Boundaries with Lint
eslint-plugin-boundaries for ESLint (7.4.2 in this post) lets you group files into "elements" by folder patterns and define with policies which element may import which. The code version of the table above:
import boundaries from 'eslint-plugin-boundaries';
import tseslint from 'typescript-eslint';
export default [
{
files: ['src/app/**/*.ts'],
ignores: ['**/*.spec.ts'],
languageOptions: { parser: tseslint.parser },
plugins: { boundaries },
settings: {
'import/resolver': { typescript: { alwaysTryTypes: true } },
'boundaries/elements': [
{ type: 'shared', pattern: 'src/app/shared/*', capture: ['kind'] },
{ type: 'ui', pattern: 'src/app/*/ui', capture: ['feature'] },
{ type: 'state', pattern: 'src/app/*/state', capture: ['feature'] },
{ type: 'data', pattern: 'src/app/*/data', capture: ['feature'] },
{ type: 'domain', pattern: 'src/app/*/domain', capture: ['feature'] },
// Files at the feature root (index.ts, *.routes.ts): public API
{ type: 'public', pattern: 'src/app/*', capture: ['feature'] },
{ type: 'app', pattern: 'src/app' },
],
},
rules: {
'boundaries/dependencies': [2, {
default: 'disallow',
policies: [
// Everyone may use shared; shared may use only itself
{ allow: { to: { element: { type: 'shared' } } } },
{ from: { element: { type: 'app' } }, allow: { to: { element: { type: 'public' } } } },
// Layers inside a feature: ui → state → data → domain
{
from: { element: { type: 'public' } },
allow: { to: { element: { types: ['ui', 'state', 'data', 'domain', 'public'], captured: { feature: '{{ from.element.captured.feature }}' } } } },
},
{
from: { element: { type: 'ui' } },
allow: { to: { element: { types: ['ui', 'state', 'domain'], captured: { feature: '{{ from.element.captured.feature }}' } } } },
},
{
from: { element: { type: 'state' } },
allow: { to: { element: { types: ['state', 'data', 'domain'], captured: { feature: '{{ from.element.captured.feature }}' } } } },
},
{
from: { element: { type: 'data' } },
allow: { to: { element: { types: ['data', 'domain'], captured: { feature: '{{ from.element.captured.feature }}' } } } },
},
{
from: { element: { type: 'domain' } },
allow: { to: { element: { type: 'domain', captured: { feature: '{{ from.element.captured.feature }}' } } } },
},
// The only cross-feature permission: orders may use catalog's public API
{
from: { element: { types: ['ui', 'state'], captured: { feature: 'orders' } } },
allow: { to: { element: { type: 'public', captured: { feature: 'catalog' } } } },
},
],
}],
},
},
];
A few details matter. capture grabs the feature name from the path and lets the policies say "the same feature" with {{ from.element.captured.feature }}. The default is disallow, so every dependency that is not allowed is an error. The only cross-feature permission is written out explicitly: Orders may use Catalog's public API, not the other way round. To resolve TypeScript imports the plugin needs eslint-import-resolver-typescript; without that setting the plugin could not resolve the imports and reported no violations at all, so a "lint is clean" result meant nothing. That is why, after setting up the rule, it matters to add a violation on purpose and see it caught.
With the code from the opening:
$ npx eslint src/app
src/app/catalog/ui/book-list-page.ts
4:27 error There is no policy allowing dependencies from elements of type "ui" and captured values: feature="catalog" to elements of type "domain" and captured values: feature="orders" boundaries/dependencies
src/app/orders/ui/checkout-page.ts
4:28 error There is no policy allowing dependencies from elements of type "ui" and captured values: feature="orders" to elements of type "data" and captured values: feature="catalog" boundaries/dependencies
5:27 error There is no policy allowing dependencies from elements of type "ui" and captured values: feature="orders" to elements of type "data" and captured values: feature="orders" boundaries/dependencies
✖ 3 problems (3 errors, 0 warnings)
All three shortcuts are caught on the import line, with an error whose message says which element goes to which. After the code is fixed:
$ npm run lint
> bookstore09@0.0.0 lint
> eslint src/app
(exit code: 0)
When this check runs in CI (Continuous Integration) alongside the build, the rule no longer depends on someone's attention in code review. In monorepos that use Nx, the @nx/enforce-module-boundaries rule does the same job with project tags; the tool changes, the idea is the same: the direction of dependencies is a decision, and that decision needs to be written down somewhere and checked automatically.
The Facade Service
A facade, in the sense of the GoF (Gang of Four: Gamma, Helm, Johnson and Vlissides, authors of the 1994 Design Patterns book) catalog, opens a single, simple door to a complex subsystem; we saw the .NET side in the Facade Pattern post of the Design Patterns series. In Angular, what sits behind that door is usually state management and HTTP:
import { Injectable, computed, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { CatalogLookup } from '../../catalog';
import { OrdersApi } from '../data/orders-api';
import { type CartLine, priceCart } from '../domain/pricing';
type CheckoutStatus = 'editing' | 'placing' | 'placed' | 'failed';
// All the component sees: signals and methods that state an intent
@Injectable()
export class CheckoutFacade {
private readonly catalog = inject(CatalogLookup);
private readonly api = inject(OrdersApi);
private readonly lines = signal<readonly CartLine[]>([]);
private readonly status = signal<CheckoutStatus>('editing');
private readonly orderId = signal<string | null>(null);
readonly cartLines = this.lines.asReadonly();
readonly checkoutStatus = this.status.asReadonly();
readonly placedOrderId = this.orderId.asReadonly();
readonly totals = computed(() => priceCart(this.lines()));
readonly canPlaceOrder = computed(() => this.lines().length > 0 && this.status() === 'editing');
async addBook(bookId: number): Promise<void> {
const book = await firstValueFrom(this.catalog.findBook(bookId));
this.lines.update(lines => [...lines, { bookId, title: book.title, unitPrice: book.price, quantity: 1 }]);
}
changeQuantity(bookId: number, quantity: number): void {
const clamped = Math.min(Math.max(quantity, 1), 10);
this.lines.update(lines => lines.map(l => (l.bookId === bookId ? { ...l, quantity: clamped } : l)));
}
async placeOrder(): Promise<void> {
if (!this.canPlaceOrder()) return;
this.status.set('placing');
try {
this.orderId.set(await firstValueFrom(this.api.placeOrder(this.lines())));
this.status.set('placed');
} catch {
this.status.set('failed');
}
}
}
The surface the component sees is small: a few read-only signals and four methods that state an intent. How book data comes from Catalog, which endpoint the order goes to with which body, how the price is calculated: all of that is behind the facade. The page now injects only the facade:
import { Component, inject } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { CheckoutFacade } from '../state/checkout-facade';
@Component({
selector: 'app-checkout-page',
imports: [CurrencyPipe],
template: `
@for (line of facade.cartLines(); track line.bookId) {
<p>{{ line.title }} x{{ line.quantity }}</p>
}
<p class="total">{{ facade.totals().total.amount | currency: 'EUR' }}</p>
<button [disabled]="!facade.canPlaceOrder()" (click)="facade.placeOrder()">Place order</button>
@if (facade.placedOrderId(); as id) {
<p class="confirmation">Order {{ id }} placed</p>
}
`,
})
export class CheckoutPage {
// No HttpClient, no store: only the facade
protected readonly facade = inject(CheckoutFacade);
}
This pays off twice. First, testing: the page's test passes a fake object made of signals in place of the facade, and neither HTTP nor Catalog comes into play. Second, changeability: if you move the facade's internals to NgRx Signal Store tomorrow, or decide to keep the cart on the server, not a single line of the page changes. The facade's own test verifies the whole chain with the real HTTP layer (against a fake backend):
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { CheckoutFacade } from './checkout-facade';
describe('CheckoutFacade', () => {
it('adds a book from Catalog and places the order', async () => {
TestBed.configureTestingModule({ providers: [CheckoutFacade, provideHttpClient(), provideHttpClientTesting()] });
const facade = TestBed.inject(CheckoutFacade);
const http = TestBed.inject(HttpTestingController);
const adding = facade.addBook(1);
http.expectOne('/api/catalog/books/1').flush({
id: 1, title: 'The Hobbit', authorName: 'J. R. R. Tolkien', listPrice: { amount: 14, currency: 'EUR' },
});
await adding;
facade.changeQuantity(1, 5);
const placing = facade.placeOrder();
const request = http.expectOne('/api/orders');
console.log(`body=${JSON.stringify(request.request.body)} total=${facade.totals().total.amount}`);
request.flush({ orderId: 'ORD-1001', total: 63, currency: 'EUR' });
await placing;
expect(facade.checkoutStatus()).toBe('placed');
expect(facade.placedOrderId()).toBe('ORD-1001');
});
});
$ npx ng test --watch=false --reporters=verbose
stdout | src/app/orders/state/checkout-facade.spec.ts > CheckoutFacade > adds a book from Catalog and places the order
body={"lines":[{"bookId":1,"quantity":5}]} total=63
✓ |bookstore09| src/app/orders/domain/pricing.spec.ts > priceCart > applies 10 percent from 3 copies
✓ |bookstore09| src/app/orders/state/checkout-facade.spec.ts > CheckoutFacade > adds a book from Catalog and places the order
✓ |bookstore09| src/app/orders/ui/checkout-page.spec.ts > CheckoutPage > renders whatever the facade exposes
The facade has a limit too. Putting a mechanical facade in front of every service produces a layer that only forwards calls and hides nothing. A facade earns its keep when there is complexity behind it that is genuinely worth hiding: several sources, state transitions, error cases.
The API Client Layer
The data folder is the only place that talks to the backend. The most important job here is converting the backend's contract (the DTO) into the app's own types:
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { type Observable, map } from 'rxjs';
import { money } from '../../shared/util/money';
import type { Book } from '../domain/book';
// The backend's contract: this shape lives only in this file
interface BookDto {
id: number;
title: string;
authorName: string;
listPrice: { amount: number; currency: string };
}
@Injectable({ providedIn: 'root' })
export class CatalogApi {
private readonly http = inject(HttpClient);
getBooks(): Observable<readonly Book[]> {
return this.http.get<BookDto[]>('/api/catalog/books').pipe(map(dtos => dtos.map(toBook)));
}
getBook(id: number): Observable<Book> {
return this.http.get<BookDto>(`/api/catalog/books/${id}`).pipe(map(toBook));
}
}
function toBook(dto: BookDto): Book {
return { id: dto.id, title: dto.title, author: dto.authorName, price: money(dto.listPrice.amount, dto.listPrice.currency) };
}
The backend sends authorName and a nested listPrice; the rest of the app sees author and Money. When the backend team renames a field, there is a single function to fix. Carrying the DTO type all the way into components means leaking every backend decision into the UI (user interface); it is the same decision we made in the DTO and Mapping post of the ASP.NET Core Web API series about not returning entities directly as responses, only this time on the client side.
Separating Domain Logic from the UI
The discount rule we have carried since the start of the series now lives in Orders' domain folder, as a function that does not touch Angular, HTTP or the screen:
import { type Money, money } from '../../shared/util/money';
export interface CartLine {
readonly bookId: number;
readonly title: string;
readonly unitPrice: Money;
readonly quantity: number;
}
export interface CartTotals {
readonly subtotal: Money;
readonly discount: Money;
readonly total: Money;
}
// Business rule: independent of Angular, HTTP and the screen
export function priceCart(lines: readonly CartLine[]): CartTotals {
const subtotal = lines.reduce((sum, l) => sum + l.unitPrice.amount * l.quantity, 0);
// 10 percent off on lines with 3 or more copies
const discount = lines.reduce((sum, l) => sum + (l.quantity >= 3 ? (l.unitPrice.amount * l.quantity) / 10 : 0), 0);
return { subtotal: money(subtotal), discount: money(discount), total: money(subtotal - discount) };
}
We need to be honest here: domain logic in the frontend is a copy of the real rule in the backend. The server must calculate and validate the correct order total; the client-side calculation exists to give the user instant feedback. The way to keep this copy deliberate is to put it in a single place (this file) and decide explicitly which one wins when it differs from the total the server returns. If the rule is scattered across the templates of several components, nobody knows how many places in the frontend a backend change touches.
A Short Look at Micro-Frontends
Once the boundaries are drawn, the next question is: does it make sense to split the features into separate apps and deploy them separately? Cam Jackson's 2019 article "Micro Frontends" on martinfowler.com defines this as an architectural style where independently deliverable frontend applications are composed into a greater whole. In the Angular world this is usually done with Module Federation or Native Federation.
The same article also lists the costs: a payload that grows as separately built bundles download shared dependencies again, differences between the development environment and production, and more operational and governance overhead with more repositories, pipelines and servers. That cost is worth paying when teams genuinely need to deploy independently (different release schedules, different organizations, products that scale separately). In an app where two teams work in the same repository and deploy together, the feature boundaries and lint rules in this post give most of the isolation micro-frontends bring at a fraction of the cost. Build a modular monolith first; if the boundaries hold for a year and deploy independence is a real need, consider splitting. It is the same reasoning as the Modular Monolith versus Microservices debate on the backend.
Common Pitfalls
Dumping everything into shared. In a project where everything two features need is moved into shared, shared becomes the biggest folder that nobody owns and everybody depends on. Keep only things without domain knowledge in shared: basic types like Money, generic UI pieces, helper functions. If a concept means something in both features, decide which one owns it and expose it to the other through its public API.
Not verifying that the lint rules work. With the resolver missing, the plugin reported no violations and the result looked "clean". After setting up the rule, add a violation on purpose and see it caught.
Turning the public API into an index.ts that exports everything. Lines like export * from './data/catalog-api' leave the boundary on paper only. index.ts should expose the smallest surface the other features actually need.
Making cross-feature dependencies two-way. If Orders uses Catalog, Catalog should not use Orders. If two features need each other, either the boundary is drawn in the wrong place or the shared concept belongs somewhere else. Writing cross-feature permissions explicitly and one-way in the lint policy makes this visible.
Turning the facade into a god service. If the app has a single AppFacade and every page uses it, you have removed the boundaries and gathered everything in one place. A facade should be the door of one feature or one page.
A Small Experiment
★☆☆ Add a book details page to catalog and spread it across the ui, state and data folders. Run npm run lint; then inject CatalogApi directly in the details page and read the error message.
★★☆ Solve Catalog's "buy 3" need without breaking the boundaries. Who owns the pricing rule? Compare exposing a function from Orders' index.ts, moving the rule to Catalog, and asking the backend for a ready-made price, together with which line of the lint policy you would need to change.
★★★ Add a third feature: account (the customer's past orders). What should Orders' public API expose, and should the permission go from Account to Orders or from Orders to Account? Write your decision into the policy, add an import in the wrong direction on purpose and see lint catch it.
With this post the UI Architecture Patterns series is complete. We started with the idea of Separated Presentation, saw three different ways of separating the view from logic with MVC (Model-View-Controller), MVP (Model-View-Presenter) and MVVM (Model-View-ViewModel), discussed where Angular sits among these patterns, made data flow in one direction with Flux, built that flow in Angular with signals and services, split the component tree into container and presentational pieces, and finally enforced the boundaries between features with lint. At every step there was the same question: who will change this code, and what breaks when they do? The full coverage of NgRx with Store, Effects and Signal Store is waiting for you in the upcoming State Management in Angular series in the Frontend category, and in-depth coverage of component and facade tests in the Frontend Testing series in the Testing and Quality category.