Requirements

Imagine we are building a fresh feature for a blog platform. The goal is to introduce a page that displays:

  • A directory of authors.
  • A click on an author triggers an API request to retrieve that author’s articles and present them to the user.
  • Filtering of the author list based on topic and article count. For instance, a visitor might want to see authors who have written at least 4 pieces, with a minimum of 1 article focused on Angular.
  • The author dataset could be extensive, possibly reaching thousands of entries. As seasoned developers, we aim to avoid overwhelming the DOM, so we implement pagination.

The relevant API endpoints for the author list:

  • GET /api/authors
  • GET /api/authors/:id/articles
  • GET /api/authors?first=1&last=10&topic=angular&nbArticles=4

Straightforward, correct? Let’s ramp up the difficulty.

On that same page, we also need the ability to toggle the displayed list between authors and articles. Pagination remains, but the topic filter is the only one applicable to the article view.

The relevant API endpoints for the article list:

  • GET /api/articles
  • GET /api/articles?first=1&last=10&topic=angular

Application Mockup

Straightforward Implementation

enum ListLevel {
    AUTHORS = 'AUTHORS',
    ARTICLES = 'ARTICLES'
}

interface Pagination {
    first: number;
    last: number;
}

@Component({
    selector: 'list',
    templateUrl: 'list.component.html',
    providers: [ApiService, StoreService]
})
export class ListComponent implements OnInit {
    public showNbOfArticlesDropdown$: Observable<boolean>;
    public mainList$: Observable<Authors[] | Articles[]>;
    public listLevel$: Observable<ListLevel>;
    public pagination$: Observable<Pagination>;
    public topic$: Observable<string>;
    public nbArticles$: Observable<number>;

    constructor(
        private apiService: ApiService,
        private storeService: StoreService
    ) {
        this.listLevel$ = this.storeService.select('listLevel');
        this.pagination$ = this.storeService.select('pagination');
        this.topic$ = this.storeService.select('topic');
        this.nbArticles$ = this.storeService.select('nbOfArticles');
    }

    ngOnInit(): void {
        this.mainList$ = this.getMainList();
        this.showNbOfArticlesDropdown$ = this.showNbOfArticles();
    }

    private getMainList(): Observable<Authors[] | Articles[]> {
        return combineLatest(
            this.listLevel$,
            this.pagination$,
            this.topic$,
        ).pipe(
            switchMap(([listLevel, pagination, topic]) => {
                const { first, last } = pagination;
                switch (listLevel) {
                    case ListLevel.AUTHORS:
                        return this.nbArticles$.pipe(
                            switchMap(nbArticles => {
                                return this.apiService.fecthAuthors({first, last, topic, nbArticles})
                            })
                        );
                    case ListLevel.ARTICLES:
                        return this.apiService.fecthArticles({ first, last, topic });
                    default:
                        break;
                }
            })
        )
    }

    private getArticlesByAuthor(authorId: number): Observable<Articles[]> {
        return this.apiService.fetchArticlesByAuthor(authorId);
    }

    private showNbOfArticles(): Observable<boolean> {
        return this.listLevel$.pipe(
            map(listLevel => {
                switch (listLevel) {
                    case ListLevel.AUTHORS:
                        return true;
                    case ListLevel.ARTICLES:
                        return false;
                    default:
                        break;
                }
            })
        )
    }
}

Here’s a breakdown of the code above:

  1. We introduce two distinct services:
    • apiService handles all HTTP requests to the backend.
    • storeService manages the application’s state, functioning as a simple RxJS BehaviorSubject store.
  2. getMainList returns an Observable stream: whenever listLevel$, pagination$, or topic$ push a new value, it evaluates the current list level and executes the relevant API call. Note that for the Authors level, we must subscribe to nbArticles$ before issuing the request.
  3. getArticlesByAuthor fetches all articles linked to a given author.
  4. showNbOfArticles evaluates the current list level to determine whether the article count button should appear.
  5. We can safely assume that the template handles all subscriptions via the async pipe.

The code is fully reactive and functional, yet it warrants some reflection:

  1. The current list level is evaluated in two places. If we need to add more logic tied to list level, the number of evaluations will grow. Consider adding a title to the list—we’d be forced to include a third check.
this.listTitle$ = this.listLevel$.pipe(
    map(listLevel => {
        switch (listLevel) {
            case ListLevel.AUTHORS:
                return 'Trending authors that published few minutes ago!';
            case ListLevel.ARTICLES:
                return 'Best articles for you!';
            default:
                break;
        }
    })
);
  1. Suppose we want to expand the *“show by”* dropdown to include grouping authors by country or language. We would have to modify every existing check for the current list level to accommodate the new value.
  2. ListComponent would accumulate a significant amount of business logic. Consequently, the code becomes more bug-prone, harder to comprehend, and tougher to maintain.

In short, ListComponent breaches SOLID principles:

  1. Open-Closed principle – introducing new functionality necessitates modifying existing code.
  2. Dependency Inversion principle – the code is coupled to concrete implementations rather than abstractions.

We can improve this. Let’s refactor.

Leveraging Abstractions and Dependency Injection

Abstract classes serve as blueprints for inheritance, enabling other classes to extend them. Instantiating an abstract class directly is not possible. Typically, an abstract class declares one or more abstract methods or properties, which the extending class is obligated to implement – TypeScript

The core concept behind Abstraction is separating policy from implementation details, facilitating loose coupling.

In our scenario, we define a ListService abstract class that outlines the policy and the requirements of ListComponent. The abstract keyword signals what the derived class must implement.

