DEV Community

Manish Boge
Manish Boge

Posted on • Originally published at manishboge.Medium on

OOP Applied in Angular: From Theory to Practice

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

Angular DI Wiring

providers: [
  { provide: PaymentGateway, useClass: GooglePay}
]
Enter fullscreen mode Exit fullscreen mode

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 }
]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

Environment-Based Injection

providers: [
  {
    provide: Logger,
    useClass: environment.production ? ServerLogger : ConsoleLogger
  }
]
Enter fullscreen mode Exit fullscreen mode

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) {}
Enter fullscreen mode Exit fullscreen mode
  • 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 {}
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

Summary

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)