DEV Community

loading...
Cover image for UI, Composition and Inversion of Control

UI, Composition and Inversion of Control

lorenzofox3 profile image RENARD Laurent ・8 min read

(photo: Tetris by Rob Oo)

Designing robust software often involves dividing a complex problem into smaller and flexible bits in order to then compose them into a coherent whole.
In this article we will go through different aspects of software composition thanks to an example built with a web component which renders a greeting message: the famous "hello world" code sample.
Web components specification offers a low level API and does not hide much complexity behind various layers of abstractions as popular UI frameworks can do (this is why you would use such frameworks after all) an therefore make this technology a perfect fit for this tutorial on architectural concepts.
Keep in mind though, that in the context of this article, web components technology is a just a tool to understand the essence of these concepts and prior knowledge of the technology is not mandatory.
Let's start by having a look at the two following functions

const filterEvenNumbers = (numbers) => {
    const output = [];
    for (const number of numbers) {
        if (number % 2 === 0) {
            output.push(number);
        }
    }
    return output;
};

const filterStringsWithE = (strings) => {
    const output = [];
    for (const string of strings) {
        if (string.includes('e')) {
            output.push(string);
        }
    }
    return output;
};

Both work in a similar way yet remain quite different and rely on totally different assumptions: one operates on numbers whereas the other does on strings. They both follow an imperative style you can easily read as a sequence of basic instructions. 
Although they do the job, you can quickly see they are not very flexible as they mix together code related to the iterations on theirs data structures and condition checks. It prevents us from sharing any logic between the two functions. However we could quickly let emerge a pattern, especially if we rewrite them as so:

const filterEvenNumbers = (numbers) => {
    const output = [];
    const predicate = (number) => number % 2 === 0;
    for (const number of numbers) {
        if (predicate(number)) {
            output.push(number);
        }
    }
    return output;
};

const filterStringsWithE = (strings) => {
    const output = [];
    const predicate = (string) => string.includes('e');
    for (const string of strings) {
        if (predicate(string)) {
            output.push(string);
        }
    }
    return output;
};

Now we could draw a template into a filter operator:


const filter = (predicate) => (items) => {
    const output = [];
    for (const item of items) {
        if (predicate(item)) {
            output.push(item);
        }
    }
    return output;
};

and write our two functions


const filterEvenNumbers = filter((number) => number % 2 === 0);
const filterStringsWithE = filter((string) => string.includes('e'));

Our predicates become totally independent of the context they are used in while the filter operator does not need to make any assumption on the nature of the data structures it will operate on (beyond the fact they need to implement the iterator protocol). Somehow, we can see the filter operator as a procedure with holes which needs to be filled by the caller.
This principle is often called inversion of control and is at the base of many design patterns such template methods, plugins, dependency injection, etc


UI, data fetching and responsibilities

Let's now consider the following web component:


// component.js
import {createService} from './service.js';

export class Greetings extends HTMLElement {

    static get observedAttributes() {
        return ['name'];
    }

    get name() {
        return this.getAttribute('name');
    }

    set name(val) {
        this.setAttribute('name', val);
    }

    attributeChangedCallback() {
        this._render();
    }

    constructor() {
        super();
        this._fetch = createService();
    }

    async _render() {
        this.textContent = await this._fetch(this.name);
    }
}


For the readers who do not know web components: 
The web components specification forces us to declare a component by extending the regular HTMLElement class. We can then define which HTML attributes we want the browser to watch for us thanks to the static getter observedAttributes; and what to do when their values change thanks to the attributeChangedCallback (this is an equivalent for the reactivity/watch mechanism you could find in many UI frameworks) . In our case, we call a custom render function which relies on a data fetch service the component will have created within its constructor.


The service implementation is here a detail but you can imagine something similar to:


// service.js
export const createService = (opts = {}) => async (name) => `Hello ${name || 'Mr. Nobody'}`;

(a basic asynchronous function which takes a string as argument and return a formatted greeting message).
Besides the declarative API (through HTML attributes), we also provide a programmatic API thanks to a property accessor ("name").
We can't however call the constructor ourself (it will throw and error) and must delegate this operation to the browser by registering our custom element into a global registry (this is part of the specification):


// injector.js
export const define = (tag, klass) => customElements.define(tag, klass);

This will allow the browser to create instances of our custom component simply by parsing a tag in the HTML document, or as any regular HTML element, by calling document.createElement(tag).


<!DOCTYPE html>
<html lang="en">
<!-- ... ->
<body>
<app-greetings name="lorenzofox"></app-greetings>
<script type="module">
    import {define} from './injector.js';
    import {Greetings} from './component.js';
    define('app-greetings', Greetings);
</script>
</body>
</html>

You can play around in the following code sandbox by changing the name attribute or with the provided dev tool environment.


Although this example works fine, it is far from being perfect: our component implementation is tightly coupled to a given fetch service. For instance if you wish to test the component in isolation it might be difficult: the service may need to make some network calls, etc. To abstract away the service implementation you would need to hijack the import (with service workers, proxies, etc) to provide a mock or something similar. Jest allows you to do so with global mocks but it is in my opinion an anti pattern and is just a hack which hides a deeper problem in your software.
Writing tests is not an end in itself, but if you encounter some difficulties to test a part of you code, it may be a code smell that your different components are tightly coupled together.
Let's say the requirements have changed and we want to display a different localization message depending on a query string parameter. We have now, various services:


