Sitemap
ANUTOSH GHOSH

ANUTOSH GHOSH

I am passionate about software development and full stack development. I am aiming to become a MERN stack developer. Loves C++. Learning Java.

Hard parts of Angular : Object-Oriented Thought Process Specific to TypeScript as Required in Angular

16 min readJul 5, 2025

--

Press enter or click to view image in full size
Thinking in terms of objects and classes in Angular using typescript

Becoming an Angular architect isn’t just about knowing Angular’s APIs or TypeScript syntax. It’s about a deeper understanding of how to structure, scale, and maintain complex applications. At the heart of this mastery lies a profound grasp of Object-Oriented Programming (OOP) principles and how they are implemented and leveraged within TypeScript, Angular’s language of choice.

Angular is, by its very design, an object-oriented framework. Components are classes, services are classes, and even directives are class-based. Embracing OOP principles transcends mere syntax; it shapes your thought process, enabling you to model real-world problems effectively, build resilient systems, and foster collaborative development.
This article will rigorously explore the fundamental OOP concepts vital for an Angular architect, along with other indispensable TypeScript features that empower you to build robust, scalable, and maintainable Angular applications.

Part 1: The Object-Oriented Thought Process in Angular

Before diving into specific concepts, let’s establish the mindset. The object-oriented thought process is about viewing your application not as a series of disconnected functions, but as a collection of interacting “objects” that represent real-world or conceptual entities.

* Thinking in “Things” (Objects/Entities):

* Instead of “display user data,” think “User object” that has properties (name, email) and can do things (login, update profile).
* Instead of “fetch product list,” think “ProductService object” that provides products and manages fetching logic.
* Every component, every service, every model is an “object” with its own responsibilities.

* Identifying Responsibilities and Collaborations:

* What data does an object manage? (Properties)
* What actions can it perform? (Methods)
* How does it interact with other objects? (Dependencies, interfaces)
* For an Angular architect, this translates to: What data should a component hold? What methods should a service expose? Which service should depend on another?
* Modeling Reality (or Abstractions):
* OOP helps you create software models that closely resemble the problem domain. A Product class maps directly to a product in an e-commerce system.
* This makes your code more intuitive, easier to understand, and less prone to logical errors.
* Managing Complexity through Decomposition:
* Large problems are broken down into smaller, manageable, self-contained objects.
* Each object (e.g., a service) encapsulates its logic, hiding internal complexity from other objects that only interact with its public interface.

This is crucial for large Angular applications.
By adopting this mindset, you naturally gravitate towards building modular, testable, and maintainable Angular features.

Part 2: Core OOP Concepts in TypeScript for Angular

TypeScript provides full support for classic OOP paradigms. Understanding these deeply is non-negotiable for an Angular architect.

A. Encapsulation: The Shield of Information Hiding

Definition: Encapsulation is the bundling of data (attributes) and methods (functions) that operate on the data into a single unit, or class. It also involves restricting direct access to some of the object’s components, which is known as information hiding. The internal state of an object is protected, and interactions happen only through its public interface.

Why it’s Crucial in Angular:

  • Data Integrity: Prevents external code from corrupting an object’s internal state.
    * Maintainability: Changes to internal implementation don’t break external code, as long as the public interface remains consistent.
    * Reduced Side Effects: Clearly defines what can and cannot be accessed/modified, reducing unexpected behavior.

* Testability: Objects can be tested in isolation based on their public API.

  • TypeScript Modifiers:
    * public (default): Accessible from anywhere.
    * private: Accessible only within the class where it’s defined.
    * protected: Accessible within the class and by subclasses that extend it.
    * readonly: A property that can only be assigned once, either at declaration or in the constructor.

Code Example: Secure User Session Service

  • Let’s imagine a UserSessionService that manages sensitive user token and profile data. We want to ensure this data is only manipulated through controlled methods, not directly.
// src/app/auth/user-session.service.ts
import { Injectable } from '@angular/core';
import { Observable, BehaviorSubject } from 'rxjs';
import { UserProfile } from './models'; // Assume UserProfile interface exists

