DEV Community

Skye Simpson
Skye Simpson

Posted on • Updated on

Adding Structure to If Statements with help from Angular

I regularly contribute to the Frontend at Trade Me. It’s large, single page application that is built using Angular. I recently encountered an interesting architectural problem that I’d like to share.

What problem are we looking to solve?

My team and I were building a feature called Recent Searches, which are clickable cards displayed on the homepage that show key information about your most recent search. They will re-run that search with all the same parameters and filters when selected to take members right back to their exact previous search. The idea was to make it easy for our members to easily jump in and re-search what they are looking for 😁.

What’s a vertical, and how does it impact what’s shown?

Our Recent Search cards have different designs for each vertical, highlighting the search parameters and filters that are most relevant for that vertical. A vertical at Trade Me is also sometimes called a business unit, and it's an umbrella category referring specifically to Marketplace, Property, Motors and Jobs. The vertical will impact what is shown, as every vertical has search filters and parameters that only apply to that vertical. For example you would ONLY search for an Odometer under 100,000km if you were searching in motors. Likewise pay type hourly or annually would only apply when you are searching for a job. Image of recent searches on the homepage

Here's a couple of examples of cards for various verticals.Property, marketplace and motors search cards

How was the problem solved previously, and what were the issues with it?

Previously, this problem was solved by having one Recent Searches component that is responsible for ALL the verticals in one file. Things had started to get a little messy and we were having to do a lot of logic and many if statements to filter out the search refinements we needed to show based on which vertical we were currently in.

 <strong *ngIf="recentSearch.areaOfBusiness === areaOfBusinessEnum.property && recentSearch.keywords ">..
</strong>
<ng-container *ngIf="recentSearch.breadcrumbs && recentSearch.areaOfBusiness !== areaOfBusinessEnum.jobs">...
</ng-container>
Enter fullscreen mode Exit fullscreen mode

This was feeling like a bad code smell for a few reasons. When we wanted to add another card here, for example Stores searches, it was quite hard to do. We had to make sure we didn't break any existing logic. The manual testing for even small changes was very large. There were a lot of side effects and you had to be worried about breaking things as everything was all tightly coupled. Furthermore this approach violated the Single Responsibility Principle of SOLID.

Below shows how the problem was currently being solved with display logic for each vertical all kept together in one file.
diagram of how problem is currenty

How did we re-build it?

We decided that this problem could be solved in a more maintainable, scalable and reliable way using a polymorphic design that takes advantage of composition. In Martin Fowler’s book Refactoring he discusses this at length as the Replace Conditional with Polymorphism refactoring pattern. The complex conditional logic of the Recent Searches Component could be extracted into separate components and composed into a single component using dependency injection.

We made a component called the recent-searches-card-switcher that is only responsible for projecting each of the search cards and can switch between different card types. It uses a component factory resolver to make a generic component which is filled in with the vertical specific component based on the result of the injection token and an isMatcher function.

Recent Searches Card Switcher component

export class RecentSearchesCardSwitcherComponent implements OnChanges, OnDestroy {
    @Input() public recentSearch: IRecentSearch;

    @ViewChild('recentSearchCardHost', { read: ViewContainerRef, static: true }) public recentSearchCardHost: ViewContainerRef;

    private _componentRef: ComponentRef<IRecentSearchesCardComponent>;

    constructor (
        @Optional() @Inject(RecentSearchesToken) private _recentSearchCards: Array<IRecentSearchCard>,
        private _componentFactoryResolver: ComponentFactoryResolver
    ) { }
....
}
Enter fullscreen mode Exit fullscreen mode

How did we achieve this?

The main concepts from the Angular Framework that help us achieve this approach are dependency injection - specifically injection tokens.

Using dependency injection in our app

There are some good resources for learning about dependency injection in an angular application such as the Angular docs.

Angular has a hierarchical tree of injectors, with the root injector at its root. If we register a dependency with providedIn: root, then it will be registered at the root injector. This is great for most of our dependencies, because it means that if two components in different parts of the application ask for that dependency, they will get the same instance.

However in our use case we have our components that have dependencies on an abstraction (card-switcher). That abstraction has different implementations in different verticals. How we manage this is by using an injection token to be able to depend on that abstraction. The injection token is a simple string value.

Recent search card token

export const RecentSearchesToken = new InjectionToken<IRecentSearchesCardComponent>('recentSearchesCardItemToken');
Enter fullscreen mode Exit fullscreen mode

We can't register our concrete implementations in the root injector because they need to be registered with the same injection token. There will either be an error if you try to register things twice under the same token (without specifying multi: true) or the last one wins (which could introduce unwanted side effects).

In our case we want to specify multi: true in the modules of the specific card implementations that have been split out. Here the provider takes the injectable token and the useValue key is what lets you associate a fixed value with a DI token - in our case the value of the type of component that is passed in as below.

search card provider

