Angular components (@Component) control UI sections using templates and TypeScript. Angular modules (@NgModule) group related code into functional blocks. Angular services (@Injectable) hold shared business logic accessed via dependency injection. Since Angular 17, standalone components are the default, making NgModule optional for most new apps.
Angular is one of the most widely used frameworks for building enterprise-grade web applications, and its power comes from three interconnected building blocks: components, modules, and services. Understanding how each one works, what it is responsible for, and how they connect to each other is the foundation of writing Angular code that is clean, scalable, and easy to maintain over time. Whether you are building a single feature or architecting an application with hundreds of screens, these three concepts govern everything Angular does.
This guide explains Angular components, Angular modules, and Angular services in precise technical terms, with real code examples for each. It also covers the shift that Angular 17 introduced by making standalone components the default architecture, what that means for NgModule going forward, and how to structure your Angular applications using modern best practices that work well in both small projects and large production codebases.
An Angular component is a TypeScript class decorated with @Component that controls a specific part of the user interface. Every visible element in an Angular application, whether it is a navigation bar, a product card, a login form, or a data table, is managed by a component. Angular components consist of three parts that always work together:
Template: the HTML that defines what the component renders on screen.
Class: the TypeScript file that holds the component’s data, methods, and logic.
Styles: the CSS or SCSS rules that apply exclusively to this component’s template.
Here is a minimal Angular component that displays a product name:
import { Component } from '@angular/core';
@Component({
selector: 'app-product-card',
standalone: true,
template: `
<div class="card">
<h2>{{ productName }}</h2>
<p>{{ productPrice | currency }}</p>
<button (click)="addToCart()">Add to Cart</button>
</div>
`,
styles: [`
.card { border: 1px solid #ddd; padding: 16px; border-radius: 8px; }
`]
})
export class ProductCardComponent {
productName = 'Wireless Headphones';
productPrice = 79.99;
addToCart() {
console.log(`${this.productName} added to cart`);
}
}
Notice standalone: true in the decorator. Since Angular 17, standalone is the default and recommended approach for new components. It means the component does not need to be declared inside a NgModule to be used.
Angular components go through a predictable sequence of lifecycle events from creation to destruction. Angular provides lifecycle hook interfaces that let you run code at specific moments in this sequence. The most commonly used hooks are:
import { Component, OnInit, OnDestroy, Input, OnChanges, SimpleChanges } from '@angular/core';
import { Subscription } from 'rxjs';
import { ProductService } from './product.service';
@Component({
selector: 'app-product-list',
standalone: true,
templateUrl: './product-list.component.html'
})
export class ProductListComponent implements OnInit, OnChanges, OnDestroy {
@Input() categoryId!: string;
products: any[] = [];
private subscription!: Subscription;
constructor(private productService: ProductService) {}
ngOnInit(): void {
this.loadProducts();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['categoryId'] && !changes['categoryId'].firstChange) {
this.loadProducts(); // reload when category changes
}
Angular components frequently need to share data with each other. The two primary mechanisms for parent-to-child and child-to-parent communication are @Input and @Output decorators.
@Input allows a parent component to pass data down to a child component. @Output allows a child component to emit events that the parent can listen to and respond to.
// Child component
@Component({
selector: 'app-item',
standalone: true,
template: `
<div>
<span>{{ label }}</span>
<button (click)="select.emit(label)">Select</button>
</div>
`
})
export class ItemComponent {
@Input() label!: string;
@Output() select = new EventEmitter<string>();
}
// Parent component template
// <app-item [label]="item.name" (select)="onItemSelected($event)" />
Best Practice: Keep components focused on a single responsibility. If a component is managing both UI display and data fetching, split it: one presentational component handles the template and another container component handles data. This separation makes both components easier to test and reuse.
An Angular module is a class decorated with @NgModule that groups related components, directives, pipes, and services into a cohesive unit. Every Angular application has at least one module, the AppModule, which serves as the root and bootstraps the application. Additional feature modules organize the app into manageable sections.
Here is a complete feature module for a user authentication section:
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { LoginComponent } from './login/login.component';
import { RegisterComponent } from './register/register.component';
import { AuthService } from './auth.service';
@NgModule({
declarations: [
LoginComponent,
RegisterComponent
],
imports: [
CommonModule,
ReactiveFormsModule,
RouterModule.forChild([
{ path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent }
])
],
providers: [AuthService]
})
export class AuthModule {}
One of the most important performance benefits of Angular modules is lazy loading. When you configure a route to use loadChildren instead of component, Angular only loads that module’s code when the user navigates to that route. This reduces the initial bundle size and speeds up the first page load.
// app-routing.module.ts
const routes: Routes = [
{
path: 'products',
loadChildren: () =>
import('./products/products.module').then(m => m.ProductsModule)
},
{
path: 'admin',
loadChildren: () =>
import('./admin/admin.module').then(m => m.AdminModule)
}
];
Angular 17 made standalone components, directives, and pipes the default when generating new code using the Angular CLI. This is one of the most significant architectural shifts in Angular’s history, and understanding what it means for NgModule is essential for any developer working with modern Angular.
// Modern standalone approach (Angular 17+ default)
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { ProductCardComponent } from '../product-card/product-card.component';
@Component({
selector: 'app-product-list',
standalone: true,
imports: [CommonModule, RouterModule, ProductCardComponent],
templateUrl: './product-list.component.html'
})
export class ProductListComponent {
// component logic here
}
A standalone component manages its own imports directly in the @Component decorator instead of relying on a module’s declarations array. This eliminates a layer of indirection and makes each component fully self-contained.
With standalone components, lazy loading also works directly on the component rather than requiring a module wrapper:
// Lazy loading a standalone component directly
const routes: Routes = [
{
path: 'products',
loadComponent: () =>
import('./products/product-list.component').then(m => m.ProductListComponent)
}
];
NgModule requires you to declare every component in a module before it can be used. Standalone components declare their own dependencies inside @Component. Standalone reduces boilerplate significantly in large apps and makes it clear exactly what each component depends on without opening a separate module file.
An Angular service is a TypeScript class decorated with @Injectable that holds business logic, data access functions, state management, or utilities that multiple components need to use. Services keep components lean by moving data-fetching, transformation, and shared state out of the component class itself.
Here is a ProductService that fetches data from an API and provides it to components across the application:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, BehaviorSubject } from 'rxjs';
import { tap, catchError } from 'rxjs/operators';
@Injectable({
providedIn: 'root' // singleton available throughout the app
})
export class ProductService {
private apiUrl = 'https://api.example.com/products';
private productsSubject = new BehaviorSubject<any[]>([]);
products$ = this.productsSubject.asObservable();
constructor(private http: HttpClient) {}
getAll(): Observable<any[]> {
return this.http.get<any[]>(this.apiUrl).pipe(
tap(products => this.productsSubject.next(products)),
catchError(err => {
console.error('Failed to load products', err);
throw err;
})
);
}
getByCategory(categoryId: string): Observable<any[]> {
return this.http.get<any[]>(`${this.apiUrl}?category=${categoryId}`);
}
getById(id: number): Observable<any> {
return this.http.get<any>(`${this.apiUrl}/${id}`);
}
}
Angular’s dependency injection system (DI) is what makes services available to components without the component needing to instantiate them manually. When you list a service in a component’s constructor, Angular’s injector looks up the registered service and provides the existing instance automatically.
@Component({
selector: 'app-product-list',
standalone: true,
imports: [CommonModule],
template: `
<ul>
<li *ngFor="let p of products">{{ p.name }}</li>
</ul>
`
})
export class ProductListComponent implements OnInit {
products: any[] = [];
// Angular injects ProductService automatically
constructor(private productService: ProductService) {}
ngOnInit(): void {
this.productService.getAll().subscribe(data => this.products = data);
}
}
Where you provide a service determines how many instances of it exist in the application. There are three main scopes:
The table below summarizes the key technical differences between the three core Angular building blocks to help you quickly decide which one applies to a given situation.
| Aspect | Component | Module | Service |
|---|---|---|---|
| Decorator | @Component |
@NgModule |
@Injectable |
| Purpose | Manages a piece of UI | Groups components, directives, pipes, and services | Holds shared logic and data |
| Has Template | Yes (HTML + CSS) | No | No |
| Has TypeScript Class | Yes | Yes (metadata only) | Yes (methods and state) |
| Reusable Across App | Within declared module or standalone | Imported into other modules | Yes, via dependency injection into any class |
| Dependency Injection | Receives services via constructor | Declares providers for the module | Provided via providedIn: 'root' |
| Typical File Suffix | *.component.ts |
*.module.ts |
*.service.ts |
| Standalone Support | Yes (v14+, default in v17) | Still used in library contexts | Always available |
A real Angular application connects all three building blocks in a pattern that keeps UI, organization, and logic clearly separated. Here is a concrete example using a product catalog feature to show how they interact:
The ProductService (service layer) handles all HTTP calls, state management, and business rules. It is provided at root level so any component in the app can access the same data without making duplicate API calls.
The ProductListComponent and ProductDetailComponent (component layer) each inject ProductService through the constructor and display data in their templates. They do not know or care how the data is fetched. They only know that when they call productService.getAll(), they get an observable of products.
In a module-based architecture, the ProductsModule (module layer) declares both components and sets up the lazy-loaded route. In a standalone architecture, the components import their own dependencies directly and the router uses loadComponent instead of loadChildren.
// The flow in a standalone Angular 17+ app:
// 1. Route configuration loads the component lazily
{ path: 'products', loadComponent: () =>
import('./product-list.component').then(c => c.ProductListComponent) }
// 2. Component injects the service and fetches data on init
@Component({ standalone: true, ... })
export class ProductListComponent implements OnInit {
constructor(private productService: ProductService) {}
ngOnInit() { this.productService.getAll().subscribe(...); }
}
// 3. Service performs the actual HTTP request
@Injectable({ providedIn: 'root' })
export class ProductService {
getAll(): Observable<Product[]> {
return this.http.get<Product[]>('/api/products');
}
}
Components display and react. Services fetch and manage. Modules (when used) organize and scope. Keeping these responsibilities separate is what makes Angular applications easy to test, extend, and maintain as they grow. A component that directly calls HttpClient is a warning sign that its concerns need to be separated into a service.
A component controls UI: it has an HTML template, CSS styles, and TypeScript logic for user interactions. A service controls data and business logic: it has no template and is shared across multiple components via dependency injection. Components display things. Services do things.
For most new Angular 17 applications, you do not need NgModule at all. Standalone components handle their own imports directly. The root application is bootstrapped with bootstrapApplication() instead of bootstrapModule(), and lazy loading uses loadComponent instead of loadChildren. NgModule remains necessary for third-party library distribution and for existing projects that have not migrated.
When you write @Injectable({ providedIn: ‘root’ }), you are telling Angular to register the service in the root injector, which makes it a singleton available throughout the entire application. This is the recommended default for most services because it enables Angular’s tree-shaking to remove unused services from the production bundle.
Use ngOnInit to fetch data. Angular calls ngOnInit once after it has set all @Input property values, so it is the correct and safe place to trigger initial data loading. Do not use the constructor for data fetching because Angular has not yet applied inputs when the constructor runs.
Declarations lists the components, directives, and pipes that belong to this module. Imports brings in other Angular modules whose exported declarations you need to use in your templates. Providers registers services that should be available within this module’s injector scope. In a standalone component, declarations moves into the standalone component’s imports array, and providers moves into the component’s providers array.
Services share data using RxJS Subjects or BehaviorSubjects. A BehaviorSubject holds a current value and emits it to all new subscribers immediately. Any component that subscribes to the observable exposed by the service receives updates whenever the service updates the subject. This allows unrelated components with no parent-child relationship to stay synchronized without passing data through multiple layers of @Input and @Output bindings.
Create a Shared Module when you have components, directives, or pipes that you need to use in more than two feature modules. Common candidates include custom UI components like buttons and cards, form field components, currency and date pipes, and icon components. Without a Shared Module, you would need to duplicate declarations across feature modules, which breaks the single-declaration rule and causes runtime errors.
Read Also: 10 Popular Angular Frameworks for Web App Development
Angular components, modules, and services are not just three concepts you learn once and move past. They are the recurring architectural decisions you make throughout every feature you build. Getting them right from the start determines how easy your codebase is to test, extend, debug, and hand off to other developers.
The modern Angular approach in 2026 leans toward standalone components that own their dependencies, singleton services provided at the root level, and lazy-loaded feature code split at the route level. NgModule remains a supported and valuable tool for library distribution and complex enterprise applications, but it is no longer a required step for building a new Angular feature from scratch. For businesses considering outsourcing Angular development, adopting these modern architectural patterns from the beginning helps improve scalability, simplify maintenance, and reduce long-term development costs.
If your team is building a new Angular application or migrating an existing Angular codebase to a more maintainable architecture, partnering with an Angular development services provider can help you adopt best practices and accelerate development. At Zealous System, our Angular developers work across the full spectrum of Angular versions and patterns, from NgModule-based enterprise applications to modern standalone architectures. We build high-performance, scalable, and maintainable Angular applications that support your business as it grows.
Our team is always eager to know what you are looking for. Drop them a Hi!
Comments