@Injectable({
providedIn: 'root'
})
export class UserSessionService {
// Private members to encapsulate sensitive data and internal state
private _accessToken: string | null = null;
private _userProfile: BehaviorSubject<UserProfile | null> = new BehaviorSubject(null);
private readonly SESSION_STORAGE_KEY_TOKEN = 'app_auth_token';
private readonly SESSION_STORAGE_KEY_PROFILE = 'app_user_profile';

constructor() {
this.initializeSession();
}

// Public getter to expose user profile as an observable (read-only reactive access)
public get userProfile$(): Observable<UserProfile | null> {
return this._userProfile.asObservable();
}

// Public methods: controlled ways to interact with the session
public login(token: string, profile: UserProfile): void {
if (!token || !profile) {
console.error('Login failed: Token or profile missing.');
return;
}
this._accessToken = token;
this._userProfile.next(profile);
sessionStorage.setItem(this.SESSION_STORAGE_KEY_TOKEN, token);
sessionStorage.setItem(this.SESSION_STORAGE_KEY_PROFILE, JSON.stringify(profile));
console.log('User logged in successfully.');
}

public logout(): void {
this._accessToken = null;
this._userProfile.next(null);
sessionStorage.removeItem(this.SESSION_STORAGE_KEY_TOKEN);
sessionStorage.removeItem(this.SESSION_STORAGE_KEY_PROFILE);
console.log('User logged out.');
}

public isAuthenticated(): boolean {
// Only exposes whether authenticated, not the token itself directly
return !!this._accessToken && !!this._userProfile.value;
}

// Protected method: only accessible by this class and its subclasses
protected getRawAccessToken(): string | null {
// This could be used by a subclass that needs to refresh the token,
// but not exposed to components.
return this._accessToken;
}

// Private method: purely internal logic, cannot be called from outside
private initializeSession(): void {
const storedToken = sessionStorage.getItem(this.SESSION_STORAGE_KEY_TOKEN);
const storedProfile = sessionStorage.getItem(this.SESSION_STORAGE_KEY_PROFILE);

if (storedToken && storedProfile) {
try {
this._accessToken = storedToken;
this._userProfile.next(JSON.parse(storedProfile));
console.log('Session initialized from storage.');
} catch (e) {
console.error('Failed to parse stored profile:', e);
this.logout(); // Clear invalid session
}
}
}

// Example of a readonly property that's set in constructor (or at declaration)
public readonly serviceId: string = Math.random().toString(36).substring(2, 10);
}
// src/app/auth/models.ts
export interface UserProfile {
id: string;
username: string;
email: string;
roles: string[];
}

Explanation:
* _accessToken, _userProfile, SESSION_STORAGE_KEY_TOKEN, SESSION_STORAGE_KEY_PROFILE are private or readonly, preventing direct external modification.
* userProfile$ is a public getter, but it returns an Observable, providing a read-only stream of user profile data. Consumers can subscribe but cannot next() new values.
* login() and logout() are the only public methods to change the session state.
* getRawAccessToken() is protected, demonstrating a method intended for potential subclass use but not external consumption.
* initializeSession() is private, emphasizing it’s an internal helper function.
* serviceId is readonly, ensuring it cannot be reassigned after initialization.
This robust encapsulation protects the internal session state and provides a clear, safe API for other parts of the application to interact with user authentication.

B. Abstraction: Focusing on “What,” Hiding “How”

Definition: Abstraction is the process of hiding the complex implementation details and showing only the essential features of an object. It focuses on “what” an object does rather than “how” it does it. In TypeScript, abstraction is primarily achieved using interfaces and abstract classes.

Why it’s Crucial in Angular:

* Decoupling: Components/services depend on abstractions (interfaces/abstract classes) rather than concrete implementations. This makes it easy to swap out implementations without affecting dependents.
* Testability: Allows for easy mocking of dependencies during unit testing.
* Scalability: Facilitates the introduction of new features or changing underlying technologies without ripple effects.
* Design Contracts: Interfaces define a contract that classes must adhere to, ensuring consistency across different implementations.

Get ANUTOSH GHOSH’s stories in your inbox

