Munsif.
AboutExperienceProjectsAchievementsBlogsContact
HomeAboutExperienceProjectsAchievementsBlogsContact
Munsif.

Frontend Developer crafting scalable web applications with modern technologies and clean code practices.

Quick Links

  • About
  • Experience
  • Projects
  • Achievements
  • Blogs
  • Contact

Connect

© 2026 Shaik Munsif. All rights reserved.

Built with Next.js & Tailwind

0%
Welcome back!Continue where you left off
Back to Blogs
Angular

SOLID Principles in Angular: An In-Depth Guide with Real-World Examples

Master all five SOLID principles — Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion — through Angular-specific before/after examples, mermaid diagrams, and a full feature walkthrough.

Apr 25, 202620 min read
AngularSOLIDArchitectureBest PracticesTypeScriptDesign PatternsInterview Prep

Introduction

You've probably seen SOLID mentioned in job interviews, code reviews, or architecture discussions. It stands for five design principles that make your code easier to change, test, and understand. They were coined by Robert C. Martin ("Uncle Bob") for general object-oriented programming, but they fit Angular's world — components, services, DI — almost perfectly.

This is not a theory post. Every principle comes with a real Angular "before" code that has the problem, a clear "after" that fixes it, and a plain-English explanation of why it matters.

Think of SOLID like this: imagine a kitchen where every chef does exactly one job, the kitchen layout never needs to change when you add a new menu item, and every sous chef can cover for any other without the menu falling apart. That's what SOLID does for your codebase.


Quick Reference

Reading Time: ~20 minutes
Skill Level: Intermediate → Advanced
Prerequisites: Familiarity with Angular components, services, and TypeScript classes

Navigate by Section:

  • S — Single Responsibility — One reason to change
  • O — Open/Closed — Extend without modifying
  • L — Liskov Substitution — Substitutable subtypes
  • I — Interface Segregation — Slim, focused interfaces
  • D — Dependency Inversion — Depend on abstractions
  • Putting It Together — Full-feature example
  • Angular-Specific Guidance — Framework patterns
  • Common Violations — Anti-patterns to avoid

S — Single Responsibility Principle

"A class should have only one reason to change."
— Robert C. Martin

What It Means

Every class should own exactly one job. If your class needs to change because the API URL changed and also because the display format changed — those are two different reasons, meaning two different jobs are living in the same place. Split them.

A simple test: ask yourself, "Who would ask me to change this class?" If two different teams or two different requirements could drive a change, it's doing too much.

Violation in Angular

Here's a component that's playing four roles at once — fetching data, saving data, formatting a name, and enforcing a permission rule:

typescript
// ❌ BAD: UserComponent does too many things
@Component({
  selector: 'app-user',
  templateUrl: './user.component.html'
})
export class UserComponent implements OnInit {
  user: User | null = null;
  errorMessage = '';

  constructor(private http: HttpClient, private router: Router) {}

  ngOnInit() {
    // Responsibility 1: fetch data
    this.http.get<User>('/api/user/1').subscribe({
      next: (data) => (this.user = data),
      error: () => (this.errorMessage = 'Failed to load user')
    });
  }

  formatName(): string {
    // Responsibility 2: format display
    return `${this.user?.firstName} ${this.user?.lastName}`.trim();
  }

  saveUser() {
    // Responsibility 3: persist data
    this.http.put('/api/user', this.user).subscribe(() => {
      this.router.navigate(['/dashboard']);
    });
  }

  isAdminRole(): boolean {
    // Responsibility 4: business rule
    return this.user?.role === 'ADMIN';
  }
}

This one file has four distinct reasons to change:

  1. The API endpoint URL changes
  2. The name display format changes
  3. The save/redirect flow changes
  4. The admin role logic changes

Refactored with SRP

The fix is simple: give each concern its own home. HTTP calls go into a service. Permission logic goes into its own service. Formatting goes into a pipe. The component keeps only the view-coordination job.

