Discovery Service

Scan every singleton provider for decorator metadata at startup - the foundation of ambient discovery in MiiaJS.

DiscoveryService lets a provider walk the DI container at startup and find every method decorated with a given symbol - across every other provider, controller, and guard in the app. You write @Cron('* * * * *'), @QueueHandler('emails'), or @On('user.created') on a method, and a discovery-aware service finds it automatically. No manual register([...]) list, no central registry to keep in sync.

This is the same mechanism the framework uses internally for @Get/@Post route discovery on controllers - DiscoveryService exposes it as a public API so userland packages and your own code can build the same pattern.

Mental model

The whole story is three steps:

  1. A custom method decorator writes metadata to Symbol.metadata[KEY] on the class at definition time. Each method that carries the decorator pushes one entry into an array under the same symbol key.
  2. The framework calls onReady() on every singleton during app.init(), after every onInit() has completed. By this point every provider in the container has a fully-initialized instance, regardless of registration order.
  3. A singleton holding inject(DiscoveryService) calls getMethodsWithMeta(KEY) in its onReady(). It receives a flat list of { instance, methodName, metadata } tuples - one per decorated method across the whole container - ready to bind handlers, register subscriptions, or whatever the discovery-aware service needs to do.

This is exactly how @miiajs/messaging wires up @On handlers under the hood, and it's how any future scheduler, queue worker, or metrics package will work too.

DiscoveryService API

Inject DiscoveryService like any other provider, then call one of its two methods inside an onReady() hook.

import { DiscoveryService, Injectable, inject } from '@miiajs/core'

@Injectable()
class MyDiscoveryService {
  private discovery = inject(DiscoveryService)

  async onReady() {
    const singletons = this.discovery.getSingletons()
    // ... or scan for a specific decorator key
  }
}
MethodReturnsDescription
getSingletons()Array<{ ctor, instance }>Every singleton currently in the container - providers, controllers, and guards that have been instantiated. Useful for raw inspection.
getMethodsWithMeta<T>(key)DiscoveredMethod<T>[]Scans every singleton for the given metadata key and returns one entry per discovered method. The primary API for ambient discovery.

Transient and request-scoped providers are deliberately excluded - they have no stable instance to bind a handler to. If you need discovery on those, restructure as a singleton.

The shape returned by getMethodsWithMeta():

interface DiscoveredMethod<T extends DiscoverableMethodMeta> {
  instance: object
  ctor: Constructor
  methodName: string
  metadata: T
}

Bind a handler with (instance as any)[methodName].bind(instance) and you have a callable function that preserves this.

The DiscoverableMethodMeta convention

getMethodsWithMeta() doesn't know the shape of your metadata - it relies on a single contract: the metadata stored under key must be an array of objects, and each object must include a handlerName: string field. The framework uses handlerName to bind the discovered metadata back to the actual method on the instance.

interface DiscoverableMethodMeta {
  handlerName: string
}

Everything else in the metadata object is yours to define. A minimal custom decorator looks like this:

import { createMethodDecorator, pushMeta } from '@miiajs/core'

const METRIC = Symbol('metric')

interface MetricMeta {
  handlerName: string
  name: string
}

export const Metric = createMethodDecorator<[name: string]>(
  (_target, ctx, name) => {
    pushMeta(ctx.metadata!, METRIC, {
      handlerName: ctx.name as string,
      name,
    } satisfies MetricMeta)
  },
)

Use ctx.name from the TC39 decorator context to capture the method name automatically. The user just writes @Metric('signups') async onSignup() {} and the convention is satisfied.

The onReady lifecycle hook

onReady() is the third provider lifecycle hook, called after every onInit() has completed. It exists specifically to give discovery-aware services a safe scanning point - by the time any onReady() runs, every singleton in the container is fully initialized, so a single sweep through getMethodsWithMeta() will find every decorated method without race conditions.

Two caveats worth internalising:

  • Don't emit or trigger work from inside onInit() or onReady(). The three-phase ordering only guarantees that all singletons exist before any onReady() runs - it does not guarantee an order between onReady() calls themselves. If your MyDiscoveryService.onReady() runs before OtherService.onReady() and OtherService.onReady() synchronously fires an event, the subscription MyDiscoveryService is supposed to wire might not be installed yet. Lifecycle hooks should scan and subscribe, not trigger work. Real work happens later - in request handlers, timers, or external triggers.
  • onReady is singleton-only, same as onInit and onDestroy. Transient and request-scoped providers do not run lifecycle hooks at all.

