You’re Already Using SOLID Principles in Angular, Here’s Your Guide to Proving It

VAIBHAV SHARMA
9 min readSep 14, 2023

Hey, Angular developers! 🌟

Ever found yourselves in a team meeting where your manager or architect throws out the question -

“Can anyone here explain how you’re implementing SOLID principles in your project?” and the room goes silent ….

Created by DALL-E
Created by DALL-E

If you’ve felt that lump in your throat even though you’ve been coding Angular applications for a while now, this blog post is your arsenal for the next meeting. You’re probably implementing SOLID principles without even labeling them. We’re going to help you identify these hidden gems in your codebase so you can confidently say, “Yes, I am using SOLID principles, and here’s how.”

Get ready to speak with confidence in your next technical discussion.

What Are SOLID Principles?

Before we start spotting SOLID in the wild, let’s quickly understand what these principles are:

  1. Single Responsibility Principle (SRP): A component, class, or function should do one thing and do it well.
  2. Open/Closed Principle (OCP): Your code should be open for extension but closed for modification.
  3. Liskov Substitution Principle (LSP): Child classes should be substitutable for their parent classes.
  4. Interface Segregation Principle (ISP): Don’t add unnecessary methods to an interface.
  5. Dependency Inversion Principle (DIP): Depend on abstractions, not on concrete implementations.

Got it? Cool, let’s see where you’ve been using these principles in Angular, often without realizing it!

1. Single Responsibility Principle (SRP)

What Is It?

In Angular, this principle mostly applies to components and services. A component should be in charge of a specific view and its logic, whereas a service should handle a specific functionality.

How Are You Already Using It?

You are probably using Angular CLI to generate components and services, right? By default, Angular sets you up with a pretty clean structure that promotes single responsibility. When you run ng generate component, you get a component focused on a specific view and logic.

Example: Let’s look at a common TodoComponent.

// todo.component.ts
import { Component, Input } from '@angular/core';

@Component({
selector: 'app-todo',
template: `
<div>
<input type="checkbox" [checked]="todo.completed" />
{{ todo.title }}
</div>
`,
})
export class TodoComponent {
@Input() todo;
}

In this example, TodoComponent has one responsibility: displaying a todo item. Simple, right? 🎉

So, for example in my project, there are many components like :

login component : Only responsible for only login functionality

reset-password component : Only responsible for reset-password functionality

fill-survey component : Only responsible for filling survey

add-offer-price component : Only responsible for adding offer-price only

2. Open/Closed Principle (OCP)

What Is It?

Code should be written in such a way that it can be extended without needing modification.

How Are You Already Using It?

Have you ever used Angular Directives? If yes, congratulations, you’ve been applying the Open/Closed Principle! Directives allow you to add behavior to elements in the DOM. You are extending the behavior without modifying the actual component.

Example: Let’s assume you often use the ngFor directive to loop through an array of items.

<!-- In some component template -->
<ul>
<li *ngFor="let item of items">{{ item.name }}</li>
</ul>

I’ll provide multiple examples with explanations so you can fully grasp the Open/Closed Principle (OCP) and utilize it effectively.

Case : Dynamic Validation

Scenario

You have an Angular form that requires different sets of validations based on the user type.

interface Validator {
validate(value: any): boolean;
}

class EmailValidator implements Validator {
validate(value: any): boolean {
// Email validation logic
}
}

class AgeValidator implements Validator {
validate(value: any): boolean {
// Age validation logic
}
}

class ValidationService {
validate(validator: Validator, value: any) {
return validator.validate(value);
}
}

By implementing a Validator interface, you can easily extend the validation rules without modifying existing validation code.

Case : Payment Strategies

Scenario

Your Angular application needs to support multiple payment strategies.

interface PaymentStrategy {
pay(amount: number): void;
}

class PaypalPayment implements PaymentStrategy {
pay(amount: number) {
// Paypal payment logic
}
}

class CreditCardPayment implements PaymentStrategy {
pay(amount: number) {
// Credit card payment logic
}
}

@Injectable()
export class PaymentService {
pay(strategy: PaymentStrategy, amount: number) {
strategy.pay(amount);
}
}

Using a PaymentStrategy interface makes it easy to add new payment methods without altering existing code.

Let’s see how we implemented this rule in our project :

  1. We created this strategy :

// Created Dropdown Options Strategy
// (To render different options in various cases)
export class DropdownOptionStrategy {
private strategy: DropdownStrategyInterface;
// add constructor if yu want a default strategy

setStrategy(strategy: DropdownStrategyInterface) {
this.strategy = strategy;
}

executeWebhook(): object[] {
return this.strategy.process();
}
}
export interface DropdownStrategyInterface {
process(): object[];
}

