Lewati ke konten utama
Versi: 4.x

Services & Dependency Injection

foal generate service my-service
export class MyService {

}

Description

Services are useful to organize your code in domains. They can be used in a wide variety of situations: logging, interaction with a database, calculations, communication with an external API, etc.

Architecture

Basically, a service can be any class with a narrow and well defined purpose. They are instantiated as singletons.

Use & Dependency Injection

You can access a service from a controller using the @dependency decorator.

Example:

import { dependency, Get, HttpResponseOK } from '@foal/core';

class Logger {
log(message: string) {
console.log(`${new Date()} - ${message}`);
}
}

class AppController {
@dependency
logger: Logger

@Get('/')
index() {
this.logger.log('index has been called!');
return new HttpResponseOK('Hello world!');
}

}

When instantiating the controller, FoalTS will provide the service instance. This mechanism is called dependency injection and is particularly interesting in unit testing (see section below).

In the same way, you can access a service from another service.

Example:

import { dependency } from '@foal/core';

class MyService {
run() {
console.log('hello world');
}
}

class MyServiceA {
@dependency
myService: MyService;

foo() {
this.myService.run();
}
}

Dependencies are injected after the instantiation of the controller/service. So they will appear as undefined if you try to read them inside a constructor. If you want to access the dependencies when initializing a controller/service, refer to the boot method.

Circular dependencies are not supported. In most cases, when two services are dependent on each other, the creation of a third service containing the functionalities required by both services solves the dependency problem.

Testing services

Services are classes and so can be tested as is.

Example:

// calculator.service.ts
export class CalculatorService {
sum(a: number, b: number): number {
return a + b;
}
}
// calculator.service.spec.ts
import { strictEqual } from 'assert';
import { CalculatorService } from './calculator.service';

it('CalculatorService', () => {
const service = new CalculatorService();
strictEqual(service.sum(1, 2), 3);
});

Services (or Controllers) with Dependencies

If your service has dependencies, you can use the createService function to instantiate the service with them.

Example:

// weather.service.ts
import { dependency } from '@foal/core';

class ConversionService {
celsiusToFahrenheit(temperature: number): number {
return temperature * 9 / 5 + 32;
}
}

class WeatherService {
temp = 14;

@dependency
conversion: ConversionService;

getWeather(): string {
const temp = this.conversion.celsiusToFahrenheit(this.temp);
return `The outside temperature is ${temp} °F.`;
}
}
// weather.service.spec.ts
import { strictEqual } from 'assert';
import { createService } from '@foal/core';
import { WeatherService } from './weather.service';

it('WeatherService', () => {
const service = createService(WeatherService);

const expected = 'The outside temperature is 57.2 °F.';
const actual = service.getWeather();

strictEqual(actual, expected);
});

A similar function exists to instantiate controllers with their dependencies: createController.

In many situations, it is necessary to mock the dependencies to truly write unit tests. This can be done by passing a second argument to createService (or createController).

Example:

// detector.service.ts
import { dependency } from '@foal/core';

class TwitterService {
fetchLastTweets(): { msg: string }[] {
// Make a call to the Twitter API to get the last tweets.
return [];
}
}

class DetectorService {
@dependency
twitter: TwitterService;

isFoalTSMentionedInTheLastTweets() {
const tweets = this.twitter.fetchLastTweets();
if (tweets.find(tweet => tweet.msg.includes('FoalTS'))) {
return true;
}
return false;
}
}
// detector.service.spec.ts
import { strictEqual } from 'assert';
import { createService } from '@foal/core';
import { DetectorService } from './weather.service';

it('DetectorService', () => {
const twitterMock = {
fetchLastTweets() {
return [
{ msg: 'Hello world!' },
{ msg: 'I LOVE FoalTS' },
]
}
}
const service = createService(DetectorService, {
twitter: twitterMock
});

const actual = service.isFoalTSMentionedInTheLastTweets();

strictEqual(actual, true);
});

Injecting other Instances

