Skip to content

Services ​

Files in services/ are injected into ctx under their filename. They are singletons, created once at boot, and available to handlers, middlewares, WebSocket handlers and to each other.

ts
// src/services/auth.ts
import { service, error } from "clovejs"

export default service(async (ctx, { onDestroy }) => {
  ctx.logger.info("auth service initialized")
  let logins = 0

  onDestroy(async () => {
    ctx.logger.info("auth service destroyed")
  })

  return {
    async login({ username, password }: LoginParams) {
      const user = await ctx.db.user.find({
        username,
        password: ctx.users.hash(password),
      })
      if (!user) {
        throw error(401, { message: "Username / password pair mismatch" })
      }
      logins++
      return { user, token: sign(user) }
    },
  }
})

That file is now ctx.auth, typed, everywhere.

The factory ​

ts
service((ctx, hooks) => T | Promise<T>)
  • ctx — the same DI context handlers see, so a service can depend on other services and on di/ values.
  • hooks.onDestroy(fn) — register teardown, run in reverse registration order when the scope is disposed.

Whatever the factory returns becomes the value on ctx. It does not have to be an object — a service may return a function, a class instance, or a client library's handle.

Private state lives in the closure ​

Anything declared inside the factory but not returned is private. There is no visibility modifier to remember and no #field syntax to reach for:

ts
export default service(async (ctx) => {
  const cache = new Map<string, User>()      // private

  return {
    get(id: string) {                        // public
      return cache.get(id)
    },
  }
})

Private helpers ​

To share logic between methods without exposing it on the service, declare a plain function in the closure:

ts
export default service(async (ctx) => {
  function hash(password: string) {
    return createHash("sha256").update(password).digest("hex")
  }

  return {
    verify(password: string, digest: string) {
      return hash(password) === digest        // private to the closure
    },
  }
})

Initialisation order ​

You do not declare one. Services and di/ values resolve on demand: reading ctx.db from inside the auth factory resolves db first. All singletons are fully resolved before the server accepts traffic, so by the time a request arrives, every ctx.<service> read is synchronous.

Inside a factory, though, await anything you depend on — see resolution rules.

Teardown ​

ts
export default service(async (ctx, { onDestroy }) => {
  const consumer = await connectQueue(ctx.config.amqp)
  onDestroy(async () => consumer.close())
  return consumer
})

onDestroy callbacks run when the app shuts down — on SIGINT/SIGTERM under bootstrap(), or when you call close() yourself.

Services vs. di/ values ​

Use services/ when…Use di/ when…
You are exposing behaviour — methodsYou are exposing a value — config, a client, the current user
A singleton is rightYou need session or request lifetime

Both land on ctx and are typed the same way. See Values and lifetimes.

Special filenames ​

One name is interpreted by the framework:

FileEffect
services/cacheStore.tsReplaces the in-process route cache store
services/sessionStore.tsReturned object is used as the session store
services/logger.tsReplaces the built-in ctx.logger

ctx.logger exists without you defining anything — the framework registers a console logger before scanning, and steps aside if your project provides its own logger service.

Released under the MIT License.