export function getRecentSearchesCardProvider (component: Type<IRecentSearchesCardComponent>, isMatch: isMatchFn): ValueProvider {
    return {
        provide: RecentSearchesToken,
        useValue: { component, isMatch },
        multi: true
    };
}
Enter fullscreen mode Exit fullscreen mode

This gets called in the provider of the Module of each specific implementation (in our case we have five different components) - an example of one being called in the module is below.

marketplace module

export class MarketplaceRecentSearchCardModule {
    public static toMatch (matcher: isMatchFn): ModuleWithProviders<MarketplaceRecentSearchCardModule> {
        return {
            ngModule: MarketplaceRecentSearchCardModule,
            providers: [
                getRecentSearchesCardProvider(MarketplaceRecentSearchCardComponent, matcher)
            ]
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

The modules all get registered together in the all-recent-search-cards.module.ts which means all the dependencies can be pulled in and grouped together when in use.

All-searches-card.module

@NgModule({
    imports: [
        PropertyRecentSearchCardModule.toMatch(isPropertyRecentSearchCardMatch),
        MarketplaceRecentSearchCardModule.toMatch(isMarketplaceRecentSearchCardMatch),
        JobsRecentSearchCardModule.toMatch(isJobsRecentSearchCardMatch),
        MotorsRecentSearchCardModule.toMatch(isMotorsRecentSearchCardMatch),
        SellerRecentSearchCardModule.toMatch(isSellerRecentSearchCardMatch)
    ]
})
export class AllRecentSearchCardsModule { }
Enter fullscreen mode Exit fullscreen mode

Along with passing in their specific matcher function

isMatch function

export type isMatchFn = (areaOfBusiness?: AreaOfBusinessEnum, seller?: IMember) => boolean;
Enter fullscreen mode Exit fullscreen mode
export function isMarketplaceRecentSearchCardMatch (areaOfBusiness?: AreaOfBusinessEnum, seller?: IMember): boolean {
    return areaOfBusiness === AreaOfBusinessEnum.marketplace;
}
Enter fullscreen mode Exit fullscreen mode

This way we make sure that components like the card-switcher can live in main (a central file location) but depend on a vertical specific implementation. The card switcher pulls all the modules in via an injection token. For each new card that gets rendered it uses the isMatch on every recent search card to know which card to display. The card switcher has all the available cards injected into it - it doesn’t decide how to display the layout or details - it simply asks the interface which card to display.

This diagram outlines how all the pieces fit together, with the runtime result of the injection token and isMatcher function deciding which verticals card to render, and the display logic in the component html only being concerned with visual layout and not a lot of business logic mixed together anymore 😎.

Diagram of new approach to recent searches

The example in stackblitz also shows the working pieces fitting together.

How is it better?

What this new way of doing things has effectively done is moved the decision making based on verticals out of the component view level and up into a generic component which gets passed the information it needs from the module providers. This allowed each vertical to now have it's own component which only had to worry about the logic for its own set of search rules. For example property only needed to worry about property concerns, and marketplace is only responsible for marketplace things 😎. By the time the component is rendered the decision of “which search am I?” has already been made at the switcher level, and it can just render all the information it is feed via an input.

This makes for a very straight forward UI component, which is much nicer to work in. Unit tests and manual test are also more modular and have no side effects. Furthermore an arbitrary number of cards can be added and it won't affect the overall complexity of the project.

This new solution design also illustrates three SOLID principles. Firstly the Single Responsibility Principle - each vertical card cares only about rendering that verticals one component.

Secondly the Open-Closed Principle whereby it's available to extend in adding extra cards, without modifying the interface itself.

Lastly the Dependency Inversion Principle (arguably the most important SOLID principle 😎). Previously the Recent Searches Component implemented all of the logic for every vertical card, and therefore depends on the details of the cards implementations. In the new design, the recent-searches-card-switcher-component defines an interface, IRecentSearchCardInterface, and each card module implements that interface. The dependency has been inverted 🔥🔥.

Pros

  • Complex conditional logic can be hard to reason about - this removes lots of the previous if statements from a previously large file👏.
  • Any changes to an individual card affect only that card - increasing the ease and speed of development 👏.
  • Unit tests and manual test are more modular and have no side effects 👏.
  • An arbitrary number of cards can be added and it won't affect the overall complexity of the project- making it scalable 👏.
  • SOLID principles are honored - specifically Single Responsibility, Open-Closed and Dependency Inversion 👏.

Cons

  • Avoiding circular dependencies can be tricky with this approach.

Conclusion

When working in a large scale application where code can easily become overly complex it is really appreciated where there are strategies and patterns we can use to make things less reliant on each other and more manageable 😎

Whats next?

In future we could swap out the component factory resolver for the dynamic component loader as the resolver is now deprecated. As well as iterating on this feature based on the feedback from our ab testing. Watch this space 👀

Top comments (0)