typescript
// ✅ GOOD: Extract data access into a service
@Injectable({ providedIn: 'root' })
export class UserApiService {
  constructor(private http: HttpClient) {}

  getUser(id: number): Observable<User> {
    return this.http.get<User>(`/api/user/${id}`);
  }

  saveUser(user: User): Observable<void> {
    return this.http.put<void>('/api/user', user);
  }
}
typescript
// ✅ Extract business rules into a separate service
@Injectable({ providedIn: 'root' })
export class UserPermissionService {
  isAdmin(user: User): boolean {
    return user.role === 'ADMIN';
  }
}
typescript
// ✅ Extract formatting into a pipe
@Pipe({ name: 'fullName', standalone: true })
export class FullNamePipe implements PipeTransform {
  transform(user: User | null): string {
    if (!user) return '';
    return `${user.firstName} ${user.lastName}`.trim();
  }
}
typescript
// ✅ The component now has ONE responsibility: coordinate the view
@Component({
  selector: 'app-user',
  templateUrl: './user.component.html',
  imports: [FullNamePipe]
})
export class UserComponent implements OnInit {
  user = signal<User | null>(null);
  errorMessage = signal('');

  private userApi = inject(UserApiService);
  private permissions = inject(UserPermissionService);
  private router = inject(Router);

  isAdmin = computed(() => {
    const u = this.user();
    return u ? this.permissions.isAdmin(u) : false;
  });

  ngOnInit() {
    this.userApi.getUser(1).subscribe({
      next: (u) => this.user.set(u),
      error: () => this.errorMessage.set('Failed to load user')
    });
  }

  save() {
    const u = this.user();
    if (!u) return;
    this.userApi.saveUser(u).subscribe(() => {
      this.router.navigate(['/dashboard']);
    });
  }
}

Now if the API changes, only UserApiService needs updating. If the name format changes, only FullNamePipe changes. Changes stay isolated.

SRP Decision Checklist

QuestionIf "Yes" → Consider splitting
Does the class change for two different business reasons?✅
Does the class import both HttpClient and DOM-manipulation helpers?✅
Would a new developer say "this does a few things"?✅
Does the class have more than ~200 lines (excluding template)?✅

O — Open/Closed Principle

"Software entities should be open for extension, but closed for modification."
— Bertrand Meyer

What It Means

You should be able to add new behaviour to your app without editing code that already works. Think of a power strip — you plug new devices in without rewiring the strip itself. Good software works the same way: new features are "plugged in" as new code, not patched into old code.

Why does this matter? Every time you edit working code, you risk breaking something that was passing tests. OCP protects stable, tested code from accidental regressions.

Violation in Angular

typescript
// ❌ BAD: Every new notification type requires editing this class
@Injectable({ providedIn: 'root' })
export class NotificationService {
  send(type: 'email' | 'sms' | 'push', message: string): void {
    if (type === 'email') {
      console.log(`[Email] ${message}`);
    } else if (type === 'sms') {
      console.log(`[SMS] ${message}`);
    } else if (type === 'push') {
      console.log(`[Push] ${message}`);
      // Adding 'slack' means touching this switch-case ❌
    }
  }
}

Every time your product team asks for a new channel — Slack, Teams, WhatsApp — a developer has to open this file, edit it, and re-test the whole thing. That's risky and doesn't scale.

Refactored with OCP

Instead, define a common "channel" shape and make each channel a separate class. Adding Slack means creating one new file and registering it — the existing NotificationService never changes.

typescript
// ✅ Define an abstraction (interface)
export interface NotificationChannel {
  send(message: string): void;
}
typescript
// ✅ Each channel is a separate, independently testable class
@Injectable()
export class EmailChannel implements NotificationChannel {
  send(message: string): void {
    console.log(`[Email] ${message}`);
  }
}