import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from '../api.service';
import { StoreService } from '../store.service';
import { PaginationModel } from '../../models/pagination.model';
import { ListLevel } from '../../models/list-level.enum';
import { AuthorModel } from '../../models/author.model';
import { ArticleModel } from '../../models/article.model';

export type ListType = AuthorModel[] | ArticleModel[];

@Injectable()
export abstract class ListService {
  public listLevel$: Observable<ListLevel>;
  public nbArticles$: Observable<number>;
  public pagination$: Observable<PaginationModel>;
  public topic$: Observable<string>;

  abstract listTitle: string;
  abstract showNbOfArticlesDropdown: boolean;

  constructor(
    protected apiService: ApiService,
    protected storeService: StoreService
  ) {
    this.listLevel$ = this.storeService.select('listLevel');
    this.nbArticles$ = this.storeService.select('nbOfArticles');
    this.pagination$ = this.storeService.select('pagination');
    this.topic$ = this.storeService.select('topic');
  }

  abstract getList(): Observable<ListType>;
}

Next, we realize the policy through concrete classes. Naturally, the implementation varies between AuthorsList and ArticlesList.

import { Injectable } from '@angular/core';
import { combineLatest, Observable } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { AuthorModel } from '../../models/author.model';
import { ListService } from './list.service';

@Injectable()
export class AuthorsListService extends ListService {
  public listTitle = 'Weekly best authors!';
  public showNbOfArticlesDropdown = true;

  public getList(): Observable<AuthorModel[]> {
    return combineLatest([this.pagination$, this.topic$, this.listLevel$]).pipe(
      switchMap(([{ first, last }, topic]) => {
        return this.nbArticles$.pipe(
          switchMap((nbArticles) => {
            return this.apiService.fetchAuthors({
              first,
              last,
              topic,
              nbArticles,
            });
          })
        );
      })
    );
  }
}
import { Injectable } from '@angular/core';
import { combineLatest, Observable } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { ArticleModel } from '../../models/article.model';
import { ListService } from './list.service';

@Injectable()
export class ArticlesListService extends ListService {
  public listTitle = 'Interesting articles!';
  public showNbOfArticlesDropdown = false;

  public getList(): Observable<ArticleModel[]> {
    return combineLatest([this.pagination$, this.topic$, this.listLevel$]).pipe(
      switchMap(([{ first, last }, topic]) => {
        return this.apiService.fetchArticles({ first, last, topic });
      })
    );
  }
}

Observe that we extend ListService rather than implement it. This strategy lets us reuse common functionality from StoreService.

Moving forward, if a new feature demands another list type, we simply introduce a new service extending the abstract ListService. This means extension only, never modification.

Extend Abstract Service

import { Component, Injector, OnInit } from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import { tap } from 'rxjs/operators';
import { ListLevel } from './models/list-level.enum';
import { ArticleModel } from './models/article.model';
import { ApiService } from './services/api.service';
import { ListService, ListType } from './services/list/list.service';
import { AuthorsListService } from './services/list/authors-list.service';
import { ArticlesListService } from './services/list/articles-list.service';

@Component({
  selector: 'list',
  templateUrl: './list.component.html',
  styleUrls: ['./list.component.less'],
  providers: [AuthorsListService, ArticlesListService],
})
export class ListComponent implements OnInit {
  public listTitle: string;
  public showNbOfArticlesDropdown: boolean;
  public list$: Observable<ListType>;
  private listService: ListService;
  private subscriptions = new Subscription();

  constructor(private injector: Injector, private apiService: ApiService) {
    this.listService = this.listServiceFactory(ListLevel.AUTHORS);
  }

  ngOnInit(): void {
    this.subscriptions.add(
      this.listService.listLevel$
        .pipe(
          tap((listLevel: ListLevel) => {
            this.listService = this.listServiceFactory(listLevel);
            this.initListData();
          })
        )
        .subscribe()
    );
  }

  public getArticlesByAuthor(authorId: number): Observable<ArticleModel[]> {
    return this.apiService.fetchArticlesByAuthor(authorId);
  }

  private initListData(): void {
    this.list$ = this.listService.getList();
    this.listTitle = this.listService.listTitle;
    this.showNbOfArticlesDropdown = this.listService.showNbOfArticlesDropdown;
  }

  private listServiceFactory(listLevel: ListLevel): ListService {
    switch (listLevel) {
      case ListLevel.AUTHORS:
        return this.injector.get(AuthorsListService);
      case ListLevel.ARTICLES:
        return this.injector.get(ArticlesListService);
    }
  }
}

Doesn’t the revamped ListComponent look tidier?

Several important observations:

  • We utilize Dependency Injection to supply ListComponent with both AuthorsListService and ArticlesListService.
  • A private property listService of type ListService is added, referencing the abstract class we designed.
  • We assign it via the listServiceFactory function. The injector.get() method fetches and returns an injector instance based on the token provided.
  • Now, we evaluate the current list level just once and obtain the correct ListService instance. In plain language, we’re telling our component: “I want you to adapt and configure yourself as a list of type A. Retrieve the list configuration of type A from your node injector.”
  • To accomplish this, we subscribe to listLevel$ and exchange ListService instances whenever the listLevel shifts.

With this refactoring, ListComponent now relies on the ListService abstraction, and the concrete implementation can be swapped according to the current list level. This embodies both Dependency Inversion and Liskov Substitution Principles.

Final Thoughts

Every developer should grasp and apply SOLID principles. These concepts lay the groundwork for a robust architectural foundation in any project.

Adopting clean, SOLID code does introduce extra files into your codebase due to abstractions. However, it empowers large development teams to scale efficiently while minimizing bugs. This is especially critical in large enterprise environments where product demands evolve constantly.