Opening Thoughts

This is the second installment in our SOLID article series—a collection of guidelines that helps us write code that scales with less friction and lets us alter application behavior without rewriting substantial portions of the existing codebase.

The principles we'll cover are:

Our focus today is the Open/Closed Principle 🙂

Understanding Open/Closed

Angular & Open/Closed Principle — figure 1

Credit: https://maksimivanov.com/posts/open-closed-principle/

The illustration above highlights poor Edward Scissorhands, stuck with only scissors at his disposal. Having access to a variety of tools would serve him much better. How might we achieve that? Perhaps he could simply pick up the tools with his hands rather than having them permanently attached to his body. 🙂

Now, onto the formal definition: a class or object should be open for extension, yet closed for modification. In simple terms—design your code so that you can introduce new functionality without altering the existing implementation.

Originally, this concept was tied to implementing polymorphism through inheritance: you create a base class and then derive from it.

Here's how that works:

  • the base class remains closed to modification by subclasses since derived classes cannot directly change the base class's methods,
  • the base class is open for extension because we can subclass it, thereby broadening its capabilities.

However, inheritance creates a rigid coupling between objects—the base class and all of its subclasses are tightly bound. When writing code, it's wise to steer clear of such rigid dependencies.

If we don't:

  • mocking dependencies and writing tests becomes significantly harder,
  • swapping implementations is painful (changes ripple through many classes),
  • the codebase becomes fragile—you end up with a monolithic dependency web where altering one thing forces changes elsewhere.

Given these drawbacks, interfaces are often a better choice than inheritance. Why is that?

  • we can swap out implementations easily (thus changing app behavior) without touching any other code,
  • interfaces are closed to modification but open for extension—new behavior comes from adding fresh implementations of an existing interface,
  • we avoid being locked into the specific fields or implementation details of a base class (giving us more freedom),
  • we gain an extra layer of abstraction that promotes "loose" coupling between objects.

A Practical Walkthrough

Recently, we were building a new version of an existing application module, but we didn't want to roll it out to all users. Our goal was to restrict access to this new feature to just a handful of trusted clients. To make that happen, we turned to the feature flags approach.

Here's the idea: the backend (and theoretically the frontend too) communicates which features are enabled for each user. It's essentially an object with multiple key-value pairs. When any field holds a "TRUE" value, that corresponding feature is activated.

To control access to the fresh module, we added a guard that inspects the feature flag to decide whether entry is permitted. To support that, we defined an enum to describe the modules, plus a service that exposes a method for checking access to the new module:

export enum AppModule {
    ORDER,
    ORDER_NEW,
    CUSTOMER
}

@Injectable()
export class ModuleAccessService {
    hasAccessToNewOrderModule(): boolean {}
}

Inside the guard, we invoke the hasAccessToNewOrderModule method and let its result determine whether we navigate forward or block the route. At first glance, it looks fine—the code functions as intended.

But what's the underlying issue?

What if we need to verify access for additional modules? We'd be forced to edit the existing service and tack on more methods. Pretty soon, that service file balloons in size. Moreover, either the guard would need visibility into all these methods, or we'd be creating a separate guard per module and have to remember which method maps to which.

Angular & Open/Closed Principle — figure 2

Some of you might have already faced files stretching into the hundreds of lines. If you haven't, take my word for it—you don't want to troubleshoot such code later on. To sidestep that headache, let's refactor this into something cleaner:

export enum AppModule {
    ORDER,
    ORDER_NEW,
    CUSTOMER
}

@Injectable()
export class ModuleAccessService {
    hasAccessToModule(module: AppModule): boolean {}
}

With a new module requiring access checks, we'd simply add an entry to the enum and rely on the new, more generic service method.

Yet, this still isn't ideal—(likely) we'd be stuck modifying a large switch statement tucked inside that method. While we might now have a single shared guard leveraging the generic method, we'd still have to supply the relevant enum value to it.

So, let's push the refactoring further and extract an interface:

export abstract class ModuleAccessService {
    abstract hasAccess(): boolean;
}

Now, for every module where access needs checking, we craft a dedicated implementation:

@Injectable()
export class OrderNewModuleAccessService implements ModuleAccessService {
    hasAccess(): boolean {
        // do stuff
    }
}

From this point forward, our guards rely on abstractions rather than a concrete implementation:

@Injectable()
export class OrderNewModuleGuard implements CanActivate {
    constructor(private accessService: ModuleAccessService) {}

    canActivate(): boolean {
        return this.accessService.hasAccess();
    }
}

We wire up the specific implementation, say, at the module level:

@NgModule({
    providers: [
        {
            provide: ModuleAccessService,
            useClass: OrderNewModuleAccessService,
        },
        OrderNewModuleGuard,
    ]
})
export class OrderNewModule {}

And with that, we've embraced the Open/Closed Principle. In the upcoming article, we'll dive into the Liskov Substitution Principle.