Join Medium for free to get updates from this writer.

Code Example: Flexible Data Access Layer
Let’s design a flexible data access layer using both an interface and an abstract class to demonstrate abstraction.

// src/app/core/interfaces/crud.interface.ts
// Interface: Defines the contract for any service providing CRUD operations
export interface ICrudService<T> {
getAll(): Observable<T[]>;
getById(id: string): Observable<T>;
create(item: T): Observable<T>;
update(id: string, item: Partial<T>): Observable<T>; // Partial for partial updates
delete(id: string): Observable<void>;
}


// src/app/core/abstracts/base-api.service.ts
import { HttpClient } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ICrudService } from '../interfaces/crud.interface';

// Abstract Class: Provides common implementation for API services,
// while leaving specific URL and error handling details to subclasses.
export abstract class BaseApiService<T> implements ICrudService<T> {
protected abstract apiUrl: string; // Must be implemented by subclasses

constructor(protected http: HttpClient) {} // Protected to allow access in subclasses

// Common CRUD methods implemented here
getAll(): Observable<T[]> {
return this.http.get<T[]>(this.apiUrl).pipe(
catchError(this.handleError)
);
}

getById(id: string): Observable<T> {
return this.http.get<T>(${this.apiUrl}/${id}).pipe(
catchError(this.handleError)
);
}

create(item: T): Observable<T> {
return this.http.post<T>(this.apiUrl, item).pipe(
catchError(this.handleError)
);
}

update(id: string, item: Partial<T>): Observable<T> {
return this.http.patch<T>(${this.apiUrl}/${id}, item).pipe( // Use PATCH for partial updates
catchError(this.handleError)
);
}

delete(id: string): Observable<void> {
return this.http.delete<void>(${this.apiUrl}/${id}).pipe(
catchError(this.handleError)
);
}

// Abstract method: Must be implemented by subclasses for custom error handling
protected abstract handleError(error: any): Observable<never>;

// Protected helper method: Accessible by subclasses but not external consumers
protected getFullUrl(endpoint: string): string {
return ${this.apiUrl}/${endpoint};
}
}
// src/app/products/product.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { BaseApiService } from '../core/abstracts/base-api.service';
import { ICrudService } from '../core/interfaces/crud.interface';
import { Product } from './models'; // Assume Product interface exists

@Injectable({
providedIn: 'root'
})
// Concrete Product Service: Implements the contract and extends common API logic
export class ProductService extends BaseApiService<Product> implements ICrudService<Product> {
protected apiUrl = '/api/products'; // Specific API URL for products

constructor(http: HttpClient) {
super(http);
}

// Specific error handling for ProductService
protected handleError(error: any): Observable<never> {
console.error('ProductService Error:', error);
// You could map specific error codes to user-friendly messages here
return throwError(() => new Error('Failed to perform product operation. Please try again.'));
}

// Example of a Product-specific method, leveraging inherited functionality
getProductsByCategory(category: string): Observable<Product[]> {
return this.http.get<Product[]>(this.getFullUrl(category/${category})).pipe(
catchError(this.handleError)
);
}
}
// src/app/products/models.ts
export interface Product {
id: string;
name: string;
description: string;
price: number;
category: string;
stock: number;
}

Explanation:
* ICrudService<T> (Interface): Defines a contract for any CRUD service, ensuring that any class implementing it will have these methods. This allows components to interact with ICrudService<Product> without knowing if it’s ProductService, MockProductService, or OfflineProductService.
* BaseApiService<T> (Abstract Class): Implements the common HTTP logic for all CRUD operations. It contains concrete methods (getAll, getById, etc.) that reuse HttpClient. However, it leaves apiUrl and handleError abstract, forcing subclasses to provide their specific implementations. It also exposes a protected helper method getFullUrl.
* ProductService (Concrete Class): Extends BaseApiService and implements ICrudService. It provides the concrete apiUrl and handleError implementation, while inheriting all the common CRUD methods. It also adds a specific method getProductsByCategory.
This setup allows for a highly flexible and testable data layer. You could easily create a UserService by extending BaseApiService, or swap ProductService with a MockProductService (also implementing ICrudService) in tests, without changing the components that use them.