Complete example: a tiny event emitter

Here's the entire DiscoveryService pattern in one example: a 30-line in-process event emitter built on pure @miiajs/core, with no ecosystem dependencies. This is exactly the pattern @miiajs/messaging implements - with retry, DLQ, and pluggable transports layered on top.

import {
  createMethodDecorator,
  DiscoveryService,
  Injectable,
  inject,
  Module,
  pushMeta,
  type DiscoverableMethodMeta,
} from '@miiajs/core'

// 1. The decorator
const FIRE = Symbol('fire')

interface FireMeta extends DiscoverableMethodMeta {
  topic: string
}

const Fire = createMethodDecorator<[topic: string]>((_target, ctx, topic) => {
  pushMeta(ctx.metadata!, FIRE, {
    handlerName: ctx.name as string,
    topic,
  } satisfies FireMeta)
})

// 2. The discovery-aware bus
type Handler = (payload: unknown) => void | Promise<void>

@Injectable()
class MiniEventBus {
  private discovery = inject(DiscoveryService)
  private handlers = new Map<string, Handler[]>()

  async onReady() {
    for (const { instance, methodName, metadata } of this.discovery.getMethodsWithMeta<FireMeta>(FIRE)) {
      const list = this.handlers.get(metadata.topic) ?? []
      list.push(((instance as any)[methodName] as Handler).bind(instance))
      this.handlers.set(metadata.topic, list)
    }
  }

  async emit(topic: string, payload: unknown) {
    for (const fn of this.handlers.get(topic) ?? []) await fn(payload)
  }
}

// 3. A provider with a discovered handler
@Injectable()
class NotificationService {
  @Fire('user.created')
  async sendWelcome(payload: unknown) {
    console.log('welcome:', payload)
  }
}

// 4. Wire everything up
@Module({ providers: [MiniEventBus, NotificationService] })
class AppModule {}

After app.init() returns, MiniEventBus.onReady() has already scanned every singleton, found NotificationService.sendWelcome via its @Fire('user.created') metadata, and subscribed it to the 'user.created' topic. Calling bus.emit('user.created', { id: 1 }) from anywhere in the app fires the handler. No registration list, no manual wiring - every @Fire-decorated method on every singleton just works.

Discovering controllers

DiscoveryService also powers class-level discovery. Every controller in the app carries a RESOLVED_PREFIX metadata entry - the fully-joined path under which its routes are served, including the prefix of every module it was imported through. Filter getSingletons() by presence of that key and you have a live map of controllers to their routing prefixes.

import {
  DiscoveryService,
  Injectable,
  RESOLVED_PREFIX,
  Router,
  getMeta,
  inject,
  type Constructor,
} from '@miiajs/core'

@Injectable()
export class RouteInspector {
  private discovery = inject(DiscoveryService)
  private router = inject(Router)

  async onReady() {
    for (const { ctor } of this.discovery.getSingletons()) {
      const prefix = getMeta<string>(ctor, RESOLVED_PREFIX)
      if (prefix === undefined) continue
      console.log(`${(ctor as Constructor).name} -> /${prefix}`)
    }
  }
}

This is the pattern @miiajs/swagger uses internally to walk every controller and emit the OpenAPI spec. Any package that needs to enumerate live routes - metrics exporters, route tables, tracing instrumentation - can use the same two-line filter.

Registering routes in onReady

Router is injectable too, so a discovery-aware provider can both scan for metadata and register brand-new routes based on what it finds - all inside onReady(), before the HTTP server accepts its first request.

::warning Routes added in onReady skip per-route compilation router.compileAll() runs in the compile phase, which is before bootstrapAll(). Routes you register from inside an onReady() hook never see that pass, so they never receive a per-route compiledPipeline - no controller-level guards or @Use() middleware is attached.

They do still run inside the global pipeline (app.use() wraps the entire dispatch), so CORS, request logging, and request-id middleware apply normally. What's missing is per-route middleware and global guards - which is exactly what you want for a spec endpoint like /docs/json, but worth knowing about.

To opt out explicitly and guarantee that a late-registered route stays guard-free even after a future app.addRoute() triggers recompilation, pass { skipGlobalGuards: true }:

this.router.add('GET', '/docs/json', handler, { skipGlobalGuards: true })

This is the pattern @miiajs/swagger uses internally. Global middleware still applies; only global guards are bypassed. ::

Exports

import {
  DiscoveryService,
  type DiscoverableMethodMeta,
  type DiscoveredMethod,
} from '@miiajs/core'