Skip to content
GitHub

Services and injection

MeoCord 4.1 · Structuring your app · page 19 of 41 · since 4.0.0

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

services/greeting.service.ts
@Service()
export class GreetingService {
  buildGreeting(name: string): string {
    return `Hello, ${name}!`
  }
}
controllers/slash/greeting.slash.controller.ts
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;
  • HandlerRegistry and ShardContext from meocord/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:

app-with-services.ts
@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:

services/status.service.ts
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:

app-with-providers.ts
@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:

services/weather/weather.source.ts
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.

services/weather/weather.source.ts
// 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
  }
}
services/weather/weather.providers.ts
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],
  },
]
ShapeWhat'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:

services/rewards/daily-reward.service.ts
// 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:

services/rewards/daily-reward.service.spec.ts
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's inject: 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.

services/status.service.spec.ts
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:

services/weather/weather.controller.spec.ts
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/eslint warns about import cycles as you write them, in a project with eslint-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. Logger and errors such as UserError aren't injected at all: create them with new.

  • 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, and Translator when the app configures i18n.

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.