Skip to content

Channels

Channels are where side effects live. They subscribe to a namespaced event, do the work, and optionally return a result.

A channel is a class that subscribes to events and performs side effects — persistence, outbound HTTP, cache invalidation, writing a response. It is the reason a controller stays free of business logic.

import { Channel, On } from '@glandjs/common'
@Channel('product')
export class ProductChannel {
@On('viewed')
onViewed(payload: { id: string }) {
console.log(`viewed ${payload.id}`)
}
}

@Channel(namespace) prefixes every @On() in the class. With namespace = 'product' and @On('viewed'), the public event name is product:viewed.

The namespace is optional. @Channel() with no argument registers handlers under their bare @On() name.

@Channel @On Public event name
@Channel('db') @On('product:create') db:product:create
@Channel('db') @On('create') db:create
@Channel() @On('ping') ping
@Channel('a:b') @On('c:d') a:b:c:d

A channel handler’s return value is what ctx.call() resolves to. Declare it in the event map with IOEvent<Payload, Return>.

src/shared/events.interface.ts
import { IOEvent } from '@glandjs/events'
export interface EventTypes {
'db:product:create': IOEvent<NewProduct, Promise<Product>>
'db:product:all': IOEvent<Record<string, never>, Promise<Product[]>>
'product:viewed': { id: string } // plain type -> return is void
}
src/common/db.channel.ts
import { Channel, On } from '@glandjs/common'
@Channel('db')
export class Database {
@On('product:create')
async createProduct(input: NewProduct): Promise<Product> {
return this.repository.insert(input)
}
}
// In a controller — the result is typed as Promise<Product>
const product = await ctx.call('db:product:create', { name, price })

A plain (non-IOEvent) entry always has a void return, which is the right choice for fire-and-forget side effects.

Several channels can answer the same event. ctx.call() returns the first registered handler’s result; ctx.call(event, payload, 'all') returns an array of every result, in registration order.

// Every channel that answers 'product:created' runs.
emitter.emit('product:created', { id })
// Or collect them all.
const results = await ctx.call('product:created', { id }, 'all')

A handler receives exactly one argument: the payload. Anything else the controller needs travels inside that payload, or on ctx.

@Channel('audit')
export class AuditChannel {
@On('product:created')
async record(payload: { id: string; ctx?: unknown }) {
await this.sink.write(payload)
}
}

Carrying the context in the payload is how you let a channel finish the reply:

export interface EventTypes {
'response:send': { ctx: ExpressContext<EventTypes>; body: unknown }
}
@Channel('response')
export class ResponseChannel {
@On('send')
async send({ ctx, body }: EventTypes['response:send']) {
ctx.state.sentAt = Date.now()
return ctx.send(body, 200)
}
}
  • A channel must be listed in a module’s channels array.
  • Like controllers, only own prototype methods are discovered. Do not rely on inherited @On() handlers.
  • One handler per event name per class — the metadata is keyed by method name on the prototype, so two methods cannot both answer the same event.

Channels are instantiated by the container, so constructor parameters are resolved like any other provider.

import { Channel, On } from '@glandjs/common'
import { Inject } from '@glandjs/common'
import { Repository } from './repository'
@Channel('db')
export class Database {
constructor(private readonly repository: Repository) {}
@On('product:all')
all() {
return this.repository.findAll()
}
}

When a parameter’s type is erased by TypeScript — an interface, a union, a primitive — the container cannot infer it, so name the token explicitly.

export const LOGGER = Symbol('LOGGER')
@Channel('audit')
export class AuditChannel {
constructor(@Inject(LOGGER) private readonly logger: Logger) {}
}

See Dependency injection.

The broker also exposes a channel() method that returns a live channel object for namespacing at runtime — useful in tests and in code that composes event names dynamically.

const db = broker.channel('db')
db.on('product:all', () => products)
await db.call('product:all', {})
db.channel('product').emit('created', { id }) // nested namespaces

onChannelInit() runs after every channel is bound — the first hook at which a channel can safely call another one.

@Channel('db')
export class Database implements OnChannelInit {
async onChannelInit() {
await this.connect()
}
}