@ganbarodigital/ts-lib-servicemanager

Factory-driven dependency injection (DI) container, based on Laminas ServiceManager

Usage no npm install needed!

<script type="module">
  import ganbarodigitalTsLibServicemanager from 'https://cdn.skypack.dev/@ganbarodigital/ts-lib-servicemanager';
</script>

README

ServiceManager for Typescript

Node.js CI

Introduction

This TypeScript library provides a factory-driven dependency injection (DI) container, based on Laminas' ServiceManager.

Quick Start

# run this from your Terminal
npm install @ganbarodigital/ts-lib-servicemanager
// add this import to your Typescript code
import { ServiceManager } from "@ganbarodigital/ts-lib-servicemanager/lib/v1"

VS Code users: once you've added a single import anywhere in your project, you'll then be able to auto-import anything else that this library exports.

Why Use ServiceManager?

The Case For Dependency Injection

Dependency injection is often (mistakenly) referred to as inversion of control (IoC for short). It's not IoC, it's its own thing.

Libraries and modules inside apps use utility classes/functions, such as loggers. Without dependency injection, libraries and modules decide for themselves which utilities they are going to use.

That causes problems in applications, where you often need everything to use the same utilities. An application that ends up using half a dozen different loggers will be very hard to deploy and maintain over time, for example. An application making half a dozen different connections to the same database server will end up DDoSing your database when your app gets busy.

Dependency injection solves this problem. The libraries and modules depend on shared interfaces, and your app passes in the actual utilities to use. These utilities normally come from a dependency injection container (DI container for short), so that they get reused over and over rather than re-created.

The question isn't if dependency injection is good practice. It is. The question is: what kind of dependency injection suits you, your team, and your code base?

Is An Alternative Better For You?

There are other dependency injection libraries for TypeScript, and plenty of articles about how to write your own. The vast majority of these use @decorators to effectively emulate behaviour known as auto-wiring.

You should definitely check them out. They might suit your needs better.

The Case For The Service Locator Pattern

ServiceManager is an implementation of the service locator pattern.

In brief, that means there's a central container that holds all the services. You create it once (when your app starts up), and then use it over and over until your app shuts down.

(You can have multiple DI containers. There's nothing in ServiceManager to prevent that. In practice, we've never come across a reason to have more than one in your production app.)

That means there's one place in your app to:

  • standardise how each service is created
  • find the list of available services
  • get a service that you need

Having (for example) one place to go and get a Logger or a Database connection saves a lot of problems over the lifetime of an app. Any changes or maintenance needed? That only needs doing in the one place.

Isn't Service Locator An Anti-Pattern?

You might have read that the service locator is an anti-pattern, and should be avoided. We're going to address that in this part of the README.

Obviously, we don't agree :)

That article is 10 years old (at the time of writing this README), and we believe that it doesn't stand up to widespread practical experience in the years since. The PHP Community - one of the largest programming communities on Earth - has been successfully and safely using service locator-based DI containers for the last 7+ years.

The main flaw in the article is that all of its arguments and examples are based on the DI container being a static class that's invisibly consumed by services. Today, these static singletons are well-known to be terrible pieces of software design. Remove the static singleton from the examples, and the arguments largely fall away too.

(In 2010, the author may not have known that static singletons were terrible software design.)

Setting aside the examples, the article's underlying premise is that it's bad software design to have silent dependencies. We certainly agree with that. If any piece of code has dependencies that you cannot see at compile-time, you have no way of knowing what dependencies the service relies on, until you run it and it falls over. That's a robustness issue, and it may not get caught until the code is in production.

That is not a problem with the service locator pattern. It's a problem caused by breaking the principle of encapsulation.

Fortunately, it's an easy problem to avoid in practice:

  • do not pass the DI container itself into any of your services,
  • pass the dependencies into your service's constructor;
  • use factories to decouple the service from the container,
  • and don't use static singletons for your DI container in the first place

In your app, it's your factory that grabs any dependencies from the container - not your service's constructor. Your service should never ever see the container. Don't pass the container into your service, ever, and you'll be fine.

Do this, and you'll find that the other argument against service locator - that it's hard to test - also doesn't stand up to scrutiny.

  • Your service doesn't know anything about the DI container, so there's no impact on testing there. Your service remains as easy (or as hard) to test as it has always been.
  • Your factories do need a populated DI container, but that's the only place where coupling occurs.

What happens in practice is that the tests for your factory also serve as extra, executable documentation of what dependencies your service needs, because the test needs to populate the DI container. It makes the list of dependencies explicit, in a way that even the service's constructor cannot. New developers can look at the tests, and see exactly what dependencies each service has.

