Skip to main content

Dependency Injection

While building an app, we may rely on many services to ship features. The dependent relationship between the services could be complex, especially for a cross-platform app.

Machinat has a built-in Dependency Injection system to help with that. Actually the whole framework is built upon the DI system.

Initiate Services

When you create and start a Machinat app, a set of services are initiated to make the app works. For example:

import Machinat from '@machinat/core';
import Http from '@machinat/http';
import Messenger from '@machinat/messenger';
import Telegram from '@machinat/telegram';
import { FileState } from '@machinat/dev-tools';
import FooService from './services/Foo';
import BarService from './services/Bar';

const app = Machinat.createApp({
platforms: [
Messenger.initModule({/*...*/}),
Telegram.initModule({/*...*/}),
],
modules: [
Http.initModule({/*...*/}),
FileState.initModule({/*...*/})
],
services: [
FooService,
BarService,
],
});
app.start();

Register Modules

The platforms and modules options add services for a particular platform or functionality. For example, Messenger.Bot service is added by the Messenger platform. The bot instance is then created when app.start().

Machinat is made with progressive framework paradigm. You can start with minimum modules and gradually add more when you need.

Check API references to see all the available modules.

Use Services

After the app is started, we can require services and use them like:

import Machinat from '@machinat/core';
import Messenger from '@machinat/messenger';
import Telegram from '@machinat/telegram';

const app = Machinat.createApp({/* ... */});

app.start().then(() => {
const [messengerBot, telegramBot] = app.useServices([
Messenger.Bot,
Telegram.Bot,
]);
// use the bots ...
});

app.useServices() accepts an array of service interfaces and returns the service instances. Note that it should only be called after app.start() is finished.

Service Container

We can also require services as the params of a function, that is a Service Container. The makeContainer decorator annotates a JavaScript function as a container. Like:

import { makeContainer } from '@machinat/core';
import FooService from './services/Foo';
import BarService from './services/Bar';

const fooBarContainer = makeContainer({
deps: [FooService, BarService]
})((foo, bar) => {
// do something with foo & bar ...
});

In the example above, fooBarContainer function requires two dependencies FooService and BarService. The service instances foo and bar will be injected into the container when it's triggered by the app.

Container Handler

The app.onEvent and app.onError methods can accept a container of the handler. For example:

import { makeContainer, BasicProfiler } from '@machinat/core';

app.onEvent(
makeContainer({ deps: [BasicProfiler] })(
(profiler) =>
async ({ event, reply } ) => {
const profile = await profiler.getUserProfile(event.user);
await reply(<p>Hello {profile.lastName}!</p>)
}
)
);

The container receives a BasicProfiler instance and returns an ordinary handler function. When an event is popped, the contained handler receives event context as usual. Then it can use the required profiler for replying.

Many Machinat APIs support using a container as the callback handler, like @machinat/script and @machinat/stream. We'll introduce them later.

Optional Requisition

By default it throws an error if an unregistered dependency is required. You can mark a dependency as optional to prevent it.

makeContainer({
deps: [{ require: FooService, optional: true }]
})((foo) => (ctx) => {
// foo would be null if not registered
if (foo) {
// ...
}
})

Standard Services

Machinat defines some standard services which are commonly used while making conversational apps. Like recognizing intent, fetching an user’s profile and accessing chat state.

Here is an example to put them together:

import {
makeContainer,
IntentRecognizer,
BasicProfiler,
StateController,
} from '@machinat/core';

app.onEvent(
makeContainer({
deps: [IntentRecognizer, BasicProfiler, StateController],
})(
(recognizer, profiler, stateController) =>
async (context) => {
const { bot, event } = context;
const { channel, user } = event;

if (event.type === 'text') {
const intent = await recognizer.detectText(channel, event.text);

if (intent.type === 'hello') {
const profile = await profiler.getUserProfile(user);
await bot.render(channel, `Hello ${profile?.name || 'there'}!`);

await stateController
.channelState(channel)
.update('hello_count', (count = 0) => count + 1);
}
}
}
)
);

Here are the list of the standard services:

Register Services

We can also register individual service in the services option:

import MessengerAssetsManager from '@machinat/messenger/asset';
import FooService from './foo';

Machinat.createApp({
platforms: [/*...*/],
modules: [/*...*/],
services: [
MessengerAssetsManager,
FooService,
],
})

The services then can be required via app.useServices() or a container.

const [foo, assets] = app.useServices([
FooService,
MessengerAssetsManager,
]);

makeContainer({ deps: [FooService, MessengerAssetsManager] })(
(foo, assetsManager) =>
(ctx) => {
// ...
}
)

