Express does not include an @Service decorator or a dependency-injection container. It provides routes and middleware; service registration, construction, dependency resolution, and lifecycle are application-level choices. A small, explicit registry can reproduce the useful part of the convention without pretending the decorator does the work by itself.
This example targets Express 5 and TypeScript. Express 5’s application API currently lists Node.js >=20.19.3 <21 || >=22.2.0 as its requirement; check the versioned Express 5 application documentation against your project before adopting that runtime range.
As an Amazon Associate I earn from qualifying purchases.
What “@Service” needs to do
Express describes itself as a routing and middleware web framework with minimal functionality of its own: an app is essentially middleware functions executed during the request-response cycle. Its documented building blocks are route handlers and routers, not a service container. See Express’s middleware guide and routing guide.
A service convention has several distinct jobs:
- Marking: identify a class as a service.
- Registration: make it available under a runtime token.
- Construction: decide when and how to create it.
- Resolution: supply dependencies to consumers.
- Lifecycle: decide whether an instance is shared or created per request.
A decorator can help with marking or registration, but it does not automatically solve the other jobs. The implementation below keeps construction and dependency wiring explicit, then uses a decorator only as a registry marker.
#1 Best Overall
A small TypeScript implementation
Use class constructors as tokens. This keeps the example type-aware and avoids relying on TypeScript interfaces at runtime: interfaces are erased when TypeScript compiles, so they cannot identify a dependency in a JavaScript container.
1. Create a registry and decorator
type Constructor<T = object> = new (...args: any[]) => T;
const serviceTypes = new Set<Constructor>();
function Service<T extends Constructor>(target: T): T {
serviceTypes.add(target);
return target;
}
class Container {
private readonly instances = new Map<Constructor, unknown>();
register<T>(token: Constructor<T>, instance: T): void {
this.instances.set(token, instance);
}
resolve<T>(token: Constructor<T>): T {
if (!this.instances.has(token)) {
throw new Error(`Service not registered: ${token.name}`);
}
return this.instances.get(token) as T;
}
}
@Service adds a class to a set; it does not instantiate the class. The container stores instances by constructor and fails clearly when a dependency has not been registered.
2. Define a service and its dependency
class UserRepository {
findName(id: string): string | undefined {
return id === "42" ? "Ada" : undefined;
}
}
@Service
class UserService {
constructor(private readonly users: UserRepository) {}
getName(id: string): string | undefined {
return this.users.findName(id);
}
}
The decorator registry can be used to check which classes were marked, but it does not infer the constructor parameter or decide how to create it. Wiring stays in one composition function:
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
Rank #2
function buildContainer(): Container {
const container = new Container();
const users = new UserRepository();
const userService = new UserService(users);
container.register(UserRepository, users);
container.register(UserService, userService);
return container;
}
Explicit construction is intentionally visible: the repository is created first, passed to the service, and both are registered. For a larger application, this wiring can be moved into modules or replaced with a full container, but the lifecycle decisions remain important.
Connect the service to an Express route
Express route handlers can be functions, arrays of functions, or combinations, and routers provide modular, mountable routing. A handler factory is a direct integration point: resolve what the route needs once and close over it.
import express from "express";
function createUserRouter(container: Container) {
const router = express.Router();
const users = container.resolve(UserService);
router.get("/:id", (req, res) => {
const name = users.getName(req.params.id);
if (!name) {
res.sendStatus(404);
return;
}
res.json({ id: req.params.id, name });
});
return router;
}
const app = express();
const container = buildContainer();
app.use("/users", createUserRouter(container));
app.listen(3000);
The route is mounted at /users, so a request to /users/42 reaches the router’s /:id handler. This handler uses a shared service instance because buildContainer() creates it once and the router closes over it. That is appropriate for stateless services or shared infrastructure clients; it is not appropriate for storing one user’s request data on the service instance.
Rank #3
Choose a lifecycle deliberately
Singleton and request-scoped are different behaviors, not interchangeable ways to write the same registration. A singleton is one instance reused across requests. A request-scoped object is created for, or associated with, an individual request. Do not put request-specific mutable state in a singleton.
- Use shared instances for stateless domain services and intentionally shared clients whose own lifecycle is managed appropriately.
- Pass request data as method arguments when a service needs the current user, locale, or transaction context but does not need a request-specific instance.
- Introduce request scope only when needed. It requires per-request creation and a clear way to resolve that request’s dependencies; it should not be simulated by mutating a global singleton.
For an example of a different Express integration pattern, the Awilix Express package listing surfaces container registration, per-request scope, and controller/route decorators. That demonstrates an existing approach, not a claim about the current release’s recency or a requirement to use it.
Replace a dependency in a test
Because construction is explicit, a test can provide a fake repository without changing global state or patching modules:
Rank #4
class FakeUserRepository extends UserRepository {
findName(id: string): string | undefined {
return id === "test-user" ? "Test Person" : undefined;
}
}
const container = new Container();
const repository = new FakeUserRepository();
const service = new UserService(repository);
container.register(UserRepository, repository);
container.register(UserService, service);
if (container.resolve(UserService).getName("test-user") !== "Test Person") {
throw new Error("Unexpected service result");
}
The example checks service wiring directly. An HTTP-level test can additionally mount createUserRouter(container) in an Express app and assert the response, using the project’s chosen test runner and HTTP test client.
When a decorator or container is the wrong tool
For a small Express app, a function that constructs and passes dependencies may be clearer than a registry. A custom decorator becomes useful when it removes repetitive registration without hiding important construction rules. If the application needs nested scopes, automatic construction, or extensive binding conventions, an established container may be preferable to extending this minimal example.
Frameworks document different parts of the distinction. LoopBack’s service decorator documentation describes injection of an instance matching a service binding; it also calls for a string or symbol token when an interface is not a class, and requires the service to be bound in the context. Ts.ED’s providers documentation distinguishes AutoInjectable behavior when a class is instantiated with new from provider registration that makes a class injectable into other classes. In both cases, a decorator should not be confused with registration, binding, or lifecycle policy.
JavaScript and TypeScript caveats
This example uses TypeScript class decorators and constructor types, but does not use reflection metadata or infer constructor arguments. That is why manual construction is explicit and why the example does not need to treat an interface as a runtime token. In plain JavaScript, the same registry and container can be written with constructor functions or classes, but TypeScript’s compile-time types will not be available.
If you adopt a decorator implementation that depends on emitted metadata, check its requirements against the TypeScript compiler configuration, decorator version, and runtime. Those details are not established by Express itself. Keep the Express major version and Node.js runtime aligned with the project’s lockfile and deployment environment; the versioned Express 5 API page states the Node requirement noted above.
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.
Recommended Free Tools