C. Inheritance: Building on Existing Foundations

Definition: Inheritance is an OOP mechanism where a new class (subclass/derived class) is created from an existing class (superclass/base class). The subclass inherits properties and methods from its superclass, promoting code reuse and establishing an “is-a” relationship (e.g., a Dog is a Mammal).

Why it’s Crucial in Angular:

* Code Reusability: Share common logic, properties, and lifecycle hooks among related components, services, or directives.
* Reduced Boilerplate: Avoids duplicating code across similar classes.
* Hierarchical Design: Helps in organizing classes into a logical hierarchy, reflecting the application’s structure.
* Base Components/Services: Common patterns in Angular involve creating BaseComponent or BaseService classes.
TypeScript Keywords:
* extends: Used to inherit from a class.
* super(): Used in the subclass constructor to call the superclass constructor. Also used to call superclass methods (super.method()).

Code Example: Standardized Form Components with Shared Logic
Imagine a scenario where many forms in your application share common functionalities like validation messages, saving state, or reactive form setup.

// src/app/shared/components/base-form.component.ts
import { OnInit, OnDestroy, Directive } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

// Using @Directive instead of @Component if it's purely for inheritance,
// without its own template. If it had a shared template snippet, @Component is fine.
@Directive()
export abstract class BaseFormComponent implements OnInit, OnDestroy {
public form!: FormGroup; // Must be initialized by subclass
public isLoading: boolean = false;
public errorMessage: string | null = null;
public successMessage: string | null = null;

protected destroy$ = new Subject<void>(); // For RxJS cleanup

constructor() {}

ngOnInit(): void {
if (!this.form) {
console.warn('FormGroup not initialized in BaseFormComponent subclass. Ensure this.form is set in constructor/ngOnInit.');
return;
}
// Listen to form value changes (example of shared behavior)
this.form.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => {
this.clearMessages(); // Clear messages on user input
});
}

// Abstract method: forces subclasses to implement how they submit
protected abstract onSubmitForm(): void;

// Shared public method for external interaction (e.g., save button click)
public submit(): void {
this.clearMessages();
if (this.form.invalid) {
this.form.markAllAsTouched();
this.errorMessage = 'Please fix the errors in the form.';
return;
}
this.isLoading = true;
this.onSubmitForm(); // Call subclass-specific submission logic
}

// Protected method: accessible by subclasses for common validation logic
protected getControlValidationMessage(controlName: string, customMessages?: { [key: string]: string }): string | null {
const control = this.form.get(controlName);
if (!control || (control.valid && !control.touched)) { // Only show errors if touched and invalid
return null;
}

const messages: { [key: string]: string } = {
required: 'This field is required.',
minlength: 'Minimum length not met.',
maxlength: 'Maximum length exceeded.',
email: 'Invalid email format.',
// Add more common validation messages
...customMessages // Allow subclass to override or add specific messages
};

for (const errorKey in control.errors) {
if (control.errors.hasOwnProperty(errorKey) && messages[errorKey]) {
return messages[errorKey];
}
}
return null;
}

// Private method: purely internal helper
private clearMessages(): void {
this.errorMessage = null;
this.successMessage = null;
}

ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
// src/app/user/user-profile-form.component.ts
import { Component, OnInit } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { BaseFormComponent } from '../../app/shared/components/base-form.component';
import { UserProfile } from '../auth/models'; // Re-use UserProfile interface