Providing Services

Class Provider

Despite the standard services, it's easy to make your own ones. You only have to mark a normal class as a service provider. For example:

import { makeClassProvider } from '@machinat/core';
import BeerService from './Beer';

class BarService {
constructor(beerService) {
this.beerService = beerService;
}

serve(drink) {
if (drink !== '🍺') {
return null;
}
return this.beerService.pour();
}
}

export default makeClassProvider({
lifetime: 'singleton',
deps: [BeerService],
})(BarService);

makeClassProvider(options)(Klass) decorator annotates a class constructor as a service. It takes the following options:

  • deps - required, the dependencies of the provider.
  • lifetime - optional, the lifetime of the service, has to be one of 'singleton', 'scoped' or 'transient'. Default to 'singleton'. Check the service lifetime section.
  • name - optional, the name of the provider, default to the constructor name.
  • factory - optional, the factory function to create the provider instance, default to (...deps) => new Klass(...deps).

Now we can register the service and use it like:

const app = Machinat.createApp({
services: [BeerService, BarService],
});

app.start().then(() => {
const [bar] = app.useServices([BarService]);
return bar.serve('🍺');
});

Factory Provider

We can make a provider with another style: a factory function. For example:

import { makeFactoryProvider } from '@machinat/core';
import BeerService from './Beer';

const useBar = (beerService) => (drink) =>
drink === '🍺' ? beerService.pour() : null;

export default makeFactoryProvider({
lifetime: 'transient',
deps: [BeerService],
})(BarService);

makeFactoryProvider(options)(factoryFn) decorator annotates a factory function as a service. The factory function receives the dependencies like a container and returns the service instance (which can be a function). It takes the following options:

  • deps - required, the dependencies of the provider.
  • lifetime - optional, the lifetime of the service, has to be one of 'singleton', 'scoped' or 'transient'. Default to 'transient'. Check the service lifetime section.
  • name - optional, the name of the provider, default to the factory function name.

Then we can register and use the service like:

const app = Machinat.createApp({
services: [BeerService, useBar],
});

app.start().then(() => {
const [getDrink] = app.useServices([useBar]);
return getDrink('🍺');
});

Interface and Binding

The provider is also a service interface so we can require it as a dependency. When we register the provider, it provides the service instance for itself.

Machinat.createApp({
services: [MyService],
});
// is equivalent to
Machinat.crrateApp({
services: [
{ provide: MyService, withProvider: MyService },
],
});

The binding between a service interface and a service provider is created when we register a service. The interface can be bound to another provider, so we can swap the service implementation.

const app = Machinat.crrateApp({
services: [
{ provide: MyService, withProvider: AnotherService },
],
});

const [myService] = app.useServices([MyService]);
console.log(myService instanceof AnotherService); // true

Pure Interface

Besides the provider itself, we can create an interface with makeInterface for binding different implementations. For example:

import { makeInterface } from '@machinat/core';
import MyServiceImpl from './MyServiceImpl';

const MyService = makeInterface({ name: 'MyService' });

Machinat.crrateApp({
services: [
{ provide: MyService, withProvider: MyServiceImpl },
],
});

const [myService] = app.useServices([MyService]);
console.log(myService instanceof MyServiceImpl); // true

Provide a Value

An interface can be bound with the value directly instead of a provider. This is especially useful to pass configurations in a decoupled way:

const BotName = makeInterface({ name: 'BotName' })

Machinat.crrateApp({
services: [
{ provide: BotName, withValue: 'David' },
],
});

const [botName] = app.useServices([BotName]);
console.log(botName); // David

Multiple Bindings

An interface can also accept multiple implementations with multi option. When we require a multi interface, a list of services is resolved. Like this:

import { makeInterface } from '@machinat/core';

const MyFavoriteFood = makeInterface({ name: 'MyService', multi: true })

Machinat.crrateApp({
services: [
{ provide: MyFavoriteFood, withValue: '🌮' },
{ provide: MyFavoriteFood, withValue: '🥙' },
],
});

const [dinner] = app.useServices([MyFavoriteFood]);
console.log(dinner); // ['🌮', '🥙']

Service Lifetime

Service lifetime defines how the service instances are created in the app. There are three types of lifetime:

  • 'transient' - every time the service is required.
  • 'scoped' - only once per service scope.
  • 'singleton' - only once in the app when app.start().

A service scope is an abstract period for handling an event or dispatching the messages. A service with 'scoped' lifetime is created lazily in a scope, and the instance will be cached for later requisition.