Angular App Architecture and Angular DevTools in Practice [2026]

Link copied
Angular App Architecture and Angular DevTools in Practice [2026]

Angular App Architecture and Angular DevTools in Practice [2026]

Most Angular apps don't become hard to change because of one bad decision. They drift: a component that fetches data, formats it, and also renders a dialog; a shared/ folder that everything imports and nobody owns; a service provided in root because that was easiest, quietly holding state for features that are no longer on screen. Architecture is the set of rules that stops the drift, and the tools that let you check the rules still hold.

This is lesson 14.9 of the Angular Tutorial, and the last one. It follows lesson 14.8 on workspaces and libraries, and it ties together lazy loading, dependency injection and state management. You'll learn a practical structure for a single Angular app, how lazy routes and route-level providers make features independent, where state should live, and how to use Angular DevTools to confirm that the running app matches the design.

The building blocks #

Unit Responsibility Knows about
Presentational component Renders inputs, emits outputs Its inputs and outputs only
Container (smart) component Connects a screen to services and state Stores, services, the router
Feature A user-facing area: routes, components, its own services and state Shared and core code, never another feature's internals
Core App-wide singletons: auth, HTTP interceptors, error handling, layout shell Nothing feature-specific
Shared Reusable UI, pipes, directives, utilities Nothing app-specific

The rules point one way: features depend on shared and core; shared depends on nothing in the app; features don't import each other.

Containers and presentational components #

A presentational component is easy to reuse and test because it has no dependencies to mock:

@Component({
  selector: 'app-order-table',
  template: `
    @for (order of orders(); track order.id) {
      <tr (click)="select.emit(order.id)">
        <td>{{ order.number }}</td>
        <td>{{ order.total | currency: 'EUR' }}</td>
      </tr>
    }
  `,
})
export class OrderTable {
  orders = input.required<Order[]>();
  select = output<string>();
}

The container owns the data and the decisions:

@Component({
  selector: 'app-orders-page',
  imports: [OrderTable, OrderFilters],
  template: `
    <app-order-filters [value]="store.filters()" (filtersChange)="store.setFilters($event)" />
    <app-order-table [orders]="store.visibleOrders()" (select)="open($event)" />
  `,
})
export class OrdersPage {
  protected store = inject(OrdersStore);
  private router = inject(Router);

  open(id: string) {
    this.router.navigate(['/orders', id]);
  }
}

Don't apply this as dogma. A small leaf component that injects a formatting service is fine. The useful rule is that data fetching, navigation and state mutation happen in a few known places, usually the route's top-level component and the stores it uses, so you can trace any change back to its source. With signal inputs, OnPush by default and immutable updates, presentational components also re-render only when their inputs change, which is the checklist from lesson 8.8 applied by structure rather than by effort.

A feature-based folder structure #

Organize by feature, not by file type. A components/, services/, models/ split at the top level spreads one feature across the whole tree:

src/app/
├── core/
│   ├── auth/              # auth service, guards, interceptor
│   ├── layout/            # shell, header, nav
│   └── error-handling/
├── shared/
│   ├── ui/                # button, card, empty-state
│   ├── pipes/
│   └── util/
├── features/
│   ├── orders/
│   │   ├── orders.routes.ts
│   │   ├── orders-page.ts
│   │   ├── order-detail-page.ts
│   │   ├── ui/            # order-table, order-filters
│   │   ├── data/          # orders-api.ts, orders.store.ts
│   │   └── models.ts
│   └── customers/
├── app.config.ts
└── app.routes.ts

When a feature folder grows large enough to need its own sub-structure or several teams touch it, that's the signal to move it into a library, using the workspace options from lesson 14.8. Nx's module boundary rule can then enforce the dependency rules from the table above.

Naming matters more than any specific layout. Pick one convention for files and class names, one place for API access in each feature (data/), and one name for state containers (*.store.ts), then let the CLI and code review keep it consistent.

Lazy routes as the unit of architecture #

Each feature exposes exactly one thing to the rest of the app: its routes.

// app.routes.ts
export const routes: Routes = [
  { path: '', component: HomePage },
  {
    path: 'orders',
    canMatch: [authGuard],
    loadChildren: () => import('./features/orders/orders.routes').then(m => m.ORDERS_ROUTES),
  },
  {
    path: 'customers',
    loadChildren: () => import('./features/customers/customers.routes').then(m => m.CUSTOMERS_ROUTES),
  },
];

This gives you three properties at once. The feature's code is a separate chunk, loaded only when visited. Nothing outside the feature imports its components, because the router is the only entry point. And the route is a natural place for the feature's guards, resolvers and providers.

Scoping DI to a feature #

Services marked providedIn: 'root' are app-wide singletons that live for the whole session. That's right for auth or configuration, but wrong for a store that holds one feature's state. Provide feature services on the route instead:

// features/orders/orders.routes.ts
export const ORDERS_ROUTES: Routes = [
  {
    path: '',
    providers: [OrdersApi, OrdersStore],
    children: [
      { path: '', component: OrdersPage },
      { path: ':id', component: OrderDetailPage },
    ],
  },
];