@Injectable()
export class SmsChannel implements NotificationChannel {
  send(message: string): void {
    console.log(`[SMS] ${message}`);
  }
}

// Adding Slack: add a NEW file — don't touch anything existing ✅
@Injectable()
export class SlackChannel implements NotificationChannel {
  send(message: string): void {
    console.log(`[Slack] ${message}`);
  }
}
typescript
// ✅ Aggregate using Angular's multi-provider pattern
export const NOTIFICATION_CHANNELS = new InjectionToken<NotificationChannel[]>(
  'notification.channels'
);

// In app.config.ts:
export const appConfig: ApplicationConfig = {
  providers: [
    { provide: NOTIFICATION_CHANNELS, useClass: EmailChannel, multi: true },
    { provide: NOTIFICATION_CHANNELS, useClass: SmsChannel, multi: true },
    { provide: NOTIFICATION_CHANNELS, useClass: SlackChannel, multi: true }
    // Adding a new channel = add ONE line here. Zero existing code changes.
  ]
};
typescript
// ✅ The orchestrator never changes
@Injectable({ providedIn: 'root' })
export class NotificationService {
  private channels = inject(NOTIFICATION_CHANNELS);

  sendAll(message: string): void {
    this.channels.forEach(channel => channel.send(message));
  }
}

OCP in Angular's Own Framework

Angular itself is designed with OCP in mind. You never modify Angular's internals — you extend them:

  • HTTP_INTERCEPTORS — add interceptors without modifying HttpClient
  • APP_INITIALIZER — run init logic without modifying AppComponent
  • Router guards — add auth/role checks without changing the router itself
  • CanActivate, CanDeactivate — plug in logic without touching route definitions

L — Liskov Substitution Principle

"Objects of a supertype should be replaceable with objects of a subtype without altering the correctness of the program."
— Barbara Liskov

What It Means

In plain terms: if class B extends class A, you should be able to use B anywhere A is expected, and everything should still work correctly. A subclass can do extra things, but it must never break the promises the parent class made.

A practical sign of an LSP violation: you start adding if (instanceof SomeSubclass) checks in your code. If the caller has to inspect what specific type it got, substitution is already broken.

Violation in Angular

The classic example is the Square/Rectangle problem. A Square is mathematically a rectangle, but it doesn't behave like one in code:

typescript
// ❌ Classic LSP violation: squares are NOT substitutable for rectangles
class Rectangle {
  protected width = 0;
  protected height = 0;

  setWidth(w: number) { this.width = w; }
  setHeight(h: number) { this.height = h; }
  area() { return this.width * this.height; }
}

class Square extends Rectangle {
  // Square violates contract: setting width also changes height (unexpected!)
  override setWidth(w: number) {
    this.width = w;
    this.height = w; // ← breaks consumer expectations
  }
  override setHeight(h: number) {
    this.height = h;
    this.width = h; // ← breaks consumer expectations
  }
}

// Consumer code breaks silently:
function resizeWidth(shape: Rectangle, newWidth: number): void {
  shape.setWidth(newWidth);
  // Consumer expects height to stay the same — Square breaks this assumption
  console.log(shape.area()); // Wrong result for Square!
}

Angular Service Substitution

LSP is most useful in Angular when swapping services between environments — production vs development vs tests. The contract (abstract class) guarantees that swapping the implementation never surprises the consumer:

typescript
// ✅ Define a clear contract via an abstract class or interface
abstract class LoggingService {
  abstract log(message: string): void;
  abstract error(message: string, err?: unknown): void;
}
typescript
// ✅ Production implementation — fully honours the contract
@Injectable()
export class ProductionLogger extends LoggingService {
  log(message: string): void {
    // Sends to Datadog / Splunk
    externalMonitoring.track('info', message);
  }
  error(message: string, err?: unknown): void {
    externalMonitoring.track('error', message, err);
  }
}
typescript
// ✅ Development stub — also fully honours the contract
@Injectable()
export class ConsoleLogger extends LoggingService {
  log(message: string): void {
    console.log(`[INFO] ${message}`);
  }
  error(message: string, err?: unknown): void {
    console.error(`[ERROR] ${message}`, err);
  }
}
typescript
// ✅ Consumer never knows which logger it gets — and doesn't need to
@Injectable({ providedIn: 'root' })
export class OrderService {
  private logger = inject(LoggingService); // <- abstract type