We've successfully - and safely - used factory-driven service locators on many projects, including a busy payment system for central government. We've successfully handed these projects over to other developers, who had no difficulty in continuing to use factory-driven service locators long after we'd left the project.

With ServiceManager, we're bringing this tried-and-proven approach to our TypeScript projects.

The (Further) Case For Factories

ServiceManager is based on a factory-driven feature set from PHP's Laminas ServiceManager. It doesn't support @decorators - and probably never will.

So why this approach?

  • The resulting code is much easier to trace and maintain. @decorators do save a lot of time when writing code for the first time, but it can be hard for future maintainers to trace through the code and work out exactly how it is working.
  • The compiled JavaScript looks much more like the original TypeScript. That makes it easier to respond to runtime failures, and map the JavaScript error stack trace to your original code.
  • It's your app that decides which factories are used to create each service. That puts you - the app author - in control, not the library. That's what inversion of control is actually about.

In our experience, these benefits are worth the little bit of extra effort required to write factory-driven DI code.

Type Integrity Baked In

A dependency injection container (DI container for short) has to hold many different types of object. That's its job. That also means that you don't get the compile-time checks that make TypeScript worth using.

You end up either:

  • using typecasts (such as as XXX) to tell the compiler to trust that you have the right object from the DI container, or
  • using type guards to prove to the compiler that you have the object you asked the DI container for

Typecasts are dangerous as hell in TypeScript. They transfer all the risk to when your code runs. If there's a problem, you only find out about it when your code goes bang.

Type guards are safe, but they transfer all the costs to when your code runs. Every time you retrieve a service from the DI container, those guards have to be run. Those costs quickly add up, and slow down your app.

With ServiceManager, we've found a way to make it hold different types of object and still have compile-time type information that you can trust. Provided you know in advance what services you want to register, you can rely on the TypeScript compiler to eliminate both the runtime risk and the runtime cost.

Highly Customisable

At its heart, the ServiceManager is an object that calls a function to retrieve your service object. You decide which function it calls for each service.

And that means you can customise it to behave however you need.

We've shipped a bunch of pre-built functions that cover the behaviours you'll probably want the most. They're based on the behaviours that we regularly use in the Laminas ServiceManager.

If you need something different, there's nothing stopping you from writing your own functions (called ServiceProvider builders) as well, and mixing them in with our pre-built behaviours.

Core Types

ServiceManager

export class ServiceManager<L extends ServicesList = ServicesList> {
    /**
     * holds all of the services stored in the DI container
     */
    public readonly services: L;

    /**
     * creates a new DI container
     *
     * @param services
     *        a list of services (and their factories) that you guarantee
     *        will be available
     */
    public constructor(services: L);

    /**
     * adds a service to the DI container
     *
     * You can also use this to replace an existing service.
     *
     * @param name
     * @param provider
     */
    public addProvider<T>(name: string, provider: ServiceProvider<T>);

    /**
     * retrieve a service from the DI container
     *
     * @param name
     */
    public get(name: string): object;

    /**
     * retrieve an existing ServiceProvider from the DI container
     *
     * @param name
     */
    public getProvider(name: string): ServiceProvider<object>;

    /**
     * data guard. checks to see if we have a provider for the
     * requested service
     *
     * @param name
     *        the service you want to check for
     */
    public has(name: string): boolean;