Route providers create an environment injector for that route and its children. OrdersPage and OrderDetailPage share one OrdersStore, the services are bundled in the lazy chunk instead of the main bundle, and the rest of the app can't inject them by accident.

Where it's provided Lifetime Use for
providedIn: 'root' Whole app session Auth, config, app-wide caches
Route providers Created when the route is first activated Feature stores and feature APIs
Component providers Each component instance Per-instance state: a form wizard, an editable row

By default, route injectors are created on first activation and are not destroyed when you navigate away, so the store keeps its state if the user comes back. If a store must reset on every visit, provide it on the page component instead.

Where state lives #

Lesson 14.1 covered state management in detail; architecturally the rule is short. Keep state as close to where it's used as possible, and move it up only when something else needs it. Template-only state stays in the component as signals. State shared by a feature's screens goes in a feature store provided on the route, such as a signal store or a service with signals. Only genuinely global state, like the current user, permissions or a theme, goes in a root-provided store.

Angular DevTools in practice #

Angular DevTools is a browser extension for Chrome and Firefox that adds Angular-specific tabs to the browser's developer tools. It works only with development builds, which ng serve produces by default.

Tab What you check
Components The component tree, inputs and state; edit property values live
Profiler Change detection cycles, time per component
Injector Tree Environment and element injector hierarchy
Router Tree The application's route configuration
Signal graph (from the Components tab) How signals, computeds, effects and templates depend on each other

Components #

Select a component in the tree, or use the inspect icon and click an element on the page. The properties panel shows inputs, outputs and state; right-click an editable value to change it and see the UI update. The selected instance is available in the console as $ng0 (the previous ones as $ng1, $ng2), so you can call methods on it directly. Double-click a node to jump to its host element, or use the source icon to open the component class.

Signal graph #

With a component selected, choose Show Signal Graph. It draws the component's signals, computed() values, linked signals, effects, resources and its template as nodes, with edges showing who reads whom. This is where architecture problems become visible: a template that depends on one huge state signal, an effect that writes back into the graph, or a store signal read by components that shouldn't know about it. It requires an app built with Angular 19 or later in development mode.

Injector Tree #

The injector tree shows the environment injectors (root, plus one per route with providers) and the element injectors of your components. Select a service to see where it is provided. It's the quickest way to confirm that OrdersStore really lives in the orders route injector and not in root, or to find why two components got different instances.

Profiler and the Performance panel #

The Profiler records change detection cycles as a bar chart; selecting a bar lists the components checked in that cycle and their time, and the flame graph view can show only components that actually went through change detection. Recordings can be saved as JSON and loaded later.

For timing against everything else in the browser, run ng.enableProfiling() in the browser console, then record in Chrome's Performance panel. Angular's work, including change detection and component templates, appears in its own track next to network, scripting and layout, which is how you connect a slow interaction to the components it touched.

Gotchas #

Symptom Cause Fix
Feature state survives logout Feature store is providedIn: 'root' Provide it on the feature route; clear explicitly on logout
Two pages see different store instances Store provided in each component Provide once on the parent route
Feature service lands in the main bundle Imported or provided from app.config.ts Provide on the lazy route only
shared/ imports from features/ No enforced boundaries Lint rule (Nx boundaries or import restrictions)
DevTools shows "Angular application not detected" Production build Use ng serve or build with optimization off
No Angular track in the Performance panel Profiling not enabled Run ng.enableProfiling() before recording

What's next #

That's the end of the Angular Tutorial. Over fourteen modules you've gone from components and templates through signals, forms, routing and HTTP, performance on the client and the server, debugging, testing, AI-assisted development and animations, to state management, libraries and architecture. The tutorial hub keeps the full lesson list, so come back to it when a topic becomes relevant in your own project.

Starting with v22, Angular ships one major version a year. The CLI's ng update handles most migrations, and the release notes on the Angular blog explain what changed and why. Lessons here will be revised as those releases change best practice, so the hub stays a current reference. Thanks for reading along, and go build something useful.

Try it yourself #

Open your app with Angular DevTools. In the Injector Tree, find where your biggest feature's state service is provided. If it's root, move it to the feature route's providers, then check the Signal graph of that feature's page to see what it depends on.

YouOur app has grown to about 200 components in components/, services/ and models/ folders. Every service is providedIn root. Where do I start restructuring without a big-bang rewrite?
Claude · used get_best_practicesGo one feature at a time, starting with the most self-contained one. (1) Create features/invoices/ and move its pages, components and services there, keeping file names; your editor updates the imports. (2) Add invoices.routes.ts and replace the eager component: routes in app.routes.ts with one loadChildren entry. (3) Remove providedIn: 'root' from InvoicesStore and InvoicesApi and list them in that route’s providers; anything outside the feature that still injects them will now fail with NG0201, which shows you the hidden coupling. (4) Split InvoiceListComponent into a container that uses the store and a presentational table with inputs and outputs. Confirm in the DevTools Injector Tree that the store now lives in the route injector, and check the build output for the new lazy chunk. Repeat for the next feature.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *