3d cartoon hands holding a phone

Unlock full course by purchasing a membership

Lesson 2

Applying SOLID Principles in Angular

Five key programming principles

STANDARD

Applying SOLID Principles in Angular

We are going to start with the most vague concept that is the least obvious in terms of how we should actually apply it to our Angular development: SOLID principles. The reason it is vague and not immediately obvious is because these principles have been established generically for Object Oriented Programming, not specifically for Angular applications. These principles pre-date Angular by ten years or so.

The fact that these SOLID principles are still touted and advocated for approximately 20 years after their inception should speak to their importance and long term staying power. The SOLID acronym stands for:

  • Single-responsibility principle
  • Open-closed principle
  • Liskov substitution principle
  • Interface segregation principle
  • Dependency inversion principle

Don’t worry if this all sounds like a bit much, it isn’t really important to understand all of these right away. We will use some of these concepts quite heavily, and others we may barely touch. Regardless, it is a good idea to just be aware of the existence of these concepts, study them over time, and consider where you might be able to improve your code by using these principles.

We will dedicate the rest of this lesson to covering the basic idea behind each of these principles, and providing examples in an Angular context where useful.

Single-responsibility principle

We will use this concept quite heavily, and we will save most of the discussion of this principle for our next lesson on smart and dumb components which embodies this concept well.

The general idea with this principle is that each class should have one responsibility. In an Angular context, whether that class is a component, directive, pipe, service, or anything else - it should have one responsibility.

Determining what a “responsibility” is, is part of the fun vagueness of these principles that can make them hard to apply. What is a “responsibility”? For example, imagine a service that manages the data/state for todos in an application. That service might:

  • Load todos
  • Create todos
  • Delete todos
  • Edit todos

Is this four different responsibilities? Should we really break this up into four different services each with just one responsibility? No, we absolutely should not do that. We are probably looking at how to define the “responsibility” here in too strict of a sense. Instead, we could say the responsibility of this service is to: manage the data related to todos. This would then fit the definition of having a single responsibility.

What if that service also managed settings related to how to display those todos? Maybe we have options like the ability to show/hide completed todos. We could probably re-work our definition of:

  • Manage the data related to todos

To include this additional responsibility, but really this is probably better defined as two separate responsibilities:

  • Manage the data related to todos
  • Manage preferences supplied by the user

And should be handled with two different services: a TodoService and a SettingsService each with one responsibility.

As I mentioned before, we also might not always want to apply this principle. Maybe one of our components, or a service, technically has two or three responsibilities. This doesn’t necessarily mean we have to break those classes up into multiple classes that each have one responsibility. That might be an unnecessary or premature optimisation that we don’t need. In the example above, maybe there is just one single setting related to displaying todos. We might decide rather than creating a separate service for just that one setting, we will just add it in to the TodoService. Later, if we introduce more settings, maybe we will refactor it out into its own service then.

Whilst we might not always strictly adhere to the principles, it is good to be aware of the fact that a particular class might be doing more than is optimal, and to consider whether a refactor might be useful.

In the next lesson, we will explore this concept more in relation to smart and dumb components.

Open-closed principle

This is another principle that applies quite well to Angular. This principle states that an entity should be open for extension, but closed for modification. In other words, when creating new features we should incorporate/extend/build on top of existing entities, not modify those existing entities.

Again, this is one of those things that needs to be taken in context and applied where appropriate. For example, let’s consider a simple ButtonComponent:

@Component({
    selector: 'app-button',
    template: `
        <button>Hi</button>
    `
})
export class ButtonComponent {}

It’s a button that say’s “Hi”, fantastic. Now let’s say our requirements have changed - not only do we want a button that says “Hi”, we want to be able to configure its colour!

We could modify our component to this by accepting an input:

@Component({
  selector: 'app-button',
  template: `
      <button [style.backgroundColor]="color">Hi</button>
  `,
  standalone: true
})
export class ButtonComponent {
  @Input() color? = '#cecece';
}

Now we can supply it with a colour:

<app-button color="blue"></app-button>

But, technically, this is a violation of the open-closed principle. Alternatively, we could create a directive to apply the button colour instead:

import { Directive, HostBinding, Input } from "@angular/core";

@Directive({
  selector: 'app-button[color]',
  standalone: true
})
export class ButtonColorDirective {
  @HostBinding('style.backgroundColor') @Input() color? = '#cecece';
}

NOTE: By using the selector app-button[color] this directive will only apply to app-button components that have the color attribute. If we had have just done color as the selector then it would apply to any component with the color attribute which we might not want. On top of that, we are also using the selector name as an input - this allows us to both attach the directive to a component and receive input on it with just one attribute.

We would use this directive in the exact same way:

