Dependency Injection in Hono

How to wire up services cleanly in a Hono app using Bindings and context variables — with a Telegram notification service as the example.

I’ve spent the last 7+ years building APIs in PHP and Laravel. It’s a great ecosystem — expressive, batteries-included, and with a DI container that makes wiring up services almost invisible. But lately I’ve been re-evaluating my stack. AI-assisted development has made jumping between languages far less painful, and I wanted to see what it actually felt like to build an API in TypeScript and ship it on Cloudflare Workers.

The short answer: it’s surprisingly good. The tooling is fast, the cold-start story is excellent, and Hono — a lightweight router built for edge runtimes — has a thoughtful approach to typing the request context that maps reasonably well to what I’m used to in Laravel.

One thing I wanted to get right from the start was dependency injection. In Laravel it comes for free via the service container. In Hono you build it yourself, but the primitives are there — and the result ends up being clean and testable.

This post walks through the pattern using a Telegram notification service as the example. The same approach works for any service: email senders, queues, storage clients, whatever. Hono’s context object does more than carry the request and response — it also holds typed environment bindings and arbitrary variables you can attach at the middleware layer, which turns out to be the right seam for DI.

Lets set up a new Hono project

pn create hono@latest hono-dependency-injection/

 Using target directory hono-dependency-injection/

 Which template do you want to use? cloudflare-workers

 Do you want to install project dependencies? Yes

 Which package manager do you want to use? pnpm

 Cloning the template

And this creates a little boilerplate project with a src/index.ts entrypoint and a package.json.

// index.ts

import { Hono } from 'hono'

const app = new Hono()

app.get('/', (c) => {
  return c.text('Hello Hono!')
})

export default app

We’ll create a new src/services/telegram.ts file to hold our service class.

// src/services/telegram.ts

export interface SendTelegramMessage {
  chatId: string;
  text: string;
}

export interface TelegramService {
  sendMessage(message: SendTelegramMessage): Promise<void>;
}

export class HttpTelegramService implements TelegramService {
  constructor(private readonly botToken: string) {}

  async sendMessage(message: SendTelegramMessage): Promise<void> {
    const res = await fetch(
      `https://api.telegram.org/bot${this.botToken}/sendMessage`,
      {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          chat_id: message.chatId,
          text: message.text,
        }),
      },
    );

    if (!res.ok) {
      throw new Error(
        `Telegram API request failed: ${res.status} ${res.statusText}`,
      );
    }
  }
}

To handle the request and validation we’ll install a 3rd party library like zod;

pnpm install zod @hono/zod-validator
// src/schemas/telegram.ts

import { z } from "zod";

export const SendMessageRequest = z.object({
  chatId: z.string().min(1),
  text: z.string().min(1).max(4096),
});

export type SendMessageRequest = z.infer<typeof SendMessageRequest>;

We’ll wire up a little controller to handle the contact form, and send a message to Telegram:

import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { SendMessageRequest } from "./schemas/telegram";
import { HttpTelegramService } from "./services/telegram";

app.post('/api/contact',  zodValidator('json', SendMessageRequest), async (c) => {
  const telegram = new HttpTelegramService(c.env.TELEGRAM_BOT_TOKEN);

  const { chatId, text } = c.req.valid('json');
  
  await telegram.sendMessage({ chatId, text });
  
  return c.json({ ok: true });
});

The problem with new Service() inside a route

The naive approach is to instantiate a service directly inside a route handler:

import { HttpTelegramService } from "./services/telegram";

app.post('/api/contact',  zodValidator('json', SendMessageRequest), async (c) => {
  const telegram = new HttpTelegramService(c.env.TELEGRAM_BOT_TOKEN);

  const { chatId, text } = c.req.valid('json');
  
  await telegram.sendMessage({ chatId, text });
  
  return c.json({ ok: true });
});

This works, but there’s no seam to replace the real HTTP client with a fake in tests. Every test hits the real Telegram API — or you’re stuck monkey-patching imports.

Define the service interface first

Start with a narrow interface in a shared package. The interface is the contract; implementations are details.

// src/services/telegram.ts

export interface SendTelegramMessage {
  chatId: string;
  text: string;
}

export interface TelegramService {
  sendMessage(message: SendTelegramMessage): Promise<void>;
}

Then write two implementations: a real one that calls the API, and a fake for tests.

export class HttpTelegramService implements TelegramService {
  constructor(private readonly botToken: string) {}

  async sendMessage(message: SendTelegramMessage): Promise<void> {
    const res = await fetch(
      `https://api.telegram.org/bot${this.botToken}/sendMessage`,
      {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          chat_id: message.chatId,
          text: message.text,
        }),
      }
    );

    if (!res.ok) {
      throw new Error(
        `Telegram API request failed: ${res.status} ${res.statusText}`
      );
    }
  }
}