// en.js
export const createService = (opts = {}) => async (name) => `Hello ${name}`;
// fr.js
export const createService = (opts = {}) => async (name) => `Bonjour ${name}`;
// es.js
export const createService = (opts = {}) => async (name) => `Hola ${name}`;
// etc;

The worst that could happen would be a developer in a rush "solving" the problem as so:


export class Greetings extends HTMLElement {
 // ... 
    constructor() {
        super();
        const query = window.location.search;
        const lang = new URLSearchParams(query).get('lang');
        switch (lang) {
            case 'fr':
                this._fetch = createFrService();
                break;
            case 'es':
                this._fetch = createEsService();
                break;
            default:
                this._fetch = createEnService();
        }
    }
// ... 
}

Now our component is coupled to several implementations and to the global object. The constructor carries quite a bit of logic which is almost impossible to test. We could improve somehow the codebase by introducing an indirection point for our services: a single function (createService) which returns the right service based on some parameters. But what if now we want to base the choice of the service on a user setting rather than on the query parameter… yet again, this would require us to change the component code.


Inject the dependency

Ideally we don't want the component (which belongs to some sort of presentation layer) to bear the responsibility of creating/configuring the service which may depends on many parameters out of the component context… and belongs anyway to some sort of business layer.
As we can't call the constructor of a web component and rely on the browser to create an instance of the component this sounds quite challenging, but it is not. First, we can still write our constructor with a default parameter to workaround this issue:


import {createService} from './service.js';

export class Greetings extends HTMLElement {
    //...
    constructor(service = createService()) {
        super();
        this._fetch = service;
    }
    //...
}

This would work as the engine would resolve the passed service as the result of the createService function: we have moved the logic of creating the data fetch service out of the component.
Even better: if we modify slightly the code which registers our component into the global registry we can pass any service:


// injector.js
import {createEnService, createEsService, createFrService} from './service.js';


const resolveService = () => {
    const search = window.location.search;
    const lang = new URLSearchParams(search).get('lang');
    switch (lang) {
        case 'fr':
            return createFrService();
        case 'es':
            return createEsService();
        default:
            return createEnService();
    }
}


export const define = (tag, klass) => {
    const service = resolveService();
    customElements.define(tag, class extends klass{
        constructor() {
            super(service);
        }
    });
};

we have decorated the regular customElements.define function to pass a component which injects the dependency in our component. Now the component is fully independent of any context, so the services are. The only part we need to modify if the requirements ever change is the resolveService function ! This injection of dependencies code is the only one in charge of resolving the appropriate instances in an "omniscient" way.
You can see the whole code here

Testing the component

Instead of relying on global mock hacks we can now easily pass to the component any implementation of the service (including a mock) and test it in full isolation:


import stub from 'sbuts';
import {test} from 'zora';
import {flush, mountComponent} from './utils.js';
import {Greetings} from '../component.js';

test(`when component is mounted, it should not render anything if no name attribute is set`, async t => {
    // given
    const service = stub().resolve(`hello world`);

    // do
    const comp = mountComponent(Greetings, service);
    await flush();

    // expect
    t.eq(comp.textContent, '');
    t.notOk(service.called);
});

test(`when component is mounted, it should render the service message when the name attribute changes`, async t => {
    // given
    const service = stub().resolve(`hello world`);
    const attributeValue = 'lorenzofox';
    const comp = mountComponent(Greetings, service);

    // do
    comp.setAttribute('name', attributeValue)
    await flush();

    // expect
    t.eq(comp.textContent, 'hello world');
    t.eq(service.calls, [[attributeValue]], `service should have been called once with ${attributeValue}`);
});

Fort the record: mountComponent is a test utility function which basically does what the injector in our application does whereas flush is used to make sure any pending Promise is flushed before we make our assertions. 
If you wish to see the details you can have a look at the following code sandbox.

Is this a good test ?

Yes… and no. It is a good unit test in the sense it tests the component code in full isolation, abstracting away the service code and making sure whatever is the service implementation, it is called with the right argument. However if for some reason you have to change the interface of a given service implementation


// from
export const createServiceA = (opts) => async (name) => `hello ${name}` 
// to
export const createServiceA = (opts) => async ({name}) => `hello ${name}`;

Your test will keep passing although your application is broken: the test has not caught the regression. But after all, it is not its responsibility to catch changes in a dependency interface as it is meant to test the unit of code related to the web component only.
 
The point is: when you want loose coupling and introduce dependency injection like patterns you must connect the different parts through interfaces and abstract types

In Javascript, it is less obvious as the notion of interface is not built-in but if you add a type system (such Typescript) on top of it, your code would not compile and the regression would be caught.
It is then the role of the injector to fix this kind of discrepancies. You can for example use an adapter:


const adapter = (fetch) => (name) => fetch({name});

const resolveService = () => {
    const lang = new URLSearchParams(window.location.search);
    switch (lang) {
        case 'fr':
            // the service with a different interface
            return adapter(createFrService());
        case 'es':
            return createEsService();
        default:
            return createEnService();
    }
};

And Again, there is no need to change either the component code either the service code: the injector connects the dots together!

Conclusion

With this basic example we have seen how a set of architectural patterns can help to create a robust and flexible software without necessarily reaching out to many code branches (if … else … etc): we solve the problem by composition.

Discussion (0)

pic
Editor guide