<app-button color="blue"></app-button>

This would adhere to the open-closed principle as we have extended the buttons functionality by applying a directive to it, we have not modified the underlying entity.

But! That doesn’t mean this is always a good idea. In this case, it probably makes sense to have a colour configuration be a core part of the base components API, not using a directive to create the functionality. But perhaps more specialised configurations might be better suited to being an extension rather than part of the base functionality.

Primarily what we want to avoid is just continuing to extend and build onto one entity to incorporate every feature we want. Over time you can create these monster classes that handle all sorts of different use cases and make your application a mess and difficult to maintain.

Liskov Substitution Principle

We will incorporate this concept somewhat when we look at the dependency inversion principle in a moment, but this is not a principle that is generally particularly prevalent in Angular development.

The basic idea is that a sub-class should be able to replace a base class and the program should still function. With OOP, we can extend classes, e.g:

export class ButtonComponent {}
export class SpecialButtonComponent extends ButtonComponent {}

The idea with the Liskov Substitution principle is that we should be able to use our SpecialButtonComponent in place of the ButtonComponent and everything will still function. Our SpecialButtonComponent does everything the ButtonComponent does because it extends it, so it should be able to be used in place of the ButtonComponent.

You can make use of inheritance in Angular, even for components as I have given in the example above, but it is not all that common and can be awkward. An important thing to keep in mind with Angular is that if we have a component:

@Component({
    selector: 'app-button',
    template: `
        <button>Hi</button>
    `
})
export class ButtonComponent {

    someClassMember: boolean;

    someMethod(){

    }
}

and we extend it:

@Component({
    selector: 'app-special-button',
    template: ``
})
export class SpecialButtonComponent extends ButtonComponent {

    anotherMethod(){

    }
}

Our extended component only extends the class. With Angular, we use a @Component decorator to supply the template. Our extended class will not have the template of our base class, it will need to define its own template. What will be available to the extended class is the stuff in the class, e.g:

  • someClassMember (from base class)
  • someMethod (from base class)
  • anotherMethod (from sub-class)

Another awkward thing about extending classes in Angular is dealing with the dependency injection system. For example, let’s say our base button component injects the ChangeDetectorRef:

@Component({
    selector: 'app-button',
    template: `
        <button>Hi</button>
    `
})
export class ButtonComponent {

    someClassMember: boolean;

    constructor(private cdr: ChangeDetectorRef){}

    someMethod(){

    }
}

This will mean our sub-class will need to pass the ChangeDetectorRef up to the super class, even if it doesn’t need to use it itself:

@Component({
    selector: 'app-special-button',
    template: ``
})
export class SpecialButtonComponent extends ButtonComponent {

    constructor(cdr: ChangeDetectorRef){
        super(cdr)
    }

    anotherMethod(){

    }
}

NOTE: The super method is used to call the constructor of the parent class (or “super class”)

Now imagine that we change our base ButtonComponent class to inject something else using dependency injection. That will mean we need to go and modify all of our sub classes and supply that additional dependency through super.

This is why using inheritance can be quite awkward in Angular, and we often prefer to use composition (e.g. as we did in the open-closed example with adding a directive to a component).

That isn’t to say inheritance is never a good idea in Angular, sometimes it is, but in general it is going to be easier to use composition than inheritance.

Interface Segregation principle

The interface segregation principle states that: Clients should not be forced to depend upon interfaces that they do not use.

This is something that is relevant to Angular, let’s consider an example. We might have an application that handles displaying Articles. The specific entity here doesn’t really matter, the main point is that we are displaying something using the master/detail pattern. We did this with the todo application, but I think articles work better for this example since they will generally have more properties.

We might have an interface representing articles that looks like this:

interface Article {
    id: number;
    title: string;
    summary: string;
    author: string;
    datePublished: string;
    dateUpdated: string;
    content: string;    
}

We might also have two different components that deal with displaying articles. We might have one component that handles displaying a summary of the article in a list, and we might have one component that handles displaying the full details and content of an article.

Now, let’s say our ArticleSummary component displays the following details:

  • title
  • summary
  • author
  • dateUpdated

If we have our ArticleSummary component use the Article interface we are violating the interface segregation principle. It is depending on an interface that contains properties it does not need. Our component that displays the full details of an article would use all these fields.

To fix this, and adhere to the interface segregation principle, we might do something like this instead:

interface Article {
    id: number;
    title: string;
    summary: string;
    author: string;
    dateUpdated: string;
}

interface ArticleDetails extends Article {
    datePublished: string;
    content: string;   
}

Now our summary component can use the Article interface, and the full details component can use the ArticleDetails interface, and neither depend on an interface with properties they don’t use. Exactly how we break these interfaces up might depend on how we intend to structure our application.

