
Quick answer: Modern Angular architecture is a component tree, not a set of modules. Each component is standalone, declares exactly what it imports, and can be rendered anywhere. Services hold shared logic and are handed to components by dependency injection. Providers registered in app.config.ts are available application-wide.The sections below follow Angular architecture from the bootstrap down: the component tree, services and dependency injection, and the one layering rule that keeps Angular architecture from turning into a tangle. Rendered on the server too: the Angular SSR and hydration guide shows how the same component tree renders per route and hydrates in the browser.

Table of Contents

Angular architecture: from NgModules to standalone
Older Angular grouped everything into NgModules. A component belonged to exactly one module, and the component’s dependencies were declared in that module’s imports array. That system worked, but as an app grew, a single module file became a shared bottleneck that every feature had to edit.
Standalone components remove the middle layer. A component lists its own imports, so the feature folder holds everything it needs. Angular’s own style guide now recommends standalone components for all new code, and NgModules remain available only for compatibility with existing projects.
import { Component } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { ProductCardComponent } from './product-card.component';
@Component({
selector: 'app-product-list',
imports: [CurrencyPipe, ProductCardComponent],
templateUrl: './product-list.component.html',
})
export class ProductListComponent {}Read the imports array as the component’s dependency list. If the template uses CurrencyPipe, that pipe is imported here — nowhere else. That locality is the single biggest day-to-day change from the module era.
Bootstrapping the application
Instead of a root module, the app starts from a function call. main.ts bootstraps the root component, and app.config.ts holds the providers that apply to the whole application.
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));
// app.config.ts
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(),
],
};Each provide* function switches on one capability: routing, HTTP, the error listeners. That is the modern replacement for importing a module such as HttpClientModule, which is now deprecated.

Services and dependency injection
A service is a plain class that holds logic you want to share — talking to an API, caching data, formatting values. You ask for one with inject() instead of listing it as a constructor parameter.
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class ProductService {
private http = inject(HttpClient);
getProducts() {
return this.http.get<Product[]>('/api/products');
}
}providedIn: 'root' registers the service as a singleton for the whole app, so every component that injects it shares the same instance. That is usually what you want — one cache, one source of truth. For a service that should be fresh per component, provide it in the component’s own providers array instead.
The Angular architecture layering rule to remember
- Components render UI and handle user events. Keep them thin.
- Services hold shared logic and data access. They never touch the DOM.
- Providers decide scope: root for app-wide singletons, component-level for isolated instances.
When you are unsure where code belongs, this rule answers most questions: if it draws on screen it is a component, if it computes or fetches it is a service.
Next in this series: components and their lifecycle hooks, where you meet ngOnInit and friends. If you would rather learn the architecture with a mentor on a real codebase, Ampersand Academy teaches Angular one-to-one.