@Component({
selector: 'app-user-profile-form',
template: `
<div class="user-profile-form">
<h3>Edit User Profile</h3>
<form [formGroup]="form" (ngSubmit)="submit()">
<div class="form-group">
<label for="username">Username:</label>
<input id="username" type="text" formControlName="username">
<div *ngIf="getControlValidationMessage('username')" class="error-message">
{{ getControlValidationMessage('username') }}
</div>
</div>
<div class="form-group">
<label for="email">Email:</label>
<input id="email" type="email" formControlName="email">
<div *ngIf="getControlValidationMessage('email', { email: 'Please enter a valid email address.' })" class="error-message">
{{ getControlValidationMessage('email', { email: 'Please enter a valid email address.' }) }}
</div>
</div>
<button type="submit" [disabled]="isLoading">
{{ isLoading ? 'Saving...' : 'Save Profile' }}
</button>

<div *ngIf="errorMessage" class="alert error">{{ errorMessage }}</div>
<div *ngIf="successMessage" class="alert success">{{ successMessage }}</div>
</form>
</div>
,
styles: [
.user-profile-form { padding: 20px; border: 1px solid #ccc; border-radius: 8px; max-width: 500px; margin: 20px auto; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
input { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; }
.error-message { color: #dc3545; font-size: 0.85em; margin-top: 5px; }
button { padding: 10px 20px; background-color: #007bff; color: white; border: none; border-radius: 5px; cursor: pointer; }
button:disabled { background-color: #ccc; }
.alert { padding: 10px; border-radius: 5px; margin-top: 15px; }
.alert.error { background-color: #f8d7da; color: #721c24; border-color: #f5c6cb; }
.alert.success { background-color: #d4edda; color: #155724; border-color: #c3e6cb; }
`]
})
export class UserProfileFormComponent extends BaseFormComponent implements OnInit {
constructor(private fb: FormBuilder) {
super(); // Call the constructor of BaseFormComponent
}

ngOnInit(): void {
this.form = this.fb.group({
username: ['', [Validators.required, Validators.minLength(3)]],
email: ['', [Validators.required, Validators.email]]
});
// Call parent's ngOnInit to set up common behaviors
super.ngOnInit();
}

// Implementation of the abstract method from BaseFormComponent
protected onSubmitForm(): void {
const userData: UserProfile = this.form.value;
console.log('Submitting User Profile:', userData);
// Simulate API call
setTimeout(() => {
this.isLoading = false;
this.successMessage = 'Profile updated successfully!';
// In a real app, you'd handle success/error from service call
}, 1500);
}

// Can optionally override parent methods if needed, e.g., getControlValidationMessage
// or add new specific methods for UserProfileForm
}

Explanation:
* BaseFormComponent (Abstract Base Class):
* Uses @Directive() (or @Component() if it had a template) and is abstract because it’s not meant to be instantiated directly.
* Manages common form state (isLoading, errorMessage), RxJS cleanup (destroy$), and lifecycle hooks.
* Provides a concrete submit() method that performs universal form checks and then delegates to an abstract onSubmitForm().
* Offers a protected method getControlValidationMessage for retrieving validation messages, allowing subclasses to customize them.
* Has a private helper clearMessages.
* UserProfileFormComponent (Concrete Subclass):
* extends BaseFormComponent, inheriting its properties and methods.
* Calls super() in its constructor to ensure the BaseFormComponent’s constructor is run.
* Implements the abstract onSubmitForm() method, providing the specific logic for submitting user profile data.
* Calls super.ngOnInit() to ensure the base component’s initialization logic (like valueChanges subscription) is executed.
* Uses the inherited getControlValidationMessage in its template.
This pattern drastically reduces code duplication for form components and ensures consistency in behavior across your application’s forms.

D. Polymorphism: Flexibility Through Many Forms

Definition: Polymorphism (meaning “many forms”) is the ability of an object to take on many forms. In OOP, it refers to the ability to treat objects of different classes that share a common interface or base class in a uniform way. It’s often achieved through method overriding (when a subclass provides a specific implementation for a method already defined in its superclass) and interface implementation.

Why it’s Crucial in Angular:

* Flexible Design: Allows you to write code that operates on an abstraction, supporting various concrete implementations.
* Extensibility: Easily add new types or behaviors without modifying existing code.
* Dependency Injection: Angular’s DI heavily relies on polymorphism. You inject an interface or abstract class, and Angular provides a concrete instance.
* Dynamic Behavior: Render different components or execute different logic based on the specific type of an object at runtime.

Code Example: Unified Notification System
Let’s create a notification system that can send different types of notifications (Email, SMS, Push) through a common interface.