Once again, this is something to always be taken in context. This is a good ideal, but not always completely necessary. Given the example above, I would probably just shamefully violate the principle and have the one interface. Generally, the bigger and more complex an application becomes, the more important it is to follow rules like these.

Dependency Inversion

The last principle for us to discuss is dependency inversion and it ties in to our discussion on inheritance and the Liskov substitution principle. This principle states: depend upon abstractions, not concretions.

It is easier to understand this if you know what abstract classes and methods are. I’ll let you know right away that the use cases for this in Angular are reasonably advanced, and not something most people will need to use (we do use these concepts quite a lot second hand though, because Angular exposes some of its functionality this way - we will talk about that soon).

An abstract class or method defines the idea or “shape” of a class or method. For example, I might have an abstract math class that looks like this:

abstract class Math {
    abstract add(x: number, y: number): number;
    abstract multiply(x: number, y: number): number;
}

This defines the “shape” of a class called Math. It should define a function called add that takes two numbers as parameters, and returns a number. The same goes for multiply.

However, it doesn’t specify how these functions work. This is an abstraction. We could then provide a concrete implementation for this (a concretion) by defining those methods:

class MyMath implements Math {
    add(x: number, y: number) {
        return x - y;
    }

    multiply(x: number, y: number) {
        return x / y;
    }
}

We are forced to adhere to the “contract” specified by the Math abstract class by using the implements keyword… but we are being a little silly. I am doing what is required, but I am providing nonsensical methods in this case. I could also create another concretion based on the same abstract class:

class SensibleMath implements Math {
    add(x: number, y: number) {
        return x + y;
    }

    multiply(x: number, y: number) {
        return x * y;
    }
}

This one is more sensible. The idea with this principle is that our application would depend on Math which is just an abstraction. We could then provide whatever implementation of that we want - whether that is SensibleMath or MyMath. As long as we adhere to the structure set out by Math our application will not care either way.

This is used quite a lot by Angular itself. For example, when we create a pipe we do this:

@Pipe({
    name: 'myPipe'
})
export class MyPipe implements PipeTransform {
    transform(value){
        return value;
    }
}

Angular defines an abstract class called PipeTransform that we implement. By implementing this class, we are forced to supply the transform method which Angular is expecting so that it knows how it should transform the values in the template. Another place we see this in Angular is when we use lifecycle hooks like OnInit and OnDestroy.

But what might this look like in our own code? Again, this is more advanced and not something you will typically run into. However, we can make quite effective use of this principle by using providers.

We have seen this already:

@Component({
    selector: 'app-list',
    template: `template goes here`,
    providers: [ListService]
})
export class ListComponent {
    constructor(private listService: ListService)
}

We have the ability to specify providers (we are doing it directly on a component here, but you can also specify providers at a module or root level). What we haven’t seen is that we can provide the ListService “token”, but actually use a different implementation for it. For example:

@Component({
    selector: 'app-list',
    template: `template goes here`,
    providers: [
        {
            provide: ListService,
            useClass: EmployeeListService
        }
    ]
})
export class EmployeeListComponent {
    constructor(private listService: ListService)
}

Our component can still depend upon the abstraction of ListService but the actual implementation (concretion) will be provided by EmployeeListService. This can be swapped out at will as long as we are adhering to the “contract” set out by the abstract ListService. For example, we could do this:

@Component({
    selector: 'app-list',
    template: `template goes here`,
    providers: [
        {
            provide: ListService,
            useClass: ProductsListService
        }
    ]
})
export class ProductsListComponent {
    constructor(private listService: ListService)
}

Now we are using Products instead. To give further context, maybe our abstract ListService specifies that each service that implements it requires a getListItems property:

export abstract class ListService {
    abstract getListItems$: Observable<string[]>
}

Then our specific implementations/concretions just need to adhere to this:

@Injectable()
export class EmployeeListService implements ListService {
    getListItems$ = of(['Josh', 'Kathy'])
}
@Injectable()
export class ProductsListService implements ListService {
    getListItems$ = of(['Josh', 'Kathy'])
}

We can create as many different implementations of this as we like, and our application only ever depends specifically on the API defined by the abstract ListService.

Recap

If the SOLID principles are new to you, you probably aren’t going to remember what each one is right away. It’s the kind of thing you should just try to keep in mind as you program, and from time to time look it up and study examples (especially in Angular). In the beginning, it isn’t hugely important so don’t worry too much. The only thing I think is particularly important to get right is the single responsibility principle and we are going to look at that more in the next lesson.

The single-responsibility principle suggests that...

The Liskov substitution principle suggests that...

The open-closed principle suggests that...

The dependency inversion principle suggests that...

The interface segregation principle suggests that...