  placeOrder(order: Order): void {
    this.logger.log(`Order placed: ${order.id}`);
    // ...
  }
}
typescript
// Provide the right implementation per environment — zero consumer changes
export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: LoggingService,
      useClass: environment.production ? ProductionLogger : ConsoleLogger
    }
  ]
};

LSP in Angular Component Inheritance

The same idea applies to component hierarchies. If you build a BaseButtonComponent, every subclass should accept the same inputs and fire the same outputs — so the parent template never needs to know which specific button it's using:

typescript
// ✅ Base component defines the contract
@Component({ template: '' })
export abstract class BaseButtonComponent {
  @Input({ required: true }) label!: string;
  @Input() disabled = false;
  @Output() clicked = new EventEmitter<void>();
}
typescript
// ✅ All subclasses honour the contract
@Component({
  selector: 'app-primary-button',
  template: `<button [disabled]="disabled" (click)="clicked.emit()">{{ label }}</button>`
})
export class PrimaryButtonComponent extends BaseButtonComponent {}

@Component({
  selector: 'app-danger-button',
  template: `<button class="danger" [disabled]="disabled" (click)="clicked.emit()">{{ label }}</button>`
})
export class DangerButtonComponent extends BaseButtonComponent {}

LSP Checklist for Angular Services

RuleViolation Signal
Subclass must not throw where parent does notSubclass throws NotImplementedError
Subclass must not narrow the input domainSubclass only works with admin users
Subclass must not return null where parent returns a valueStub returns null without contract saying so
Subclass must not ignore required output side-effectsLogger stub silently swallows logs

I — Interface Segregation Principle

"Clients should not be forced to depend on methods they do not use."
— Robert C. Martin

What It Means

Don't create one giant interface that every class must implement in full. Instead, break it into small, purpose-built interfaces. Each class (or component) only implements — and only knows about — the slice it actually needs.

The practical benefit shows up in tests: if a component only reads data, its mock should only need getAll() — not a stub for create(), update(), delete(), exportToCsv()...

Violation in Angular

typescript
// ❌ BAD: One fat interface that forces all implementors to handle everything
interface DataService {
  getAll(): Observable<Item[]>;
  getById(id: number): Observable<Item>;
  create(item: Item): Observable<Item>;
  update(id: number, item: Item): Observable<Item>;
  delete(id: number): Observable<void>;
  exportToCsv(): Observable<Blob>;          // Only admin needs this
  importFromCsv(file: File): Observable<void>; // Only admin needs this
  getAuditLog(id: number): Observable<Log[]>;  // Only auditors need this
}

A read-only dashboard component is forced to depend on create, update, delete, exportToCsv, etc. — methods it will never call and can't meaningfully implement in a test mock.

Refactored with ISP

Split the interface by role. Each consumer picks only the interface that matches what it does:

typescript
// ✅ Segregate into role-based interfaces
interface ReadableDataService {
  getAll(): Observable<Item[]>;
  getById(id: number): Observable<Item>;
}

interface WritableDataService extends ReadableDataService {
  create(item: Item): Observable<Item>;
  update(id: number, item: Item): Observable<Item>;
  delete(id: number): Observable<void>;
}

interface CsvDataService {
  exportToCsv(): Observable<Blob>;
  importFromCsv(file: File): Observable<void>;
}