// src/app/notifications/interfaces/notification-strategy.interface.ts
import { Observable } from 'rxjs';

export interface INotificationStrategy {
send(recipient: string, message: string): Observable<boolean>;
}
// src/app/notifications/email-notification.strategy.ts
import { Injectable } from '@angular/core';
import { INotificationStrategy } from './interfaces/notification-strategy.interface';
import { Observable, of } from 'rxjs';
import { delay, tap } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class EmailNotificationStrategy implements INotificationStrategy {
send(recipient: string, message: string): Observable<boolean> {
console.log([Email] Sending to ${recipient}: "${message}");
// Simulate async email sending
return of(true).pipe(delay(1000), tap(() => console.log([Email] Sent to ${recipient})));
}
}
// src/app/notifications/sms-notification.strategy.ts
import { Injectable } from '@angular/core';
import { INotificationStrategy } from './interfaces/notification-strategy.interface';
import { Observable, of } from 'rxjs';
import { delay, tap } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class SmsNotificationStrategy implements INotificationStrategy {
send(recipient: string, message: string): Observable<boolean> {
if (!recipient.match(/^\d{10}$/)) { // Simple phone number validation
console.error([SMS] Invalid phone number: ${recipient});
return of(false);
}
console.log([SMS] Sending to ${recipient}: "${message}");
// Simulate async SMS sending
return of(true).pipe(delay(500), tap(() => console.log([SMS] Sent to ${recipient})));
}
}
// src/app/notifications/push-notification.strategy.ts
import { Injectable } from '@angular/core';
import { INotificationStrategy } from './interfaces/notification-strategy.interface';
import { Observable, of } from 'rxjs';
import { delay, tap } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class PushNotificationStrategy implements INotificationStrategy {
send(recipient: string, message: string): Observable<boolean> {
// For push notifications, recipient might be a device token or user ID
if (!recipient.startsWith('device_')) {
console.error([Push] Invalid device token: ${recipient});
return of(false);
}
console.log([Push] Sending to device ${recipient}: "${message}");
// Simulate async push notification sending
return of(true).pipe(delay(300), tap(() => console.log([Push] Sent to ${recipient})));
}
}
// src/app/notifications/notification.service.ts
import { Injectable } from '@angular/core';
import { INotificationStrategy } from './interfaces/notification-strategy.interface';
import { EmailNotificationStrategy } from './email-notification.strategy';
import { SmsNotificationStrategy } from './sms-notification.strategy';
import { PushNotificationStrategy } from './push-notification.strategy';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export enum NotificationType {
Email = 'email',
SMS = 'sms',
Push = 'push'
}