export class FakeTelegramService implements TelegramService {
  readonly sent: SendTelegramMessage[] = [];

  async sendMessage(message: SendTelegramMessage): Promise<void> {
    this.sent.push(message);
  }
}

FakeTelegramService records every message. Tests can inspect telegram.sent without touching the network.

Type the app with Bindings

Hono’s generic parameter carries two keys: Bindings (Cloudflare’s Env from wrangler.toml) and Variables (anything you set on the context yourself).

// src/bindings.ts
import type { TelegramService } from 'services/telegram';

/**
 * Builds a Telegram service bound to a specific bot token, so each route can
 * notify through its own bot. The matching chat id is passed per message.
 */
export type TelegramFactory = (botToken: string) => TelegramService;

export type Bindings = {
  Bindings: Env;
  Variables: { telegramFactory: TelegramFactory };
};

Using a factory function ((botToken: string) => TelegramService) rather than a single instance lets each route pick its own bot token at call time. If you only ever have one bot, a plain TelegramService works just as well.

Pass Bindings to every new Hono<...>() call:

// src/routes/contact.ts
import { Hono } from 'hono';
import type { Bindings } from '../bindings';

export const contactRoutes = new Hono<Bindings>();

Without the generic, c.env and c.var are untyped. With it, TypeScript knows exactly what secrets and variables are available.

Wire the factory in at app creation

The factory is injected through createApp, which accepts an optional override. Production code picks up the default; tests supply whatever they need.

// src/index.ts
import { HttpTelegramService } from './services/telegram';
import { Hono } from 'hono';
import type { Bindings, TelegramFactory } from './bindings';

export type AppOptions = {
  telegramFactory?: TelegramFactory;
};

const defaultTelegramFactory: TelegramFactory = (botToken) =>
  new HttpTelegramService(botToken);

export const createApp = ({
  telegramFactory = defaultTelegramFactory,
}: AppOptions = {}) => {
  const app = new Hono<Bindings>();

  // Register the factory once; all downstream routes read it from hono's c.var.
  app.use('*', async (c, next) => {
    c.set('telegramFactory', telegramFactory);
    await next();
  });

  // We'll cover the route in a moment
  app.route('/api/contact', ...);

  return app;
};

export default createApp();

The app.use('*', ...) middleware runs before every request and stamps telegramFactory onto the context. Routes never import or instantiate a service — they just read it off c.var.

Use the factory inside a route

The route reads the factory from context, calls it with the right bot token, and sends the message. c.env carries the secrets from wrangler.toml; c.var carries the injected factory.

// index.ts continued...

app.route('/api/contact', zodValidator('json', ContactFormRequest), async (c) => {
  const { chatId, text } = c.req.valid("json");

  await c.var.telegramFactory(c.env.TELEGRAM_WEBHOOK_BOT_TOKEN).sendMessage({
    chatId: chatId,
    text,
  });

  return c.json({ ok: true });
});

No imports from the implementation file. The route only knows about the TelegramService interface — the concrete class is entirely behind the factory.

Test it without a real bot

Because createApp accepts a factory, tests swap in FakeTelegramService in a single line:

// test/index.test.ts

import { FakeTelegramService } from './services/telegram';
import { createApp } from '../src/index';

it('forwards the submission to telegram', async () => {
  const telegram = new FakeTelegramService();
  const app = createApp({ telegramFactory: () => telegram });

  const res = await app.request('/api/contact', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      chatId: env.TELEGRAM_WEBHOOK_CHAT_ID,
      text: 'Hello! I have a question...',
    }),
  }, env);

  expect(res.status).toBe(200);
  expect(telegram.sent).toEqual([
    {
      chatId: env.TELEGRAM_WEBHOOK_CHAT_ID,
      text: expect.stringContaining('Hello! I have a question...'),
    },
  ]);
});

The factory override means the real HttpTelegramService is never constructed. FakeTelegramService.sent gives a full record of every message the handler tried to send, which you can assert against exactly.

Summary

The full shape of the pattern:

  1. Define an interface in a shared package — the contract every implementation must satisfy.
  2. Write two implementations — the real HTTP client, and a fake that records calls for assertions.
  3. Type the app with BindingsBindings for Cloudflare secrets, Variables for injected dependencies.
  4. Inject via createApp — pass an optional factory override; default to the real implementation.
  5. Register in middleware — one app.use('*', ...) call stamps the factory onto the context for all routes.
  6. Read from c.var in routes — no direct imports of concrete classes; the route is isolated from the implementation.

The end result is a codebase where routes are easy to test, services are easy to swap, and TypeScript can verify that everything is wired up correctly at compile time.

You can find the full code example on GitHub.

Enjoy!