interface AuditDataService {
  getAuditLog(id: number): Observable<Log[]>;
}
typescript
// ✅ The concrete service implements what it actually needs to
@Injectable({ providedIn: 'root' })
export class ItemService implements WritableDataService, CsvDataService {
  constructor(private http: HttpClient) {}

  getAll(): Observable<Item[]> { return this.http.get<Item[]>('/api/items'); }
  getById(id: number): Observable<Item> { return this.http.get<Item>(`/api/items/${id}`); }
  create(item: Item): Observable<Item> { return this.http.post<Item>('/api/items', item); }
  update(id: number, item: Item): Observable<Item> { return this.http.put<Item>(`/api/items/${id}`, item); }
  delete(id: number): Observable<void> { return this.http.delete<void>(`/api/items/${id}`); }
  exportToCsv(): Observable<Blob> { return this.http.get('/api/items/export', { responseType: 'blob' }); }
  importFromCsv(file: File): Observable<void> {
    const form = new FormData();
    form.append('file', file);
    return this.http.post<void>('/api/items/import', form);
  }
}
typescript
// ✅ Dashboard only depends on what it uses — typed to the slim interface
@Component({ selector: 'app-item-list', templateUrl: './item-list.component.html' })
export class ItemListComponent implements OnInit {
  private dataService = inject<ReadableDataService>(ItemService);
  items = signal<Item[]>([]);

  ngOnInit() {
    this.dataService.getAll().subscribe(items => this.items.set(items));
  }
}
typescript
// ✅ In specs, stubbing is trivial — only implement what the component uses
const mockReadService: ReadableDataService = {
  getAll: () => of([{ id: 1, name: 'Widget' }] as Item[]),
  getById: (id) => of({ id, name: 'Widget' } as Item)
};
// No need to stub create/update/delete/export/import — they're out of scope ✅

ISP and Angular Lifecycle Hooks

Angular's own lifecycle hooks are a textbook example of ISP. Each hook is a separate interface. Your component implements only the ones it needs — Angular never forces you to provide an empty ngOnChanges just because you implemented OnInit:

typescript
// Angular defines slim, focused lifecycle interfaces:
interface OnInit     { ngOnInit(): void; }
interface OnDestroy  { ngOnDestroy(): void; }
interface OnChanges  { ngOnChanges(changes: SimpleChanges): void; }
interface AfterViewInit { ngAfterViewInit(): void; }
// ...and so on

// Your component only implements the ones it uses:
@Component({ selector: 'app-counter', template: '' })
export class CounterComponent implements OnInit, OnDestroy {
  private sub = new Subscription();

  ngOnInit() { this.sub = interval(1000).subscribe(/* ... */); }
  ngOnDestroy() { this.sub.unsubscribe(); }
  // CounterComponent is NOT forced to implement ngOnChanges, ngAfterViewInit, etc.
}

D — Dependency Inversion Principle

"High-level modules should not depend on low-level modules. Both should depend on abstractions."
— Robert C. Martin

What It Means

Your business logic (the important stuff) should not be hardwired to specific tools like HttpClient, a concrete logger, or an API URL. Instead, the business logic should talk to an abstraction (an interface or abstract class), and the specific tool plugs in from outside.

Think of a TV remote and a TV. The remote (high-level) doesn't care if the TV is Samsung or Sony. It talks to a standard IR protocol (the abstraction). Both the remote and the TV depend on that protocol — not on each other. Swap the TV brand, the remote still works.

In Angular, this is mostly solved by Angular's own DI system — you just need to wire things up correctly.

Violation in Angular

typescript
// ❌ BAD: OrderService is "wired" directly to a concrete EmailService
import { EmailService } from './email.service'; // ← concrete import

@Injectable({ providedIn: 'root' })
export class OrderService {
  private emailService = inject(EmailService); // ← concrete dependency

  placeOrder(order: Order): void {
    this.processOrder(order);
    this.emailService.sendConfirmation(order); // ← tightly coupled
  }
}