Angular architecture: lazy loading and code splitting
Standalone components make lazy loading simpler than it was in the module era. In NgModule-based apps, lazy loading required a dedicated module file as the entry point. With standalone components, a route can point directly to a component, and Angular’s router handles the bundle split automatically.
The router’s loadComponent property accepts a dynamic import. The browser fetches that component’s chunk only when the user navigates to that route. Everything the component imports — pipes, child components, services — travels in the same chunk unless those dependencies are already in the main bundle.
// app.routes.ts
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () =>
import('./dashboard/dashboard.component').then(
(m) => m.DashboardComponent
),
},
{
path: 'settings',
loadComponent: () =>
import('./settings/settings.component').then(
(m) => m.SettingsComponent
),
},
];
// app.routes.ts
import { Routes } from '@angular/router';export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () =>
import('./dashboard/dashboard.component').then(
(m) => m.DashboardComponent
),
},
{
path: 'settings',
loadComponent: () =>
import('./settings/settings.component').then(
(m) => m.SettingsComponent
),
},
];
For a cluster of related routes, loadChildren still works with a standalone routes array instead of a module. Export a plain Routes array from the feature folder and point loadChildren at that file. No module wrapper needed.
A common pitfall: importing a heavy shared component at the root level when it is only used in one lazy route. That drags the component into the main bundle and erases the code-split benefit. Audit bundle output with ng build --stats-json and inspect the result in any bundle analyser to catch this early.
Angular architecture: change detection and signals
Change detection is the mechanism Angular uses to decide when to update the DOM. Understanding it is essential for building performant angular architecture at scale. The default strategy, CheckAlways, re-evaluates every component in the tree on every event. That is simple but expensive in large trees.
OnPush strategy
ChangeDetectionStrategy.OnPush tells Angular to skip a component unless one of its @Input references changes, an event fires inside it, or an async pipe resolves. Applying it to leaf components and pure display components is the fastest incremental win in any angular architecture audit.
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
@Component({
selector: 'app-price-tag',
imports: [CurrencyPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `{{ price() | currency }}`,
})
export class PriceTagComponent {
price = input.required<number>();
}
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
import { CurrencyPipe } from '@angular/common';@Component({
selector: 'app-price-tag',
imports: [CurrencyPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `{{ price() | currency }}`,
})
export class PriceTagComponent {
price = input.required<number>();
}
Notice input() — Angular’s signal-based input API. Signal inputs are inherently compatible with OnPush because Angular tracks their reads at a fine-grained level. When the signal value changes, only the components that read it are marked dirty.
Signals in services
Signals are not limited to component inputs. A service can expose a signal or computed value, and any component that reads it updates automatically when the value changes. This replaces many patterns that previously needed RxJS BehaviorSubject plus async pipe.
import { Injectable, signal, computed } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CartService {
private items = signal<string[]>([]);
count = computed(() => this.items().length);
add(item: string) {
this.items.update((prev) => [...prev, item]);
}
}
import { Injectable, signal, computed } from '@angular/core';@Injectable({ providedIn: 'root' })
export class CartService {
private items = signal<string[]>([]);
count = computed(() => this.items().length);add(item: string) {
this.items.update((prev) => [...prev, item]);
}
}
Components inject CartService and read cartService.count() directly in the template. No subscription, no takeUntilDestroyed, no memory leak risk. The angular architecture gets flatter: data flows from signal to template without an observable pipeline in between.
Structuring feature folders
File organisation is not enforced by the framework, but a consistent pattern prevents the “where does this go?” friction that slows teams down. Angular architecture scales best when each feature is self-contained: its own folder holds the component, its template, its styles, its service if it has one, and its routes.
- Feature folder — one directory per user-facing feature (e.g.
src/app/checkout/). - Shared folder — components and pipes used by three or more features. Two usages is not enough justification; wait for the third.
- Core folder — singletons that must exist exactly once: global error handlers, auth interceptors, logging services.
Resist creating a utils/ folder that becomes a dumping ground. If a helper is only used by one service, keep it in that service’s file. The angular architecture goal is that deleting a feature folder removes the feature completely, with no orphaned files left behind.
Barrel files (index.ts re-exporting everything) look tidy but can quietly undo lazy loading. If the barrel is imported at the root level, every export in it lands in the main bundle. Prefer direct imports to the specific file when the component lives behind a lazy route.
Dependency injection: advanced patterns
The basics of angular architecture’s dependency injection system are covered earlier in this article. Two patterns come up repeatedly in real projects that deserve their own treatment: hierarchical injectors and injection tokens.
Hierarchical injectors
Angular maintains a tree of injectors that mirrors the component tree. The root injector (configured in app.config.ts) sits at the top. Each component that declares a providers array creates a child injector. When a component requests a service, Angular walks up the injector tree until it finds a provider.
This matters for feature isolation. A multi-tenant dashboard might give each panel its own DataService instance by providing it at the panel component level. The root injector never sees those instances. They are created when the panel is created and destroyed when the panel is destroyed — clean lifecycle management baked into angular architecture.
Pitfall: accidentally providing a service at the component level when you intended a singleton. If two sibling components both provide the same service, they get separate instances with separate state. That is correct if intentional, but a hard-to-trace bug if not. Use providedIn: 'root' when you mean one instance for the whole app.
Injection tokens
Some values — base URLs, feature flags, environment-specific config — need to be injectable without a class to attach them to. InjectionToken handles this. Define the token once, provide the value in app.config.ts, and inject it anywhere with inject().
// tokens.ts
import { InjectionToken } from '@angular/core';
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');
// app.config.ts (inside providers array)
{ provide: API_BASE_URL, useValue: 'https://api.example.com' }
// product.service.ts
import { Injectable, inject } from '@angular/core';
import { API_BASE_URL } from '../tokens';
@Injectable({ providedIn: 'root' })
export class ProductService {
private base = inject(API_BASE_URL);
}
// tokens.ts
import { InjectionToken } from '@angular/core';
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');// app.config.ts (inside providers array)
{ provide: API_BASE_URL, useValue: 'https://api.example.com' }// product.service.ts
import { Injectable, inject } from '@angular/core';
import { API_BASE_URL } from '../tokens';@Injectable({ providedIn: 'root' })
export class ProductService {
private base = inject(API_BASE_URL);
}
This keeps environment-specific values out of service class bodies and makes them easy to override in tests. Swap the token’s value in the test’s TestBed providers and the service never knows the difference.
Testing standalone components
Standalone components are easier to test than module-based components because they declare their own dependencies. You no longer need to replicate a module’s import list in every test file. TestBed.configureTestingModule accepts an imports array that works the same way a component’s own imports does.
For a component that imports CurrencyPipe and a child component, the test imports the same two things. Nothing more. That narrow surface area means a broken import in an unrelated module cannot break this test — a genuine improvement over the module-era angular architecture testing experience.
Use provideHttpClientTesting() alongside provideHttpClient() when testing components or services that make HTTP calls. The testing provider intercepts requests so tests never hit a real network. Swap a service for a stub using the providers array in the test module; the hierarchical injector ensures the stub takes precedence over any root-level provider.
A hard rule: never use NO_ERRORS_SCHEMA to silence unknown element errors. It hides genuine template bugs. Declare the child component in the test’s imports, or replace it with a minimal stub component that has only the selector and inputs the parent cares about. That keeps tests honest without requiring the full dependency tree.
Angular architecture: common migration pitfalls
Teams migrating from NgModules to standalone components encounter a repeating set of problems. Knowing them in advance saves several debugging sessions.
Pitfall 1 — forgetting to remove declarations. In a module, components are listed in declarations. When you add standalone: true (or rely on the default in Angular 19+, where all components are standalone unless opted out), remove the component from every module’s declarations array. A component listed in both places causes a compilation error.
Pitfall 2 — missing imports in the template. Standalone components must import every directive and pipe they use. Forgetting NgIf, NgFor, or AsyncPipe produces a template parse error that can look confusing if you are used to those being available globally through a shared module. Import them from @angular/common directly in the component.
Pitfall 3 — circular imports. Two standalone components that import each other cause a circular dependency. The usual fix is to extract shared logic into a third component or service. Angular’s compiler will report the cycle, but the message can be cryptic. Structure feature folders so data flows in one direction: parent imports child, never the reverse.
Pitfall 4 — route guard scope. Guards registered with CanActivate run in the injector context of the route, not the component. If a guard needs a service that is provided at component level rather than root level, the guard will not find it. Root-level singleton services are the safe default for anything a guard depends on. The angular architecture layering rule — compute and fetch in services, not components — prevents this class of error naturally.
Pitfall 5 — mixing provideHttpClient() and the old HttpClientModule. Using both at once in the same angular architecture produces duplicate HTTP interceptor chains. Pick one. New projects use provideHttpClient(withInterceptors([...])) exclusively. Legacy interceptors written as classes can be adapted with the withLegacyInterceptors() option while you migrate them to functional form.
Migration can be done incrementally. Use Angular’s built-in schematic (ng generate @angular/core:standalone) to convert one file at a time. Run your test suite after each batch. The schematic handles the mechanical edits; human review catches the edge cases where the angular architecture was relying on implicit module-level configuration that has no direct standalone equivalent.
Frequently asked questions
Do I still need NgModules in Angular?
No, not for new code. Standalone components declare their own imports and are the recommended default. NgModules remain for compatibility with existing applications.
What does providedIn root mean in an Angular service?
It registers the service as a singleton for the whole application, so every component that injects it receives the same instance.
What is the difference between a component and a service?
Components render UI and respond to user events. Services hold shared logic such as data access and never touch the DOM.
How does Angular dependency injection work?
You ask for a dependency with inject() or a constructor parameter, and Angular supplies the registered instance. Where the service is provided decides its scope.
What is app.config.ts in Angular?
It holds the application-wide providers, such as provideRouter and provideHttpClient. It replaces the old root NgModule’s provider list.
Last updated on · Written by Dinesh Kumar R