@Injectable({
providedIn: 'root'
})
export class NotificationService {
private strategies: Map<NotificationType, INotificationStrategy>;
constructor(
private emailStrategy: EmailNotificationStrategy,
private smsStrategy: SmsNotificationStrategy,
private pushStrategy: PushNotificationStrategy
) {
// Populate the map with different notification strategies
this.strategies = new Map<NotificationType, INotificationStrategy>();
this.strategies.set(NotificationType.Email, this.emailStrategy);
this.strategies.set(NotificationType.SMS, this.smsStrategy);
this.strategies.set(NotificationType.Push, this.pushStrategy);
}

// Polymorphic method: Uses the appropriate strategy based on type
sendNotification(type: NotificationType, recipient: string, message: string): Observable<boolean> {
const strategy = this.strategies.get(type);
if (!strategy) {
console.error(No strategy found for notification type: ${type});
return new Observable(observer => observer.next(false));
}
// Call the 'send' method polymorphically
return strategy.send(recipient, message).pipe(
map(success => {
if (success) {
console.log(Notification of type ${type} sent successfully.);
} else {
console.warn(Notification of type ${type} failed to send.);
}
return success;
})
);
}
}
// src/app/notifications/notification-sender.component.ts
import { Component } from '@angular/core';
import { NotificationService, NotificationType } from './notification.service';
import { Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators';

@Component({
selector: 'app-notification-sender',
template:
<div class="notification-sender-card">
<h3>Send Notification</h3>
<div class="form-group">
<label for="type">Notification Type:</label>
<select id="type" [(ngModel)]="selectedType">
<option *ngFor="let type of notificationTypes" [value]="type">{{ type | titlecase }}</option>
</select>
</div>
<div class="form-group">
<label for="recipient">Recipient:</label>
<input id="recipient" type="text" [(ngModel)]="recipient">
</div>
<div class="form-group">
<label for="message">Message:</label>
<textarea id="message" [(ngModel)]="message"></textarea>
</div>
<button (click)="send()">Send</button>

<div *ngIf="statusMessage" class="status-message" [ngClass]="{'success': isSuccess, 'error': !isSuccess}">
{{ statusMessage }}
</div>
</div>
,
styles: [
.notification-sender-card { padding: 20px; border: 1px solid #ccc; border-radius: 8px; max-width: 500px; margin: 20px auto; background-color: #f9f9f9; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
input[type="text"], textarea, select { width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; }
button { padding: 10px 20px; background-color: #007bff; color: white; border: none; border-radius: 5px; cursor: pointer; margin-top: 10px; }
button:hover { background-color: #0056b3; }
.status-message { padding: 10px; margin-top: 20px; border-radius: 5px; }
.status-message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.status-message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
]
})
export class NotificationSenderComponent {
notificationTypes = Object.values(NotificationType);
selectedType: NotificationType = NotificationType.Email;
recipient: string = '';
message: string = '';
statusMessage: string | null = null;
isSuccess: boolean = false;

constructor(private notificationService: NotificationService) {}

send(): void {
this.statusMessage = 'Sending...';
this.notificationService.sendNotification(this.selectedType, this.recipient, this.message).pipe(
catchError(err => {
this.statusMessage = Failed to send: ${err.message};
this.isSuccess = false;
return of(false); // Propagate observable completion
})
).subscribe(success => {
this.isSuccess = success;
this.statusMessage = success ? 'Notification sent successfully!' : 'Notification failed!';
});
}
}

Explanation:

* INotificationStrategy (Interface): Defines the send method that all notification strategies must implement. This is the common contract.
* EmailNotificationStrategy, SmsNotificationStrategy, PushNotificationStrategy (Concrete Classes): Each implements INotificationStrategy and provides its specific logic for sending a notification. They fulfill the “many forms” aspect.
* NotificationService (Client):
* Injects all concrete strategy implementations using Angular’s Dependency Injection.
* Stores them in a Map.
* The sendNotification method is polymorphic: it looks up the correct strategy by type from the Map and then calls its send() method. The NotificationService itself doesn’t need to know the details of how each strategy sends. It just knows they all conform to the INotificationStrategy interface.
This example allows for easy addition of new notification types (e.g., SlackNotificationStrategy) without modifying the NotificationService’s sendNotification logic, demonstrating open/closed principle and highly flexible design.

Conclusion

As we’ve delved into the foundational OOP concepts — Encapsulation, Abstraction, Inheritance, and Polymorphism — it becomes clear that these aren’t merely theoretical constructs but practical tools for architecting robust Angular applications. More importantly, they foster a distinct object-oriented thought process.

This mindset encourages you to look at your application’s requirements not as a series of isolated functions, but as a dynamic ecosystem of interacting “objects.” You learn to identify distinct entities, define their responsibilities, encapsulate their internal workings, and establish clear, flexible contracts for their collaboration.

In Angular, this translates directly to building components as self-contained UI objects, services as dedicated business logic or data providers, and models as faithful representations of your domain. By embracing this approach, you instinctively decompose complex problems into manageable, coherent units, leading to code that is inherently more modular, maintainable, and testable.

Ultimately, mastering the object-oriented thought process is what empowers you to move beyond basic Angular development. It’s the cognitive shift that enables you to design scalable architectures, anticipate future changes, and build resilient applications that can stand the test of time. It’s the architect’s compass in the complex landscape of modern web development.

--

--

Written by ANUTOSH GHOSH

I am passionate about software development and full stack development. I am aiming to become a MERN stack developer. Loves C++. Learning Java.