Skip to contentNew book: Designing Human-Centered AI InterfacesGet notified
Abhishek Anand

Angular Architecture//6 min read

Building Angular Apps That Stay Easy to Change

Feature boundaries, state ownership, and rendering choices that keep a growing Angular application understandable.

Abhishek Anand

Abhishek Anand

UX Engineer at Google

Angular · TypeScript · Architecture · Performance

An Angular application becomes difficult to change when a small feature requires edits everywhere. A report filter touches a global service, several unrelated components, and a store that nobody quite owns. The problem becomes visible long before the application runs out of capacity.

I prefer an architecture that makes the next change easy to locate. A developer should be able to find the feature, understand its state, and change it without learning the whole application. That gives us a useful way to judge folder structures, component boundaries, and state libraries.

Organize around a feature

Put the files for a reporting workflow together: its route, page, filters, data access, and tests. A top-level folder for every service and another for every component makes related code harder to find. Angular's style guide recommends grouping by feature area (opens in a new tab).

An example reporting feature
src/app/
  reports/
    reports.routes.ts
    report-page.ts
    report-filters.ts
    reports-api.ts
    reports-state.ts
    reports-state.spec.ts
  accounts/
    ...
  ui/
    date-range-input.ts
    empty-state.ts

The folder helps navigation, but imports define the real boundary. If accounts can reach into the reports store and change a private filter, the two features are still coupled. Expose a small interface for behavior that another feature actually needs. Keep the rest local.

Be selective about shared code. A date input with a stable contract belongs in a UI library. A report filter with account-specific defaults probably belongs with reports, even if another page looks similar. Sharing it too early can produce a component with a growing list of flags for each caller's exceptions.

Separate code without splitting deployment

A feature boundary is also a useful place to defer JavaScript. Someone opening account settings should not need the reporting chart library before the page becomes usable.

Load the reporting routes when needed
import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: 'reports',
    loadChildren: () =>
      import('./reports/reports.routes').then(m => m.REPORT_ROUTES),
  },
];

This example assumes the feature exports REPORT_ROUTES. Angular supports lazy loading route configurations and individual standalone components; its route loading guide (opens in a new tab) explains both. Keep imports into the feature consistent with that boundary, or an eager import elsewhere may bring the expensive dependency back into the initial bundle.

Lazy loading changes when code arrives. It does not make a feature independently deployable. A separate deployment introduces versioning, integration, and operational decisions that a route split does not address. I would take on those costs only when teams need separate release schedules.

There is a loading trade-off too: the first visit to a deferred route may wait for another request. Measure that transition. Preloading a frequently used route can help, but preloading every feature can undo the reason for splitting it.

Give components a clear job

A page component can coordinate loading, filters, and navigation. A table component can display rows and emit a selection. That separation becomes useful when the table can be understood and tested without knowing how the backend works.

Keep data access outside the table
Keep data access outside the table The report page and its feature state call the reports API and pass rows and loading state down to the report table. The table only reports back a selected row ID; it never calls the API directly. Reports API Report page and feature state Rows and loading state Report table Selected row ID

Give events names that describe what happened: a row was selected, a filter changed, or an export was requested. Let the page decide whether that means navigation, an API call, or a state update. A reusable table should not need a report-specific store injected into it.

This is a useful boundary, not a requirement to create two components for every screen. A small page with one request may be clearer as one component. Split it when the responsibilities make changes or tests difficult, rather than to satisfy a naming convention.

Put state where it belongs

Start by asking who needs a value and how long it should survive. An expanded row can stay in the table. A filter that must survive a reload or be shared in a link belongs in the URL. Data shared across several screens may need a feature store or a cache.

Avoid keeping independently writable copies of the same value in the URL, component, and store. Choose an owner and derive the other views from it. Otherwise, browser navigation becomes a synchronization bug waiting to happen.

NgRx Store is useful when a workflow has shared state and enough transitions that actions and reducers make the behavior easier to inspect. Its selection guidance (opens in a new tab) also makes the cost clear: more structure and more concepts. Application size alone is a poor reason to put every input and open menu in a global store.

Request behavior deserves the same care. If a user changes the date range twice, should the old response be ignored? Can two saves run together? What happens when a refresh fails while old data is visible? Those decisions matter more than whether the state lives in signals, a service, or NgRx.

Keep rendering work bounded

A table can become slow because it renders too many rows, repeats an expensive calculation, or recreates DOM nodes unnecessarily. These are different problems. Use a trace to identify which one you have before changing the entire component tree.

OnPush lets Angular skip eligible component subtrees. It still checks components in response to relevant inputs and events; it does not mean a component renders only once. Mutating an input object without changing its reference can also leave the view stale. See Angular's change detection examples (opens in a new tab) for the specific rules.

Track list items by a stable ID so Angular can preserve their DOM identity across updates. This avoids unnecessary replacement, but it does not reduce the number of rows on the page. Pagination or virtual scrolling addresses that separate cost.

With the CDK's fixed-size virtual scroll strategy, the declared item height needs to match the rendered row height. Expandable or wrapped rows can break that assumption. The CDK scrolling guide (opens in a new tab) describes the supported strategies. Also test keyboard navigation and focus when rows enter or leave the rendered window.

Make the boundaries testable

Enable TypeScript's strict checking (opens in a new tab) and Angular's template checks. They catch incompatible assumptions during development. They do not validate JSON from a server: check untrusted data at the point where it enters the application.

Test the contracts that would hurt to break. A filter change should request the right data. An old response should not overwrite a newer selection. A failed save should preserve the user's input. A shared component should still work without importing an entire feature.

Formatting and linting can settle routine choices automatically. Reviews can then focus on ownership: which feature owns this rule, whether a dependency crosses a boundary, and how the next developer will find it.

A useful architecture review starts with a proposed change. Add an export format, a permission rule, or another report type, then trace the files it would touch. If every path leads through the same global service, that is a more concrete reason to refactor than an untidy folder diagram.

Continue reading

More from the journal.

All writing