// A factory class created, to choose relevent stratefy based on action
export class DropDownOptionsFactory {
strategyDescriptions: {
action: DropdownOptionAction;
strategy: DropdownStrategyInterface;
}[] = [
{
action: DropdownOptionAction.Country,
strategy: new Country(),
},
{
action: DropdownOptionAction.Gender,
strategy: new Gender(),
},
{
action: DropdownOptionAction.State,
strategy: new State(),
},

];


// This is the main function we'll be calling to get the strategy
getStrategy(action: DropdownOptionAction): DropdownStrategyInterface {
const strategy = this.strategyDescriptions.find(
value => value.action === action,
)?.strategy;
return strategy;
}

2. And implementation of these classes are here :

// For getting country
export class Country implements DropdownStrategyInterface {
process(): object[] {
return countryData.countries;
}
}

// For Getting Gender
export class Gender implements DropdownStrategyInterface {
process(): object[] {
const gender = [
{name: 'Male', value: 'M'},
{name: 'Female', value: 'F'},
{name: 'Other', value: 'O'},
{name: 'Unknown', value: 'U'},
];
return gender;
}
}

// For Getting States
export class State implements DropdownStrategyInterface {
process(): object[] {
const state = countryData.countries[0]['states'].map(state => {
return {
state: state,
};
});
return state;
}
}

3. Now all set.. so time to get correct dropdown options based on action-key, let’s check step-by-step

// 1. We want open a form, so sending formName & the data of form like:
this.openForm(integration.formConfig.formName, integration);

// 2. In form we need to create input controls, so ..
openForm(formName: string, integration: Integration) {
this.createReactiveControls(integration, formName);
}

// 3. this is it's implementation:
createReactiveControls(config, formName) {
const controlFactory = new ControlTypeFactory();
const questions: QuestionBase<string>[] = [];
config.forms[formName].groups.forEach(group => {
group.fields.forEach(field => {
questions.push(controlFactory.getControl(field, group?.groupDivName));
});
});
return questions;
}

// 4. Main line is "controlFactory.getControl(field, group?.groupDivName)"
// i.e. We need to get input control based on field name/type
// So, this is the controlFactory :
export class ControlTypeFactory {
getControl(fieldData: Field, groupName?: string) {
switch (fieldData.detail.type) {
case InputTypes.TextBox:
return new TextboxQuestion(fieldData, groupName);
case InputTypes.NumericTextBox:
return new NumericTextboxQuestion(fieldData, groupName);
case InputTypes.DropDown:
return new DropdownQuestion(fieldData, groupName);
case InputTypes.Date:
return new DatePickerQuestion(fieldData, groupName);
case InputTypes.UploadFile:
return new ImageUploadQuestion(fieldData, groupName);
case InputTypes.CheckBox:
return new CheckBoxQuestion(fieldData, groupName);
}
}
}

// 5. We want to create DropdownQuestion, here it is :
// Just know that all other functions above are also extending same QuestionBase
export class DropdownQuestion extends QuestionBase<string> {
override type = InputTypes.DropDown;

constructor(fieldData, groupName?: string) {
super();
this.name = fieldData.name;
this.options = this.getOptions(fieldData.dropDownName);
}

getOptions(action): object[] {
const factory = new DropDownOptionsFactory();
const strategy = new DropdownOptionStrategy();

strategy.setStrategy(factory.getStrategy(action as DropdownOptionAction));
return strategy.executeWebhook();
}
}

// 6. So, now above factory.getStrategy with action 'dropdown-name, like: country' will be called
// And the corresponding function will be called to return data based on it

So, as you see, we’re adding functionality of DropdownQuestions withot changing this class..

3. Liskov Substitution Principle (LSP)

What Is It?

Child classes should be able to replace parent classes without affecting the integrity of the application.

i.e. Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.

How Are You Already Using It?

You’ve been doing this when you extend Angular lifecycle hooks or when you create child components that can be substituted for their parent components.

// user.service.ts (Parent Class)
export class UserService {
getUser() {
return 'Real User';
}
}

// mock-user.service.ts (Child Class)
export class MockUserService extends UserService {
getUser() {
return 'Mock User';
}
}

4. Interface Segregation Principle (ISP)

What Is It?

An interface should not be bloated with methods that implementing classes don’t require.

How Are You Already Using It?

Ever created custom Angular interfaces for specific tasks like form validation or HTTP interceptors? Then you’ve already met ISP!

Example: Creating a custom form validator.

// custom-validator.interface.ts
export interface CustomValidator {
validate(): boolean;
}

// my-custom-validator.ts
export class MyCustomValidator implements CustomValidator {
validate() {
return true;
}
}

See, how we are using it in our project :

The example shown in “OCP” case, the interface “DropdownStrategyInterface” has only 1 method which other classes are implementing. No extra overhead of methods are in this interface.. only the required ones..

5. Dependency Inversion Principle (DIP)

What Is It?

High-level modules should not depend on low-level modules. Both should depend on abstractions.

How Are You Already Using It?

If you’re injecting services into your components using Angular’s Dependency Injection, you’re already applying DIP!

Example 1: Injecting a TodoService into a TodoListComponent.

// todo-list.component.ts
import { TodoService } from './todo.service';

export class TodoListComponent {
constructor(private todoService: TodoService) {}
}

By injecting TodoService, you're depending on an abstraction rather than a concrete implementation.

Example 2: Custom Loggers

In the logging example, the UserComponent class is not directly dependent on any specific logging mechanism. It depends on the Logger interface, not on the ConsoleLogger concrete class. This is DIP in action.

interface Logger {
log(message: string): void;
}

@Injectable()
export class ConsoleLogger implements Logger {
log(message: string) {
console.log(message);
}
}

@Injectable()
export class UserComponent {
constructor(private logger: Logger) {}
}

By depending on the abstraction (Logger), the UserComponent class adheres to DIP. If you want to change the logging mechanism later (say, log to a file or send logs to a server), you can do so without modifying the UserComponent. You would create a new class implementing the Logger interface and inject it.

Example 3: User Authentication

The high-level module (OrderService) should not depend on low-level modules . The components or services responsible for user interaction should not directly depend on the specifics of JWT or OAuth but should depend on an abstraction.

// Abstraction
interface AuthService {
authenticate(username: string, password: string): Observable<User>;
}

// Low-Level Module
@Injectable()
export class JwtAuthService implements AuthService {
authenticate(username: string, password: string) {
// JWT based authentication logic
}
}

// High-Level Module
@Injectable()
export class LoginService {
constructor(private authService: AuthService) {
this.authService.authenticate('username', 'password').subscribe();
}
}

See how we’re using it in our project :

import {Injectable} from '@angular/core';
import {IAdapter} from '@telescope/core/api/adapters/i-adapter';
import {Section} from '../models/section.model';

@Injectable()
export class SurveySectionsAdapter implements IAdapter<Section[]> {
adaptToModel(sections): Section[] {
return sections.map(section => new Section(section));
}
adaptFromModel(data) {
return data;
}
}

Here’s how:

Breakdown of the Example:

1. Abstraction (`IAdapter<T>` Interface): The `IAdapter` interface serves as an abstraction for any adapter class that can convert data to and from a specific model.

interface IAdapter<T> {
adaptToModel(data: any): T;
adaptFromModel(data: T): any;
}

2. High-Level Module (`SurveySectionsAdapter`): This class serves a specific use-case of adapting data to and from the `Section` model.

@Injectable()
export class SurveySectionsAdapter implements IAdapter<Section[]> {
adaptToModel(sections): Section[] {
return sections.map(section => new Section(section));
}
adaptFromModel(data) {
return data;
}
}

3. Low-Level Module (`Section` Model): This is the data model, and any details related to it (like its properties or methods) are abstracted away from the high-level module (`SurveySectionsAdapter`).

export class Section {
id: string;
name: string;
displayOrder: number;
tenantId: string;
surveyId: string;
surveyQuestions: SurveyQuestion[];
constructor(data: Partial<Section>) {
this.id = data?.id;
this.name = data?.name;
this.displayOrder = data?.displayOrder;
this.tenantId = data?.tenantId;
this.surveyId = data?.surveyId;
this.surveyQuestions = data?.surveyQuestions || [];
}
}

How it Follows DIP:

1. High-level module depends on abstraction: `SurveySectionsAdapter` depends on the `IAdapter<Section[]>` interface (abstraction), not directly on the `Section` model (concrete implementation).

2. Low-level module depends on abstraction: While the `Section` model is the data being adapted, it’s the interface (`IAdapter<Section[]>`) that dictates how this data should be adapted. Thus, if we ever change the `Section` model, `SurveySectionsAdapter` remains unaffected as long as the interface’s contract is met.

3. Abstraction doesn’t depend on details: The `IAdapter` interface is not aware of the specific details of how data is adapted to or from the `Section` model.

So yes, this is a good example of how Dependency Inversion Principle can be applied in Angular.

Conclusion

If you’re already working with Angular, you’ve probably been using SOLID principles, without even realizing it. Understanding these rules can help you talk about your code more confidently, especially when discussing it with your manager or team. It can also help you write better, more flexible code that’s easier to update or fix.Understanding SOLID principles isn’t just a theoretical exercise; it’s a practical toolset that can make your code more resilient, flexible, and comprehensible.

--

--

VAIBHAV SHARMA
0 Followers

Full stack developer & Tech lead in Sourcefuse