Experiment: Using Service Barrels As A De Facto Dependency-Injection Container In Vue.js 2.5.22
Coming from an Angular.js / Angular background, one of the first things I that noticed in Vue.js is that Dependency-Injection (DI) is not a first-class citizen of the framework. There is some DI in Vue.js; but, it only pertains to the component tree - not to the services that drive the application logic. Having used Dependency-Injection for years in Angular, this left wondering as to how Vue.js developers even locate their services? There doesn't seem to be much discussion on this matter - at least, not that I could find on the Googles. So, as an experiment, I wanted to see if I could use the native caching of JavaScript modules to create a de factor Dependency-Injection container where I could manually apply some Inversion of Control (IoC) patterns to my application service classes in Vue.js 2.5.22.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
In the past, I've often asked React.js developers how they locate their application services without dependency-injection. And, more often than not, I just receive a confused look. As if the question made no sense to begin with. Now that I'm trying to learn about Vue.js - and I've begun to ask similar questions of the few Vue.js developers I've met - I've discovered that Vue.js developers will also respond with a similar look.
Wiring Vue.js applications together does not seem to be a topic that people blog about. Or, perhaps I'm just searching for the wrong phrases? After looking at some existing Vue.js applications, I get the sense that Vue.js developers (and React.js developers) lean heavily on the "Singleton Pattern". That is, if they need a service, they import said service module directly and use JavaScript's module caching properties to ensure that only a single instance of said service is ever created within the application.
For example, if one service depended on a Logger implementation, said service would simply import the Logger module directly:
// NOTE: When JavaScript resolves the given "logger" module, it will cache the resulting
// instance in memory. This way, all modules in the application that attempt to import
// "logger" will all receive the same "Singleton" instance.
import logger from "./logger";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
export class MyService {
// ...
someMethod() {
try {
// ...
} catch ( error ) {
logger.handleError( error );
}
}
// ...
}
In this case, the first time the "logger" module is referenced, JavaScript will resolve the module and cache the result. Then, all subsequent references to the "logger" module will use the cached result. This means that if the logger module exports an instance of a service, that service instance becomes the "Singleton" instance within the greater Vue.js application.
To be clear, this approach to application architecture can definitely work (and clearly does for many people). My intention here is not to hate on the Singleton Pattern. But, coming from a framework that relies heavily on Inversion of Control (IoC), the Singleton pattern feels claustrophobic. As such, I wanted to see if I could find a compromise: something half-way between the Single Pattern and a full-on Dependency-Injection framework.
One idea that I had was to use "JavaScript Barrels". A "barrel" is nothing more than a single JavaScript file that exports a bunch of related values. Generally speaking, a barrel is just a "convenience" that hides a complex directory structure. But, a "barrel" combined with JavaScript's native module resolution caching could act as a de facto Dependency-Injection container.
With this approach, the Vue.js application at-large would import services from the "barrel" instead of from the individual modules directly. The barrel resolution, in turn, would import the various services, wire them together, and then export the instantiated instance. These exported instances would then become "single instances", not "singletons".
ASIDE: "Single Instance" vs. "Singleton" - At first, it's hard to understand the difference between a "single instance" and a "singleton". After all, the barrel is exporting a set of shared instances - how is that any different from a class module that exports a class instance? The difference lies in responsibility. When a module exports a single instance, it's the "module" that decides that only a single instance should exist within the greater application. Conversely, when an application only instantiates a single instance of a class, it's the "application" that decides that only a single instance should exist within the greater application. The more responsibility that gets pushed up into the application layer, the more flexible the code becomes.
To make this concrete, imagine that we have a Vue.js application that can load a list of friends. It does so using a FriendService. The FriendService talks to a remote API using an ApiClient. And, this ApiClient is provided with configuration settings that point it at the correct remote location:
Vue App -> FriendService -> ApiClient -> Configuration
With a "Singleton" approach, a Vue.js component might import directly from the "friend-service" module:
import friendService from "./services/friend-service";
This requires that the "friend-service" module have baked-in knowledge of the ApiClient which must, in turn, have baked-in knowledge of the configuration.
With the "barrel" approach, we simply put a thin layer of indirection between the Vue.js application and the service layer:
Vue App -> Service Barrel -> FriendService -> ApiClient -> Configuration
Now, the Vue.js application at large is decoupled from the way in which the services are wired together. This allows the services to be coded in such a way that they become more independent and, as a result, more flexible.
As an example, look at this Vue.js application component. This component imports the FriendService from the "service barrel" and then loads a list of friends once it is mounted:
<style scoped src="./app.component.less" />
<template>
<div class="app">
<h2>
Friends
</h2>
<ul v-if="friends">
<li v-for="friend of friends" :key="friend.id">
{{ friend.name }}
</li>
</ul>
<div v-if="errorMessage" class="error">
<strong>Sorry:</strong> {{ errorMessage }}
</div>
</div>
</template>
<script>
// Import application classes.
// --
// NOTE: The "services" barrel is our de facto dependency-injection (DI) container.
// This way, this component / module isn't tied to a specific implementation of any
// of these services, only to the ones that were created during the bootstrapping
// process of our Vue.js application.
import { friendService } from "./services";
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
export default {
data() {
return({
errorMessage: null,
friends: null
});
},
mounted() {
friendService.getFriends().then(
( friends ) => {
this.friends = friends;
},
( error ) => {
this.errorMessage = "There was a problem loading your data :("
this.friends = null;
}
);
}
};
</script>
Notice that this component doesn't know anything about where the FriendService module is located; or, about how it is configured within the application. This component only knows that the "service barrel" holds an instance of it.
The "service barrel" is just a simple JavaScript module that, upon resolution, imports and wires-together our application services:
// Import application classes.
import { ApiClient } from "./api-client";
import { FriendService } from "./friend-service";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// Without a proper dependency-injection (DI) container, we can AT THE VERY LEAST use a
// JavaScript module to create a de facto service container that other modules can pull
// from. This way, we will still have some inversion-of-control (IoC) that will help us
// think more deeply about the separation of concerns and the modularity of our code.
// --
// Manually wire services together.
export var apiClient = new ApiClient( "./api" );
apiClient.onStatusCode(
function handleStatusCode( statusCode ) {
console.group( "HTTP Status Code" );
console.log( statusCode );
console.groupEnd();
// If the status code indicates an UNAUTHORIZED request, the user's session
// has probably been terminated. As such, bounce the user to the root of the
// application.
if ( statusCode === 401 ) {
window.location.href = "/";
}
}
);
export var friendService = new FriendService( apiClient );
// CAUTION: By exporting these services in a single file, we prevent any individual
// service from being eliminated as "dead code" during subsequent "tree shaking". That
// may or may not be a cause for concern. If so, you could always move each service
// instantiation to its own file.
As you can see, all the "service barrel" does is import the Class definitions from the various application service modules, provides them with their dependencies, adds some application-specific logic (ex, bouncing users who receive a "401" HTTP response), and then exports the instances for consumption in the Vue.js application.
By transforming this JavaScript module resolution into a de facto Dependency-Injection container, our other services become decoupled from each other. This leaves us with a very clean FriendService:
export class FriendService {
// I initialize the friend-service.
constructor( apiClient ) {
this._apiClient = apiClient;
}
// ---
// PUBLIC METHODS.
// ---
// I get all of the friends. Returns a Promise.
async getFriends() {
return( await this._apiClient.get( "/friends/index.json" ) );
}
}
And, a very clean ApiClient:
NOTE: This is the first time I've ever used the fetch() API. So, please don't focus on the actual HTTP mechanics - I was just trying to branch-out a bit in my JavaScript skills.
export class ApiClient {
// I initialize the api-client service.
constructor( baseUrl ) {
this._baseUrl = baseUrl;
this._listeners = [];
}
// ---
// PUBLIC METHODS.
// ---
// I perform an HTTP GET for the given end-point.
async get( endpoint ) {
try {
var response = await fetch(
`${ this._baseUrl }${ endpoint }`,
{
method: "get",
cache: "no-cache",
credentials: "same-origin"
}
);
} catch ( error ) {
// If the fetch() operation failed, there was either a network failure or a
// CORS configuration error. As such, let's emit status code "0".
this._emit( 0 );
// Re-throw error since we cannot recover from this.
throw( error );
}
// At this point, we know there was no network error, so just emit the status
// code of the response - fetch() will resolve with both OK and NOT OK status
// codes.
this._emit( response.status );
if ( response.ok ) {
return( response.json() );
}
return( Promise.reject( await response.json() ) );
}
// I register a listener for HTTP status codes returned from the API response.
onStatusCode( listener ) {
this._listeners.push( listener );
}
// ---
// PRIVATE METHODS.
// ---
// I invoke each listener with the given HTTP status code.
_emit( statusCode ) {
for ( var listener of this._listeners ) {
listener( statusCode );
}
}
}
As you can see, neither of our service modules need to import any other files. These modules are decoupled from each other and, instead, are only coupled to the API exposed by the various injected dependencies. Furthermore, each service - ApiClient and FriendService - could theoretically be instantiated multiple times within the application if need-be. This is why they are no longer constrained by the "Singleton Pattern" despite the fact that they are, coincidentally, only instantiated once during the life-time of the application.
And, to prove that this all works, we can run the Vue.js application in the browser:
As you can see, even though our application services were decoupled from each other, our "service barrel" wired them together correctly and then provided them to the rest of the Vue.js application.
At first blush, it might seem like this Inversion of Control (IoC) doesn't really provide a benefit in Vue.js (or React.js). But, once you start using this kind of approach, you will find that it forces you to think more deeply about what your services do and about how much they need to know about the context in which they are being consumed. The "service barrel" in this exploration doesn't really add any additional code - it only moves logic around. But, it does so in a way that ultimately makes the code more flexible.
Want to use code from this post? Check out the license.
Reader Comments