To manually inject instances into the identity mapper, you can also provide your own ServiceManager to the createApp function (usually located at src/index.ts).

src/index.ts (example)

import { createApp, ServiceManager } from '@foal/core';
import { DataSource } from 'typeorm';

import { AppController } from './app/app.controller';
import { dataSource } from './db';

async function main() {
await dataSource.initialize();

const serviceManager = new ServiceManager();
serviceManager.set(DataSource, dataSource);

const app = await createApp(AppController, {
serviceManager
});

// ...
}

// ...

Note: Interfaces cannot be passed to the set method.

src/controllers/api.controller.ts (example)

import { dependency, Get, HttpResponseOK } from '@foal/core';
import { DataSource } from 'typeorm';

import { Product } from '../entities';

class ApiController {

@dependency
dataSource: DataSource;

@Get('/products')
async readProducts() {
const products = await this.dataSource.getRepository(Product).find();
return new HttpResponseOK(products);
}

}

Abstract Services

If you want to use a different service implementation depending on your environment (production, development, etc.), you can use an abstract service for this.

logger.service.ts

export abstract class Logger {
static concreteClassConfigPath = 'logger.driver';
static concreteClassName = 'ConcreteLogger';

abstract log(str: string): void;
}

Warning: the two properties must be static.

console-logger.service.ts (concrete service)

export class ConsoleLogger extends Logger {
log(str: string) {
console.log(str);
}
}

export { ConsoleLogger as ConcreteLogger };
logger:
driver: ./app/services/console-logger.service

The configuration value can be a package name or a path relative to the src/ directory. If it is a path, it must start with ./ and must not have an extension (.js, .ts, etc).

a random service

export class Service {
@dependency
logger: Logger;

// ...
}

Default Concrete Services

An abstract service can have a default concrete service that is used when no configuration value is specified or when the configuration value is local.

import { join } from 'path';

export abstract class Logger {
static concreteClassConfigPath = 'logger.driver';
static concreteClassName = 'ConcreteLogger';
static defaultConcreteClassPath = join(__dirname, './console-logger.service');

abstract log(str: string): void;
}

Usage with Interfaces and Generic Classes

Interfaces and generic classes can be injected using strings as IDs. To do this, you will need the @Dependency decorator.

src/services/logger.interface.ts

export interface ILogger {
log(message: any): void;
}

src/services/logger.service.ts

import { ILogger } from './logger.interface';

export class ConsoleLogger implements ILogger {
log(message: any): void {
console.log(message);
}
}

src/index.ts (example)

import { createApp, ServiceManager } from '@foal/core';

import { AppController } from './app/app.controller';
import { Product } from './app/entities';
import { ConsoleLogger } from './app/services';
import { dataSource } from './db';

async function main() {
await dataSource.initialize();
const productRepository = dataSource.getRepository(Product);

const serviceManager = new ServiceManager()
.set('product', productRepository)
.set('logger', new ConsoleLogger());

const app = await createApp(AppController, {
serviceManager
});

// ...
}

// ...

src/controllers/api.controller.ts (example)

import { Dependency, Get, HttpResponseOK } from '@foal/core';
import { Repository } from 'typeorm';

import { Product } from '../entities';
import { ILogger } from '../services';

export class ApiController {

@Dependency('product')
productRepository: Repository<Product>;

@Dependency('logger')
logger: ILogger;

@Get('/products')
async readProducts() {
const products = await this.productRepository.find();
this.logger.log(products);
return new HttpResponseOK(products);
}

}

Accessing the ServiceManager

In very rare situations, you may want to access the ServiceManager which is the identity mapper that contains all the service instances.

import { dependency, Get, HttpResponseOK, ServiceManager } from '@foal/core';

class MyService {
foo() {
return 'foo';
}
}

class MyController {
@dependency
services: ServiceManager;

@Get('/bar')
bar() {
const msg = this.services.get(MyService).foo();
return new HttpResponseOK(msg);
}
}