    /**
     * data guarantee. throws a DependencyNotFoundError if we do not
     * have a provider for the requested service
     *
     * @param name
     */
    public mustProvide(name: string);

ServiceManager is a dependency-injection container (DI container for short).

To use it, define your own child class, and inject the services you know will be present:


import {
    ServiceManager,
    ServiceProvider,
    ServicesList,
    sharedInstance
} from "@ganbarodigital/ts-lib-servicemanager/lib/v1";

// we need to define a list of services that will be
// exported as properties of the DI container
//
// everything in this list will have the maximum amount
// of type-safety support from the TypeScript compiler
interface AppServicesList extends ServicesList {
    logger: ServiceProvider<MyLogger>;
}

// for convenience, we'll subclass the ServiceManager
//
// this gives us a type that we can use in function parameter lists
//
// there's no need to add anything to the class definition at all
class AppServiceManager extends ServiceManager<AppServicesList> {
}

// this creates our DI container, and injects the list of services
// that are known
//
// `sharedInstance` is a `ServiceProvider`
//
// `ServiceProvider`s allow you to control what happens when the service
// is retrieved from the DI container
const container = new AppServiceManager({
    logger: sharedInstance(container, "logger", myLoggerFactory, {});
});

// you can now do this
//
// this is what you should do in your app, which is going to know
// the type of your container and its services
const logger = container.services.logger();

// or you can do this,
//
// but you have to use a typecast (not recommended)
// or type guards (has a runtime cost) to ensure
// that `logger` has the expected type
//
// this is what you will have to do in importable packages,
// which are not going to know the type of the container or
// its services
const logger = container.get("logger") as MyLogger;

AnyServiceManager

/**
 * represents any possible ServiceManager
 *
 * use this in functions that take a DI container as a parameter
 */
export type AnyServiceManager = ServiceManager<any>;

ServiceAction

/**
 * the prototype of any functional options
 */
export type ServiceAction<T extends object> = (service: T) => void;

ServiceAction is a function type. ServiceActions are functions that modify the service after it has been created.

You can pass a list of ServiceActions to some ServiceProviders.

ServiceProducer

import { AnyServiceManager } from "../ServiceManager";

/**
 * prototype for your factory functions
 */
export type ServiceProducer<T extends object, O extends object = object>
  = (container: AnyServiceManager, requestedName: string, options: O) => T;

ServiceProducer is a function type. Your factory functions (the functions that create new services) must match this type.

ServiceProvider

/**
 * a ServiceProvider returns an instance of the requested service
 */
export type ServiceProvider<T extends object> = () => T;

ServiceProvider is a function type. ServiceProviders control the behaviour of the DI container, whenever you retrieve a service from the DI container.

OptionsPreparer

/**
 * an OptionsPreparer returns a (possibly modified) instance of
 * the object you call it with
 *
 * putting this into function form allows us to swap out the behaviour
 * of how we prepare options that are passed to ServiceProducers
 */
export type OptionsPreparer<T extends object> = (options: T) => T;

OptionsPreparer is a function type. OptionPreparers are used by ServiceProvider functions to make any necessary changes to the options before they are passed into your factory.

We currently ship two functions that do this job:

Function Purpose
OPTIONS_PREPARER_DEFAULT Create deep clone of the options object
OPTIONS_PREPARER_NO_CLONE Returns the options object without modification

We added the OptionsPreparer feature because it's really easy to accidentally embed a shared copy of the options in your service. That's probably not the behaviour you want, especially for the uniqueInstance() ServiceProvider builder.

OPTIONS_PREPARER_DEFAULT

/**
 * our default OptionsPreparer
 *
 * it returns a DEEP CLONE of the provided object
 */
export function OPTIONS_PREPARER_DEFAULT<T extends object>(options: T): T;

OPTIONS_PREPARPER_NO_CLONE

/**
 * an alternative OptionsPreparer
 *
 * it returns the same object instance that you call it with
 * (ie it DOES NOT create a clone of any kind)
 */
export function OPTIONS_PREPARER_NO_CLONE<T extends object>(options: T): T;

ServiceProvider Builders

ServiceProvider builders are functions that create ServiceProvider functions for you.

We've added them to cover common behaviours.

Table Of ServiceProvider Builders

function Description Supports ServiceActions
aliasFor() create an alias for an existing service NO
existingInstance() always returns the same instance of a given service NO
sharedInstance() builds a service using your factory, then returns the same instance of the service every time YES
uniqueInstance() calls your factory to get a new copy of the service every time YES

aliasFor()

/**
 * ServiceProvider builder - create an alias for an existing service
 *
 * The returned function will always call your container to get the
 * requested service.
 *
 * @param container
 *        your DI container
 * @param name
 *        the original name for the service
 */
export function aliasFor(container: AnyServiceManager, name: string): ServiceProvider<object>;

aliasFor() is a ServiceProvider builder. You give it your container, and the name of an existing service, and the returned ServiceProvider will always call container.get(name) to return the service.

It allows you to give services multiple names. For example:

const container = new ServiceManager({
    MyLogger: sharedInstance(...),
});
container.addProvider("logger", aliasFor(container, "MyLogger"));

// you can now do this, and it will be the same
// as calling `container.get("MyLogger")`
//
// `logger` will have the type `object`
const logger: container.get("logger");

Unfortunately, it currently isn't possible to get the return type information back to your code. You'll need to use type guards (recommended, but with a runtime cost) or typecasts (not recommended, as they override compile-time checks) to work with the resulting service.

existingInstance()

/**
 * ServiceProvider builder.
 *
 * The returned function will always return the given `service`.
 *
 * Use this to ensure that your DI container always returns the same instance
 * of a service.
 *
 * @param service
 *        the service that should always be returned
 */
export function existingInstance<T extends object>(service: T): ServiceProvider<T>;

existingInstance() is a ServiceProvider builder. You give it an instance of a service, and the returned ServiceProvider will always return that instance when called.

It's used internally by the sharedInstance() ServiceProvider builder. sharedInstance() creates the new service, then caches that service in your DI container by replacing itself with the existingInstance() function instead.

It's part of the public API. You're welcome to use it in your own code.

sharedInstance()

/**
 * returns a ServiceProvider
 *
 * the returned function calls the provided `factory` to create the new
 * service, and then makes sure that the same instance of the service is
 * returned in future
 *
 * internally, it does this by replacing itself in the DI container with
 * another ServiceProvider
 *
 * this guarantees that your `factory` is only called once, and it is only
 * called the first time that someone tries to get this service from
 * the DI container
 *
 * @param container
 *        your DI container
 * @param serviceName
 *        the name that the service will be registered under in
 *        the DI container
 * @param factory
 *        the function that will build the service
 * @param options
 *        a list of options to pass into the factory
 * @param optsPreparer
 *        a function to help prefer the options before they are passed
 *        into the factory
 *        the default function will create a DEEP CLONE of the options
 * @param postInitActions
 *        a list of functions to run after the factory has been called
 */
export function sharedInstance<T extends object, O extends object = object>(
    container: AnyServiceManager,
    serviceName: string,
    factory: ServiceProducer<T, O>,
    options: O,
    optsPreparer: OptionsPreparer<O> = OPTIONS_PREPARER_DEFAULT,
    postInitActions: Array<ServiceAction<T>> = [],
): ServiceProvider<T>;

sharedInstance() is a ServiceProvider builder. It uses the provided factory to build the service. No matter how many times you get the service from your DI container, it only calls the factory once.

This is the classic behaviour of most DI containers.

// create an empty DI container
const container = new ServiceContainer({});

// register a "logger" service with the DI container
container.addProvider("logger", sharedInstance(container, "logger", myLoggerFactory, {}));

// at this point, `myLoggerFactory()` has NOT been called

const logger = container.get("logger");

// at this point, `myLoggerFactory()` HAS been called
// it won't get called again, even if you do:
const logger2 = container.get("logger");

By default, sharedInstance() passes a clone of the options to your factory. This is so that you don't accidentally embed shared objects in your service. You can change this behaviour by passing OPTIONS_PREPARER_NO_CLONE in as the optsPreparer parameter.

uniqueInstance()

/**
 * returns a ServiceProvider
 *
 * the returned function calls the provided `factory` to create the new
 * service each and every time
 *
 * use this for when every caller should get a different instance of
 * the service
 *
 * @param container
 *        your DI container
 * @param serviceName
 *        the name that the service will be registered under in
 *        the DI container
 * @param factory
 *        the function that will build the service
 * @param options
 *        a list of options to pass into the factory
 * @param optsPreparer
 *        a function to help prefer the options before they are passed
 *        into the factory
 *        the default function will create a DEEP CLONE of the options
 * @param postInitActions
 *        a list of functions to run after the factory has been called
 */
export function uniqueInstance<T extends object, O extends object>(
    container: AnyServiceManager,
    requestedName: string,
    factory: ServiceProducer<T, O>,
    options: O,
    optsPreparer: OptionsPreparer<O> = OPTIONS_PREPARER_DEFAULT,
    postInitActions: Array<ServiceAction<T>> = [],
): ServiceProvider<T>;

uniqueInstance() is a ServiceProvider builder. It uses the provided factory to build the service, everytime someone gets the service from your DI container.

// create an empty DI container
const container = new ServiceContainer({});

// register a "logger" service with the DI container
container.addProvider("logger", uniqueInstance(container, "logger", myLoggerFactory, {}));

// at this point, `myLoggerFactory()` has NOT been called

const logger = container.get("logger");

// at this point, `myLoggerFactory()` HAS been called

const logger2 = container.get("logger");

// and now `myLoggerFactory()` HAS been called again

By default, uniqueInstance() passes a clone of the options to your factory. This is so that you don't accidentally embed shared objects in your service. You can change this behaviour by passing OPTIONS_PREPARER_NO_CLONE in as the optsPreparer parameter.

Errors

DependencyNotFoundError

export interface DependencyNotFoundExtraData {
    logsOnly: {
        serviceName: string;
    };
}

export class DependencyNotFoundError extends AppError {
    public constructor(params: DependencyNotFoundExtraData & AppErrorParams);
}

DependencyNotFoundError is a throwable / catchable Javascript Error.

NPM Scripts

npm run clean

Use npm run clean to delete all of the compiled code.

npm run build

Use npm run build to compile the Typescript into plain Javascript. The compiled code is placed into the lib/ folder.

npm run build does not compile the unit test code.

npm run test

Use npm run test to compile and run the unit tests. The compiled code is placed into the lib/ folder.

npm run cover

Use npm run cover to compile the unit tests, run them, and see code coverage metrics.

Metrics are written to the terminal, and are also published as HTML into the coverage/ folder.