If you want to test OrderService, you're forced to deal with the real EmailService. If you want to swap Email for SMS, you have to edit OrderService — which also breaks OCP.

Refactored with DIP

typescript
// ✅ Step 1: Define the abstraction — a "contract" both sides agree on
export abstract class NotificationSender {
  abstract sendConfirmation(order: Order): void;
}
typescript
// ✅ Step 2: The concrete implementations depend on the abstraction
@Injectable()
export class EmailNotificationSender extends NotificationSender {
  sendConfirmation(order: Order): void {
    console.log(`Sending email for order #${order.id}`);
    // Real email logic here
  }
}

@Injectable()
export class SmsNotificationSender extends NotificationSender {
  sendConfirmation(order: Order): void {
    console.log(`Sending SMS for order #${order.id}`);
  }
}
typescript
// ✅ Step 3: High-level module only talks to the abstraction
@Injectable({ providedIn: 'root' })
export class OrderService {
  private sender = inject(NotificationSender); // ← abstraction, not concrete

  placeOrder(order: Order): void {
    this.processOrder(order);
    this.sender.sendConfirmation(order);
  }

  private processOrder(order: Order): void { /* ... */ }
}
typescript
// ✅ Step 4: Wire the concrete class at the composition root (app.config.ts)
export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: NotificationSender,
      useClass: environment.production ? EmailNotificationSender : SmsNotificationSender
    }
  ]
};
typescript
// ✅ Testing becomes trivial — swap in a mock without touching OrderService
const mockSender: NotificationSender = {
  sendConfirmation: jasmine.createSpy('sendConfirmation')
};

TestBed.configureTestingModule({
  providers: [
    OrderService,
    { provide: NotificationSender, useValue: mockSender }
  ]
});

DIP with InjectionToken (for config values)

DIP isn't just for services — it also applies to configuration. Reaching for environment.apiUrl directly inside a service is a dependency on a concrete detail. The fix is an InjectionToken that acts as the abstraction:

typescript
// ✅ Don't reach for environment.* inside services — invert it!
export const API_BASE_URL = new InjectionToken<string>('api.base.url', {
  providedIn: 'root',
  factory: () => environment.apiUrl  // wired once, at the edge
});

@Injectable({ providedIn: 'root' })
export class ProductService {
  private baseUrl = inject(API_BASE_URL); // ← depends on token, not env directly
  private http = inject(HttpClient);

  getProducts(): Observable<Product[]> {
    return this.http.get<Product[]>(`${this.baseUrl}/products`);
  }
}

// In tests, simply override the token — no need to mock the environment file:
TestBed.configureTestingModule({
  providers: [{ provide: API_BASE_URL, useValue: 'http://localhost:3000' }]
});

DIP Summary Diagram


Putting It All Together

Let's build a ProductCatalog feature that applies all five principles at once. Notice how each principle strengthens the others:

typescript
// --- Interfaces (ISP: small, focused) ---
export interface ProductReader {
  getAll(): Observable<Product[]>;
  getById(id: number): Observable<Product>;
}

export interface ProductWriter {
  save(product: Product): Observable<Product>;
  delete(id: number): Observable<void>;
}

// --- Abstract (DIP: abstraction at the centre) ---
export abstract class ProductRepository implements ProductReader, ProductWriter {
  abstract getAll(): Observable<Product[]>;
  abstract getById(id: number): Observable<Product>;
  abstract save(product: Product): Observable<Product>;
  abstract delete(id: number): Observable<void>;
}
typescript
// --- Concrete (LSP: fully honours ProductRepository contract) ---
@Injectable()
export class HttpProductRepository extends ProductRepository {
  private http = inject(HttpClient);
  private baseUrl = inject(API_BASE_URL); // DIP: config via token

