iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more
Express does not include an @Service decorator or a dependency-injection container. It provides routing and middleware; service registration, object creation, dependency resolution, and lifecycle are application-level choices. Here is a small TypeScript pattern that makes those choices explicit, connects a service to an Express route, and lets a test replace the service without changing global state.
What an @Service pattern needs to do
Express describes itself as “a routing and middleware web framework with minimal functionality of its own: an Express application is essentially a series of middleware function calls executed during the request-response cycle.” (Express middleware guide) Routes and middleware are the integration points, not a built-in service system.
A decorator can label a class, but that label alone does not answer the questions that make dependency injection work:
- Registration: How does the application know which service is available?
- Resolution: How does a handler obtain the right instance?
- Construction and lifecycle: Is the instance shared, or created for each request?
- Replacement: Can a test supply a fake without mutating a process-wide registry?
The implementation below uses a decorator only as a class marker. A separate registry binds the marked class to an explicit token, and an application-local factory creates the service and closes over it in the route. This avoids pretending that decorator metadata can infer every dependency.
#1 Best Overall
A small TypeScript implementation
This example targets Express 5 and TypeScript. The sources establish the framework integration model, not a particular compiler or decorator configuration, so the code deliberately avoids experimental decorator metadata and reflection. It uses the standard TypeScript legacy decorator form, which requires the project to enable experimentalDecorators. No package versions beyond the Express major version are asserted here; pin the versions in your own project lockfile.
1. Define a token and a marker decorator
type ServiceToken<T> = symbol & { readonly __type?: T };
function createToken<T>(description: string): ServiceToken<T> {
return Symbol(description) as ServiceToken<T>;
}
const registeredServices = new WeakSet<Function>();
function Service(): ClassDecorator {
return (target) => {
registeredServices.add(target);
};
}
function assertService(target: Function): void {
if (!registeredServices.has(target)) {
throw new Error(`${target.name} is not marked with @Service()`);
}
}
The symbol token is the runtime identity used for lookup. The marker does not instantiate the class, find dependencies, or make the service globally available. A TypeScript interface cannot serve as a runtime token because it is erased when TypeScript emits JavaScript. LoopBack’s service decorator documentation likewise calls for a string or symbol token when the service contract is an interface rather than a class (LoopBack: Service Decorator).
Rank #2
2. Declare and register a service explicitly
interface User {
id: string;
name: string;
}
interface UserRepository {
findById(id: string): Promise<User | undefined>;
}
const USER_REPOSITORY = createToken<UserRepository>("UserRepository");
const USER_SERVICE = createToken<UserService>("UserService");
@Service()
class UserService {
constructor(private readonly users: UserRepository) {}
async getUser(id: string): Promise<User | undefined> {
return this.users.findById(id);
}
}
type Constructor<T> = new (...args: never[]) => T;
class Registry {
private readonly bindings = new Map<symbol, unknown>();
bind<T>(token: ServiceToken<T>, value: T): void {
this.bindings.set(token, value);
}
resolve<T>(token: ServiceToken<T>): T {
if (!this.bindings.has(token)) {
throw new Error(`No binding for ${String(token)}`);
}
return this.bindings.get(token) as T;
}
}
function registerUserService(
registry: Registry,
UserServiceClass: Constructor<UserService> = UserService
): void {
assertService(UserServiceClass);
registry.bind(USER_SERVICE, new UserServiceClass(registry.resolve(USER_REPOSITORY)));
}
Registration receives a repository binding before constructing the service. The registry is intentionally small: it stores already-created values rather than acting as a general-purpose container that reflects over constructors or recursively resolves a dependency graph. That keeps the construction order and dependencies visible.
Do these 3 things before closing this tab:
1Clear out junk files and repair common Windows errors2Scan for outdated or missing drivers - takes under a minute3Repair Windows errors before they cause bigger problemsConnect the service to an Express route
Express routes accept handler functions and can be organized with a modular express.Router(). A handler factory is a direct bridge from application wiring to that routing model: pass the service into a function, then return the request handler. See the Express routing guide.
Rank #3
import express, { type Request, type Response } from "express";
function createUserRouter(users: UserService): express.Router {
const router = express.Router();
router.get("/users/:id", async (req: Request, res: Response) => {
try {
const user = await users.getUser(req.params.id);
if (!user) {
res.sendStatus(404);
return;
}
res.json(user);
} catch {
res.sendStatus(500);
}
});
return router;
}
function createApp(repository: UserRepository): express.Express {
const registry = new Registry();
registry.bind(USER_REPOSITORY, repository);
registerUserService(registry);
const app = express();
app.use(express.json());
app.use(createUserRouter(registry.resolve(USER_SERVICE)));
return app;
}
The route returns a JSON user for a matching ID, a 404 when the service has no result, and a 500 if the service call rejects. A production app would normally pass errors to centralized Express error-handling middleware rather than translating every thrown error to the same response inside each handler. Middleware that does not finish the response must call next(); otherwise the request can hang, as the middleware guide explains.
Replace a dependency in a test
Because app construction accepts a repository and builds a fresh registry, tests can provide a fake without changing shared module state. The same service can also be tested directly with a fake repository.
Rank #4
const fakeUsers: UserRepository = {
async findById(id) {
return id === "42" ? { id: "42", name: "Ada" } : undefined;
},
};
const app = createApp(fakeUsers);
This snippet shows the injection seam, not a complete test-runner setup or a reported test result. Add HTTP assertions using the test client and framework already used by your project.
Choose the lifecycle deliberately
createApp constructs one UserService for that app instance, so requests handled by that app share it. That is appropriate for a stateless service whose dependencies are safe to share. Do not put request-specific data—such as the current user, request, or transaction—into mutable fields on that shared instance.
If a dependency must be request-scoped, pass the request-specific value through the handler or create a request-specific scope. These lifecycles are not interchangeable: a shared service is not automatically safe for per-request state. Awilix Express’s surfaced package example demonstrates a per-request scope integration pattern, while LoopBack documents binding and injection through context (Awilix Express on npm; LoopBack service decorator). Package listings and APIs can change; check the package’s current documentation before adopting its specific API.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.When to keep the custom pattern—and when not to
| Approach | Registration and tokens | Lifecycle and Express connection | Trade-off |
|---|---|---|---|
| Explicit registry and handler factory | Bind values under class, string, or symbol tokens in application setup. | Lifecycle is determined by when setup constructs the instance; inject into a router or handler factory. | Wiring is visible and easy to replace in tests, but construction remains manual. |
| Container integration such as Awilix Express | Container registrations provide resolvable dependencies; the package listing surfaces controller and route decorators. | Its surfaced example includes per-request scoping. | Less hand-built plumbing, but adds a dependency and package-specific conventions. The listing’s release recency was not established. |
| Framework binding and decorators such as LoopBack | Bind a service in a context; use a string or symbol when the contract is an interface. | Injection resolves a matching bound service within that context. | Provides a defined framework model, but is more than adding an Express decorator. |
| Provider-based system such as Ts.ED | Provider registration makes a class available for injection into other classes. | AutoInjectable concerns injection when a class is constructed with new; it is distinct from provider registration. |
Useful when adopting that framework’s provider model; a constructor decorator alone does not register an application-wide service. |
Ts.ED documents the distinction between AutoInjectable construction and provider registration in its DI and providers guide. These options illustrate different mechanisms, not a performance or productivity ranking.
Express version and runtime
This article’s route wiring targets Express 5. The current Express 5 application API page states the supported Node.js range as >=20.19.3 <21 || >=22.2.0 (Express 5 Application API). Verify that range against the Express documentation and your project’s runtime configuration when adopting the example; do not assume it applies to Express 4 or to future Express releases.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

