Services and injection
Share clients, data and logic between handlers with services MeoCord creates and passes in for you.
You'll learn
- Write a service and inject it into a controller
- Provide values, instances and factories under a token
- Keep a shared service safe when calls overlap
- Test a service with or without a module
Before this
A service is a class marked with @Service(). It holds what handlers share: an API client,
a database connection, a cache, the logic a command calls. A controller, or another service, gets one by declaring
it in its constructor, and MeoCord passes it in.
When to use it
Move code into a service as soon as two handlers need it, or as soon as a handler does more than read the
interaction and answer it. A service takes plain values and returns plain values, so it can be tested with new and
called from a button, a command and a scheduled task alike.
Code used by one handler only can stay in that controller. A value that isn't a class, such as a settings object or a connection pool a library builds, is provided instead.
Example
@Service()
export class GreetingService {
buildGreeting(name: string): string {
return `Hello, ${name}!`
}
}import { type ChatInputCommandInteraction } from 'discord.js'
import { respond } from 'meocord/common'
import { Command, Controller, Cooldown } from 'meocord/decorator'
import { GreetingCommandBuilder } from '@src/controllers/slash/builders/greeting.builder'
import { GreetingService } from '@src/services/greeting.service'
@Controller()
export class GreetingSlashController {
constructor(private readonly greetingService: GreetingService) {}
@Command('greet', GreetingCommandBuilder)
@Cooldown({ uses: 3, seconds: 10 })
async greet(interaction: ChatInputCommandInteraction, { name }: { name: string }) {
await respond(interaction).send({ content: this.greetingService.buildGreeting(name) })
}
}The controller declares GreetingService in its constructor, and MeoCord creates one instance and passes it in.
Nothing has to be listed: a controller that injects a service is enough to bind it, and whatever that service
injects in turn.
How it works
When the bot starts, MeoCord builds a container from the app's controllers and everything they inject. Every controller and service is a singleton: one instance serves every call. A class's dependencies are created before it, so a constructor always receives instances that are ready.
A class can inject:
- your own services, and whatever they inject;
- the discord.js
Client, the one the bot logs in with; HandlerRegistryandShardContextfrommeocord/core, which list every handler and reach every shard;Translator, when the app configures localisation;- anything a provider supplies, under a class or a token.
Services nothing injects
A service that no controller depends on, but that must still exist, such as a scheduler or a queue consumer, is
listed in the app's services:
@MeoCord({
controllers: [GreetingSlashController],
// A controller that injects a service is enough to bind it; list only services nothing injects
services: [StatusService],
clientOptions: { intents: [GatewayIntentBits.Guilds] },
})
export default class App {}This one sets the bot's status once it's ready, through its onReady lifecycle hook:
import { ActivityType, Client } from 'discord.js'
import { Service } from 'meocord/decorator'
import { type OnReady } from 'meocord/interface'
import { GreetingService } from '@src/services/greeting.service'
// Nothing injects it, so the app lists it in `services`
@Service()
export class StatusService implements OnReady {
constructor(
private readonly client: Client,
private readonly greetings: GreetingService,
) {}
onReady() {
this.client.user?.setActivity(this.greetings.buildGreeting('everyone'), { type: ActivityType.Custom })
}
}Providers
Not everything a bot shares is a class it can construct itself. The app's
providers supply these, and classes inject them like any service:
@MeoCord({
controllers: [WeatherController],
providers: weatherProviders,
clientOptions: { intents: [GatewayIntentBits.Guilds] },
})
export default class App {}A value has no class to be injected by, so it gets a token. createToken makes one,
typed with what it provides, and @Inject(token) asks for it:
export interface WeatherSettings {
apiUrl: string
units: 'metric' | 'imperial'
}
// A value has no class to inject it by, so it gets a token, typed with what it provides
export const WEATHER_SETTINGS = createToken<WeatherSettings>('WeatherSettings')An abstract class is a token and a type at once, so a class that injects it needs no @Inject: the parameter's type
names it.
// An abstract class is a token and a type at once: whatever provides it is injected by the parameter's type
export abstract class WeatherSource {
abstract temperature(city: string): Promise<number>
}
export class HttpWeatherSource extends WeatherSource {
constructor(@Inject(WEATHER_SETTINGS) private readonly settings: WeatherSettings) {
super()
}
async temperature(city: string): Promise<number> {
const url = `${this.settings.apiUrl}?city=${encodeURIComponent(city)}&units=${this.settings.units}`
const body = (await (await fetch(url)).json()) as { temperature: number }
return body.temperature
}
}
// Every city at the same temperature: for development without an API, and for tests
export class FixedWeatherSource extends WeatherSource {
async temperature(): Promise<number> {
return 21
}
}export const weatherProviders: Provider[] = [
// A value, provided as it is
{
provide: WEATHER_SETTINGS,
useValue: { apiUrl: process.env.WEATHER_API_URL ?? '', units: 'metric' } satisfies WeatherSettings,
},
// A factory, given what `inject` names, in order: here it picks the source for the settings
{
provide: WeatherSource,
useFactory: (settings: WeatherSettings) =>
settings.apiUrl ? new HttpWeatherSource(settings) : new FixedWeatherSource(),
inject: [WEATHER_SETTINGS],
},
]| Shape | What's injected |
|---|---|
{ provide, useValue } | The value, as it is. |
{ provide, useClass } | One instance of that class, with its own dependencies injected. |
{ provide, useFactory } | What the factory returns, made once. It receives what inject lists, in order. |
A factory can be async. MeoCord awaits it, in dependency order, before the bot logs in, so a class that injects its value never sees a promise. The database recipe provides a connection pool this way.
Shared state and await
One instance serves every call, and calls interleave at each await. Two clicks on the same button can both pass a
check before either records its result. This service records the claim before it awaits the payment, so the second
click finds it, and undoes the record if the payment fails:
// Knows nothing of Discord: it takes a user id and answers whether the claim went through
@Service()
export class DailyRewardService {
private readonly claimedOn = new Map<string, string>()
constructor(private readonly wallet: WalletService) {}
async claim(userId: string, now = new Date()): Promise<boolean> {
const today = now.toISOString().slice(0, 10)
if (this.claimedOn.get(userId) === today) return false
// Marked before the await, so a second click arriving while credit() runs is refused
this.claimedOn.set(userId, today)
try {
await this.wallet.credit(userId, 100)
} catch (error) {
// Nothing was paid, so the claim may be tried again
this.claimedOn.delete(userId)
throw error
}
return true
}
}The test runs both claims at once:
describe('DailyRewardService', () => {
// A plain class: built with new, its dependency passed in by hand
const setup = () => {
const wallet = new WalletService()
return { wallet, rewards: new DailyRewardService(wallet) }
}
it('pays once a day, even for two claims made at the same moment', async () => {
const { wallet, rewards } = setup()
await expect(Promise.all([rewards.claim('111'), rewards.claim('111')])).resolves.toEqual([true, false])
expect(wallet.balance('111')).toBe(100)
await expect(rewards.claim('111', new Date(Date.now() + 24 * 60 * 60_000))).resolves.toBe(true)
})
it('lets a claim that failed to pay be tried again', async () => {
const { wallet, rewards } = setup()
const credit = wallet.credit.bind(wallet)
wallet.credit = async () => Promise.reject(new Error('database unavailable'))
await expect(rewards.claim('111')).rejects.toThrow('database unavailable')
wallet.credit = credit
await expect(rewards.claim('111')).resolves.toBe(true)
})
})Services around a handler
Guards, interceptors and exception filters inject services the same way:
- A guard is created for each call, so it may also inject
ExecutionContext. - An interceptor or a filter is one instance shared across calls, like a service. It holds no per-call state
and can't inject
ExecutionContext; it receives the context as an argument instead. - A service, a provided class or a factory provider is made once, so none of them can inject
ExecutionContext, or list it in a factory'sinject: MeoCord refuses it as the app is created, since it would keep the first call's context for every later one.
Testing a service
A service that takes plain values is tested with new. For one that injects others, MeoCordTestingModule builds a
container from the classes you give it, and binds every class they inject, as the app does. It binds no Discord
Client and no token, so a test provides those, and it replaces a class only where the test asks, with useValue.
import { Client } from 'discord.js'
import { createMockClient, MeoCordTestingModule } from 'meocord/testing'
import { describe, expect, it } from 'vitest'
import { StatusService } from '@src/services/status.service'
describe('StatusService', () => {
it('gets its dependencies injected, the Discord client among them', () => {
const client = createMockClient()
const module = MeoCordTestingModule.create({
providers: [
{ provide: StatusService, useClass: StatusService },
{ provide: Client, useValue: client },
],
}).compile()
module.get(StatusService).onReady()
expect(client.user?.setActivity).toHaveBeenCalledWith('Hello, everyone!', expect.anything())
})
})The testing module takes providers in the same shapes as the app, and a class that injects a token nothing provides fails when the module compiles, naming both:
describe('a token nothing provides', () => {
it('stops compile, naming the class and the token', () => {
expect(() => MeoCordTestingModule.create({ controllers: [WeatherController] }).compile()).toThrow(
"WeatherService: it injects Symbol(WeatherSettings), which nothing provides: add a provider for it to the testing module's providers.",
)
})
})Gotchas
Per-call state on the instance leaks between calls. Keep it in the handler's variables, or key it by user.
Two services that inject each other stop the bot. The one whose file loads second records the other's type before that class exists, and MeoCord names both:
text Notes: parameter 1 of its constructor has no runtime type, so it cannot be created. Usually Notes and a class it injects import each other (Reminders injects Notes), or the parameter is typed with an interface or an `import type`. Move what they both need into a third service, or inject the parameter with @Inject(token).Move what both need into a third service.
meocord/eslintwarns about import cycles as you write them, in a project witheslint-import-resolver-typescript; see Import cycles.A class without a decorator can't be injected if its constructor takes parameters. With no decorator on the class or on a parameter, TypeScript records none of their types, so the bot stops, naming the class:
text Notes: its constructor takes parameters, but Notes has no decorator, so TypeScript recorded none of their types and it cannot be created. Decorate it with @Service(), or give a class from a package a provider in @MeoCord({ providers }).A class from a package gets a provider instead.
Loggerand errors such asUserErroraren't injected at all: create them withnew.A factory that throws stops the bot before login, with the token and the error. So does a token provided twice, or one a class injects that nothing provides.
Providers that inject each other in a cycle stop the bot as it's created, classes among them included, naming the cycle:
text 'a' → 'b' → 'a': each is made before what injects it, so none of them can be made. Move what they share into a provider of its own.MeoCord's own tokens can't be provided:
Client,HandlerRegistry,ShardContext,CooldownStore, andTranslatorwhen the app configuresi18n.
Next steps
- Configuration: read settings once, in a service, rather than from
process.env. - Lifecycle hooks: start and stop what a service connects to.
- Mocks: stand in for a service in a test.