  getAll(): Observable<Product[]> {
    return this.http.get<Product[]>(`${this.baseUrl}/products`);
  }
  getById(id: number): Observable<Product> {
    return this.http.get<Product>(`${this.baseUrl}/products/${id}`);
  }
  save(product: Product): Observable<Product> {
    return product.id
      ? this.http.put<Product>(`${this.baseUrl}/products/${product.id}`, product)
      : this.http.post<Product>(`${this.baseUrl}/products`, product);
  }
  delete(id: number): Observable<void> {
    return this.http.delete<void>(`${this.baseUrl}/products/${id}`);
  }
}
typescript
// --- Business Service (SRP: orchestrates, does not fetch/format) ---
// --- (OCP: open to new product rules via extension) ---
@Injectable({ providedIn: 'root' })
export class ProductCatalogService {
  private repo = inject(ProductRepository); // DIP: depends on abstraction

  getFeaturedProducts(): Observable<Product[]> {
    return this.repo.getAll().pipe(
      map(products => products.filter(p => p.featured))
    );
  }

  discontinue(id: number): Observable<void> {
    return this.repo.getById(id).pipe(
      switchMap(product => this.repo.save({ ...product, discontinued: true })),
      map(() => void 0)
    );
  }
}
typescript
// --- Component (SRP: only manages the view) ---
@Component({
  selector: 'app-product-catalog',
  templateUrl: './product-catalog.component.html'
})
export class ProductCatalogComponent implements OnInit {
  private catalogService = inject(ProductCatalogService);
  products = signal<Product[]>([]);
  loading = signal(true);

  ngOnInit() {
    this.catalogService.getFeaturedProducts().subscribe({
      next: (p) => {
        this.products.set(p);
        this.loading.set(false);
      }
    });
  }
}
typescript
// --- Wiring (composition root — the ONLY place that knows concretions) ---
export const productProviders: Provider[] = [
  { provide: ProductRepository, useClass: HttpProductRepository },
  { provide: API_BASE_URL, useValue: environment.apiUrl }
];

Angular-Specific SOLID Guidance

Where Each Principle Lives in an Angular App

PrincipleAngular Mechanism
SRPComponents → view only; services → business logic; pipes → pure transforms
OCPmulti: true providers, InjectionToken, Angular interceptors & guards
LSPAbstract classes as DI tokens; mocks in tests that fully honour contracts
ISPTyped as slim interfaces at injection site; Angular's own lifecycle hooks
DIPabstract class / InjectionToken as DI tokens, not concrete class references

Standalone Components and SOLID

Angular's standalone APIs naturally push you toward SOLID. A standalone component explicitly declares only its own dependencies — nothing more. That's ISP in action:

typescript
// Standalone component = ISP in action
// It declares exactly its OWN dependencies — nothing more
@Component({
  selector: 'app-avatar',
  standalone: true,
  imports: [CommonModule], // ← only what it uses
  template: `<img [src]="src" [alt]="alt" />`
})
export class AvatarComponent {
  @Input({ required: true }) src!: string;
  @Input() alt = 'Avatar';
}

Signal-Based Architecture

Angular Signals make SRP even cleaner. The store service owns all state and mutations. The component just reads from signals and renders:

typescript
// SRP: the service owns state; the component owns the view
@Injectable({ providedIn: 'root' })
export class CartStore {
  // State
  private _items = signal<CartItem[]>([]);

  // Derived state (OCP: adding a computed is extension, not modification)
  readonly items = this._items.asReadonly();
  readonly totalCount = computed(() => this._items().reduce((sum, i) => sum + i.quantity, 0));
  readonly totalPrice = computed(() => this._items().reduce((sum, i) => sum + i.price * i.quantity, 0));

  // Commands (SRP: each mutation has one job)
  addItem(item: CartItem): void {
    this._items.update(current => [...current, item]);
  }

  removeItem(id: string): void {
    this._items.update(current => current.filter(i => i.id !== id));
  }
}

Common SOLID Violations in Angular

Violation 1: The "God Component"

