Beyond the Basics: Building Scalable Angular Architecture
In Part 1: OOP Fundamentals, we explored Object-Oriented Programming (OOP) as a design mindset, not just a set of keywords. We looked at how Abstraction, Encapsulation, Inheritance, Polymorphism, and Composition help software survive change over time using TypeScript as Programming Language.
In this part , we bring those ideas to life by applying them directly to Angular applications.
Angular is deeply aligned with OOP principles — especially when you design your architecture intentionally.
Today, we move past “basic classes” and look at how to use modern Angular features to enforce professional design patterns.
Let us dive in.
Why OOP Matters in Angular Applications
Angular applications don’t stay small for long. As features multiply and teams grow, codebases naturally move toward entropy. Without strong design boundaries, you end up with:
- God Services: Single files managing 5,000 lines of unrelated logic.
- Leaky State: Components reaching into services to mutate data directly.
- Fragile Hierarchies: Deep inheritance trees where a change in the base class breaks ten children.
So OOP helps Angular apps by:
- Isolating responsibilities
- Protecting state
- Enforcing contracts
- Enabling extensibility through DI
OOP isn’t about writing more code; it’s about isolating responsibilities.
Let us now understand applying OOP principles one by one in Angular.
1. Abstraction: Contracts Over Concretes
The Concept
Abstraction in Angular means depending on contracts, not concrete implementations.
In simple terms, Abstraction means depending on what a service does, not how it does it.
In Angular, abstraction is most commonly achieved using:
- Interfaces
- Abstract classes
- Dependency Injection (DI)
The Problem: Tightly Coupled Services
Many developers try to use TypeScript interfaces for DI. However, interfaces are kind of "ghosts"—they disappear at compile-time , leaving Angular with no runtime token to inject.
In TypeScript, Interfaces are metadata. They are used for type-checking during development but are completely stripped away (erased) during the transpilation process to JavaScript.
@Injectable()
export class OrderService {
placeOrder(amount: number) {
console.log('Paying via GooglePay');
}
}
Issues:
- Business logic depends on a concrete payment provider
- Hard to replace or mock
- Hard to test
The Solution: Abstract the Behavior
export interface PaymentGateway {
charge(amount: number): void;
}
@Injectable()
export class GooglePay implements PaymentGateway {
charge(amount: number): void {
console.log('Charging via GooglePay');
}
}
@Injectable()
export class OrderService {
constructor(private paymentGateway: PaymentGateway) {}
placeOrder(amount: number) {
this.paymentGateway.charge(amount);
}
}
Angular DI Wiring
providers: [
{ provide: PaymentGateway, useClass: GooglePay}
]
Wait … Here we made a mistake. The above code throws error at RunTime.
It will not find the token for the provider PaymentGateway.
But Why? — We have defined an interface right. Yes. But there is a problem with interface that we defined.
Let us understand more about it and solve this issue.
The Problem: In TypeScript, Interfaces are metadata. They are used for type-checking during development but are completely stripped away (erased) during the transpilation process to JavaScript.
The Result: When Angular’s Dependency Injection (DI) engine tries to run your code in the browser, it looks for a token named PaymentGateway. Because it was an interface, that token does not exist in the JavaScript bundle.
Angular will throw a NullInjectorError or an error stating it cannot find a provider for PaymentGateway.
Solution: Using Abstract Classes
Unlike interfaces, abstract classes exist in the JavaScript runtime and can serve as unique DI tokens.
So, we define an abstract class. It acts as both the interface (contract) and the runtime DI token. Derived classes can extend it.
// 1. Use an Abstract Class instead of an Interface
export abstract class PaymentGateway {
abstract charge(amount: number): void;
}
@Injectable({ providedIn: 'root' })
export class GooglePay extends PaymentGateway { // Use 'extends'
charge(amount: number): void {
console.log('Charging via GooglePay');
}
}
@Injectable({ providedIn: 'root' })
export class OrderService {
// 2. Modern Angular 'inject' function is preferred over constructor injection
private paymentGateway = inject(PaymentGateway);
placeOrder(amount: number) {
this.paymentGateway.charge(amount);
}
}
// 3. DI Wiring (in AppModule or AppConfig)
// Now PaymentGateway is a valid runtime token!
providers: [
{ provide: PaymentGateway, useClass: GooglePay }
]
What we achieved:
- OrderService depends on what a payment gateway does
- Not how it does it
- Switching providers requires zero business logic change
Abstraction defines capability , not the implementation.
Interface vs Abstract Classes
We are extending the PaymentGateway. B ut isn’t extends bad ? Like if we change PaymentGateway class, will it not force child classes to change ?
Let us understand these aspects to clear the difference between abstract class with and a standard class in the context of inheritance.
When you use a standard class inheritance, you are inheriting behavior (code).
When you use an abstract class with abstract methods, you are only inheriting a contract.
Does it force children to change?
If you add a new abstract method to the PaymentGateway base class, yes , every child (GooglePay, Stripe, PayPal) class will “ break” and force you to implement that method.
However, this is actually a good thing. Abstraction is a contract. If your “Payment” contract changes to require a refund() method, we want the compiler to tell us that GooglePay is now missing that capability. It prevents runtime errors where the OrderService tries to call a method that doesn't exist.
Why not just use an interface?
An interface is the purest form of a contract. But as we discussed, TypeScript interfaces don't exist in JavaScript. If we strictly want to avoid the extends keyword but still want the safety of DI, you use an InjectionToken.
No worries — we will cover more about Injection Token in another article.
2. Encapsulation: Protecting State
The Concept
Encapsulation protects an object’s internal state and ensures rules are enforced.
In Angular, encapsulation applies strongly to:
- Services
- State management logic
- Domain models
The Problem: Leaky State
@Injectable()
export class CartService {
products: Product[] = [];
}
this.cartService.products.push({ price: -100 }); // Invalid state
Nothing prevents misuse. Any componet which consumes CartService can modify the *products * array.
The Solution: Encapsulated State
@Injectable()
export class CartService {
private items: CartItem[] = [];
addItem(item: CartItem) {
if (item.price <= 0) {
throw new Error('Invalid price');
}
this.items.push(item);
}
getItems(): readonly CartItem[] {
return this.items;
}
}
Why This Matters in Angular
- Prevents accidental mutations
- Enforces business rules centrally
- Makes debugging predictable
Encapsulation keeps Angular apps stable under change.
3. Inheritance in Angular (Use Carefully)
The Concept
Inheritance models an Is-A relationship.
Angular supports inheritance well but it’s also where many designs go wrong.
Let us understand now.
Valid Use Case: Base Components
export abstract class BaseComponent {
isLoading = false;
protected startLoading() {
this.isLoading = true;
}
protected stopLoading() {
this.isLoading = false;
}
}
@Component({ /* ... */ })
export class UserComponent extends BaseComponent {
loadUsers() {
this.startLoading();
}
}
When Inheritance Hurts
- Deep component hierarchies
- Hidden side effects
- Tight coupling to base class changes
Inheritance increases coupling — hence hard to manage the derived calss in accordance with base class changes.
Rule of Thumb:
Use inheritance only when the relationship is undeniably Is-A_._
4. Polymorphism in Angular
The Concept
Polymorphism allows different implementations to be used interchangeably.
Angular’s DI system is a polymorphism engine.
The Problem: Conditional Services
Let us understand a scenario where we need to use the Console for logs in development mode only.
if (env === 'dev') {
this.logger.logToConsole();
} else {
this.logger.logToServer();
}
If more environments like stage, uat , qa then it will be:
- Hard to modify and read
- Easy to break
- We loose code extendability
The Polymorphic Solution
We create a contract Logger . Implement the Contract based on the requirement.
export abstract class Logger {
abstract log(message: string): void;
}
@Injectable()
export class ConsoleLogger extends Logger {
log(message: string) {
console.log(message);
}
}
@Injectable()
export class ServerLogger extends Logger {
log(message: string) {
// send to API
}
}
Environment-Based Injection
providers: [
{
provide: Logger,
useClass: environment.production ? ServerLogger : ConsoleLogger
}
]
Result:
- No conditionals
- Behavior changes at runtime
- Open for extension, closed for modification
Polymorphism eliminates branching logic.
Abstraction + Polymorphism in Angular DI
// Service
@Injectable()
export class ConsoleLogger implements Logger {
log(message: string) {
console.log(message);
}
}
// In a component
constructor(private logger: Logger) {}
- Logger → Abstraction
- ConsoleLogger / ServerLogger → Polymorphism
Angular resolves the concrete class at runtime.
5. Composition Over Inheritance in Angular
The Problem with Inheritance-Based Reuse
export class LoggingComponent {
log(msg: string) {}
}
export class OrderComponent extends LoggingComponent {}
Components now inherit behavior they may not always need.
Solution: Composition with Services
@Injectable()
export class LoggerService {
log(message: string) {
console.log(message);
}
}
@Component({ /* ... */ })
export class OrderComponent {
constructor(private logger: LoggerService) {} // You can use inject function as well.
createOrder() {
this.logger.log('Order created');
}
}
Why Angular Loves Composition
- DI makes composition effortless
- Services are swappable
- Testing becomes trivial
Composition creates horizontal flexibility .
Mapping OOP Principles to Angular Concepts
- Abstraction → Interfaces, DI Tokens
- Encapsulation → Services, Private State
- Inheritance → Base Components / Classes
- Polymorphism → Dependency Injection
- Composition → Service Injection
Angular is not anti-OOP it’s OOP done pragmatically.
Summary
OOP in Angular is not about classes. It’s about designing systems that evolve safely.
- Depend on abstractions to keep your services swappable.
- Protect state aggressively using access modifiers.
- Prefer composition over deep inheritance trees.
Check List:
What You Should Do Next
- Refactor one fat component using services
- Replace conditionals with polymorphic providers
- Use abstract classes over interfaces
Once you start seeing OOP in Angular, you can’t unsee it.
Source Code & Resources
If you’d like to dive deeper or try the examples yourself, all source code including those discussed in this series is available on my GitHub repository: Oop-Applied-Angular
Don’t forget to give this Repository a star ⭐ — not only does it help you stay updated when code changes, but it also shows appreciation for the content and motivates continued development.


Top comments (0)