A component that injects five services and has 300+ lines of ngOnInit is doing too many jobs. Every new requirement changes this one file — a classic SRP failure.

typescript
// ❌ SRP violation: component does everything
@Component({ selector: 'app-dashboard', template: '...' })
export class DashboardComponent implements OnInit {
  // Data fetching
  private http = inject(HttpClient);
  // Auth
  private auth = inject(AuthService);
  // Routing
  private router = inject(Router);
  // Local storage
  private storage = inject(LocalStorageService);
  // Analytics
  private analytics = inject(AnalyticsService);

  ngOnInit() { /* 300+ lines of mixed concerns */ }
}

Fix: Extract each concern into dedicated services and sub-components.

Violation 2: Environment Inside Services

Directly reading environment.apiUrl inside a service couples your business logic to the build configuration. You can't override it in tests without a workaround.

typescript
// ❌ DIP violation: service depends on concrete environment object
@Injectable({ providedIn: 'root' })
export class ApiService {
  private baseUrl = environment.apiUrl; // ← tight coupling to env file
}

Fix: Use InjectionToken (see DIP section above).

Violation 3: Fat Interfaces from Shared Services

When a read-only component injects the full ProductService (with 15 methods), it knows about operations it will never use. This creates unnecessary coupling and makes mocking painful.

typescript
// ❌ ISP violation: component injects full service when it only reads
@Component({ selector: 'app-read-only-list' })
export class ReadOnlyListComponent {
  private service = inject(ProductService); // ProductService has 15 methods
  // This component only calls service.getAll() — it knows too much
}

Fix: Type the injection as the slim interface — inject<ReadableDataService>(ProductService).

Violation 4: instanceof Betrays LSP

If your code checks the concrete type of something that was typed as an abstraction, it means the abstraction isn't capturing the full behaviour — and subtypes aren't truly substitutable.

typescript
// ❌ LSP violation: caller inspects the concrete type
function processNotification(sender: NotificationSender): void {
  if (sender instanceof EmailNotificationSender) {
    // special email-only logic
  }
}

Fix: Move the special behaviour into the abstraction (e.g., add a method to NotificationSender) so every subtype handles it in its own way.

Violation 5: Rigid Switch-Cases (OCP)

Every time a new role or type is added, this function must be edited and re-tested. That's a closed-for-extension, open-for-modification design — the exact opposite of OCP.

typescript
// ❌ OCP violation: every new user-role requires editing this function
function getPermissions(role: string): string[] {
  switch (role) {
    case 'admin': return ['read', 'write', 'delete'];
    case 'editor': return ['read', 'write'];
    case 'viewer': return ['read'];
    // Adding 'auditor' means editing here ❌
  }
}

Fix: Use a permissions map or strategy pattern — adding a new role means adding an entry, not editing the function.


Conclusion

SOLID isn't a rulebook you follow to the letter — it's a set of instincts that help you notice when code is getting fragile. In Angular terms:

  • SRP — if your component is doing HTTP calls, it's doing too much.
  • OCP — if adding Slack notifications requires editing NotificationService, your design is closed for extension.
  • LSP — if you're writing instanceof checks, your abstraction isn't rich enough.
  • ISP — if your test mock needs 10 empty stub methods, your interface is too fat.
  • DIP — if you can't test a service without the real HTTP client, you haven't inverted the dependency.

The real payoff comes over time: code that's easy to refactor, fast to test, and safe to extend — which is exactly what a growing Angular codebase needs.

🧠 Test Your Knowledge

Now that you've learned the concepts, let's see if you can apply them! Take this quick quiz to test your understanding.

PreviousCSS Pseudo-Elements Mastery: ::before, ::after, Counters & Decorative Techniques

Written by

Shaik Munsif

Read more articles

Found this helpful? Share it with your network!

On this page

0/41
Question 1 of 10Easy
Score: 0/0

Which SOLID principle states that a class should have only one reason to change?