DI Container Gotchas: When @Service() Doesn't Actually Register Your Class
I spent two days debugging a problem that boiled down to this: decorating a class with @Service() does not register it in the DI container. It sets metadata. Registration is a separate step that happens somewhere else, and if that "somewhere else" does not run, your class exists in memory but is invisible to the rest of your application.
This article covers what I learned building queue processors for Vibed, a Jira-like task management backend built with KickJS. The lessons apply to any decorator-based DI system in TypeScript -- NestJS, InversifyJS, tsyringe, or custom implementations.
The Setup: Queue Processors That Worked on Restart but Broke on Cold Boot
Vibed uses BullMQ for background job processing. Email notifications, activity logging, and push notifications each have a dedicated processor class decorated with @Job() and @Service():
// src/modules/queue/infrastructure/processors/email.processor.ts
@Service()
@Job('email')
export class EmailProcessor {
@Autowired(MAILER) private mailer!: MailerService;
@Process('send-welcome-email')
async sendWelcome(job: BullMQJob<{ email: string; firstName: string }>) {
logger.info(`Sending welcome email to ${job.data.email}`);
await this.mailer.send({
to: job.data.email,
subject: `Welcome to Vibed, ${job.data.firstName}!`,
html: `<h1>Welcome to Vibed!</h1><p>Hi ${job.data.firstName}, your account is ready.</p>`,
});
}
@Process('send-task-assigned')
async sendTaskAssigned(job: BullMQJob<{ email: string; taskKey: string; taskTitle: string; assignerName: string }>) {
logger.info(`Sending task assigned email to ${job.data.email}`);
await this.mailer.send({
to: job.data.email,
subject: `You were assigned to ${job.data.taskKey}: ${job.data.taskTitle}`,
html: `<p>${job.data.assignerName} assigned you to <strong>${job.data.taskKey}</strong>: ${job.data.taskTitle}</p>`,
});
}
// ... more processors
}The module file imported these processors as side effects to ensure the decorators ran:
// src/modules/queue/queue.module.ts
import type { AppModule, ModuleRoutes, Container } from '@forinda/kickjs-core';
import './infrastructure/processors/email.processor';
import './infrastructure/processors/notification.processor';
import './infrastructure/processors/activity.processor';
export class QueueModule implements AppModule {
register(_container: Container): void {
// Nothing here -- we assumed @Service() handled registration
}
routes(): ModuleRoutes | null {
return null;
}
}On a fresh kick dev start, this worked. Emails sent. Notifications delivered. Then I changed something in a controller, HMR kicked in, and the logs filled with:
No binding found for: EmailProcessor
No binding found for: NotificationProcessor
No binding found for: ActivityProcessorJobs started piling up in the queue. Nothing was processing them.
The Root Cause: Decorators Set Metadata, They Don't Register
To understand what happened, I had to trace what @Service() actually does at runtime. Here is the simplified version:
// What @Service() does internally (simplified)
function Service(): ClassDecorator {
return (target) => {
Reflect.defineMetadata('injectable', true, target);
Reflect.defineMetadata('scope', 'singleton', target);
};
}That is it. It marks the class as injectable. It does not call container.register(target). Registration happens later, when some other piece of code reads that metadata and calls the container.
For controllers, buildRoutes(TasksController) reads the metadata and registers the class. For repositories, @Repository() works similarly -- and then the module's register() method explicitly binds the token:
// src/modules/tasks/tasks.module.ts
export class TasksModule implements AppModule {
register(container: Container): void {
container.registerFactory(TOKENS.TASK_REPOSITORY, () =>
container.resolve(MongoTaskRepository),
);
}
}For queue processors, the QueueAdapter is supposed to discover classes decorated with @Job() and register them. And it does -- on cold boot. The problem is what happens next.
How We Discovered This: The HMR Class Identity Problem
When Vite HMR re-evaluates a module file, the JavaScript runtime creates a new class object. The class has the same name, the same methods, the same decorators. But it is a different object in memory. OldEmailProcessor !== NewEmailProcessor.
The DI container stored a binding for OldEmailProcessor. When QueueAdapter.beforeStart() ran during rebuild, it tried to resolve NewEmailProcessor -- which had no binding. Hence the error.
But this was not just an HMR problem. It revealed a deeper architectural issue: the registration path for queue processors was implicit and fragile. It depended on:
- The side-effect import running before the adapter starts
- The
@Job()decorator populating a global registry - The
QueueAdapterreading that registry at exactly the right time - No module re-evaluation happening between steps 2 and 3
Any break in that chain, and the processor silently vanishes from the container.
The Workaround: Explicit Registration
Before KickJS patched this, I had two workarounds.
Workaround 1: Manual registration in the module. Instead of trusting the decorator chain, register processors explicitly:
export class QueueModule implements AppModule {
register(container: Container): void {
// Explicitly register each processor
container.register(EmailProcessor);
container.register(NotificationProcessor);
container.register(ActivityProcessor);
}
routes(): ModuleRoutes | null {
return null;
}
}This works but defeats the purpose of @Service(). You are doing manually what the decorator was supposed to do.
Workaround 2: @AutoRegister decorator with deferred registration. I wrote a custom decorator that hooks into the container directly:
function AutoRegister(): ClassDecorator {
return (target: any) => {
// Defer registration until the container is available
const originalMetadata = Reflect.getMetadata('injectable', target);
if (!originalMetadata) {
Reflect.defineMetadata('injectable', true, target);
}
// Queue for registration on next tick (container may not exist yet)
queueMicrotask(() => {
const container = Container.getInstance();
if (!container.has(target)) {
container.register(target);
}
});
};
}
// Usage:
@AutoRegister()
@Service()
@Job('email')
export class EmailProcessor {
// ...
}The queueMicrotask ensures the container exists before we try to register. This is hacky. It depends on timing. I did not love it.
The Fix: QueueAdapter Auto-Registration (v1.2.6)
KickJS v1.2.6 changed the QueueAdapter to auto-register @Job classes in the container before resolving them:
// Inside QueueAdapter.beforeStart() — simplified from v1.2.6
async beforeStart(container: Container) {
for (const [queueName, jobClass] of jobRegistry.entries()) {
// Register the class if it's not already in the container
if (!container.has(jobClass)) {
container.register(jobClass);
}
const processor = container.resolve(jobClass);
this.setupWorker(queueName, processor);
}
}This means the side-effect imports still need to run (so the @Job() decorator populates the registry), but the adapter handles registration. The QueueModule becomes minimal:
// src/modules/queue/queue.module.ts — current version
export class QueueModule implements AppModule {
register(_container: Container): void {
// No manual registration needed — QueueAdapter v1.2.6+ auto-registers
// @Job classes in the container before resolving them.
}
routes(): ModuleRoutes | null {
return null;
}
}The HMR Fix: Container._onReset and allRegistrations (v1.2.7)
The cold boot problem was solved, but HMR still broke processors. KickJS v1.2.7 addressed this with two changes:
1. An allRegistrations map that survives Container.reset(). When HMR triggers a rebuild, the container calls reset() to clear all bindings. Previously, this wiped everything. Now, an allRegistrations map stores every class that was ever registered, keyed by class name (string), not class identity (object reference). After reset, the container can re-register classes even though their object identity has changed.
2. A _onReset hook. Adapters like QueueAdapter can register a callback that runs after Container.reset(). The callback re-reads the job registry and re-registers processor classes with the new class identities.
This is the sequence now:
- HMR re-evaluates
email.processor.ts - JavaScript creates
NewEmailProcessor(new object identity) @Job('email')decorator updates the job registry withNewEmailProcessorContainer.reset()fires, clearing bindings_onResetcallback runs, QueueAdapter re-registersNewEmailProcessor- Workers resolve the fresh class and continue processing
Lessons Learned About Decorator-Based DI
1. Decorators are metadata, not behavior
This is the most important takeaway. In every decorator-based DI system I have seen, the decorator itself just writes metadata. Something else reads that metadata and acts on it. If that "something else" does not run, or runs at the wrong time, the decorator is inert.
When debugging DI issues, do not ask "is the decorator applied?" Ask "what reads this metadata, and when does it run?"
2. Registration and resolution are separate concerns
Registration binds a token (or class) to a factory or instance. Resolution looks up that binding and returns an instance. Most DI bugs are registration bugs, not resolution bugs. The error says "cannot resolve X" but the fix is "register X in the right place."
3. Object identity matters more than you think
In languages with a single class loading mechanism (Java, C#), a class is a class. In JavaScript with HMR, bundlers, or dynamic imports, the same source code can produce multiple class objects. DI containers that use class references as keys (which is most of them) will fail silently when the class identity changes.
If you are building a DI container, consider supporting string-based fallback keys. If you are using one, be aware that hot reloading, re-imports, and dynamic import() can all change class identity.
4. Side-effect imports are a code smell (but sometimes necessary)
Vibed's queue module relies on side-effect imports:
import './infrastructure/processors/email.processor';
import './infrastructure/processors/notification.processor';
import './infrastructure/processors/activity.processor';These exist solely to make @Job() and @Service() decorators execute, populating global registries. This is fragile because:
- Tree-shaking can remove them if nothing references the exports
- Import order matters if registries are read during module evaluation
- It is not obvious why they exist without a comment
The better pattern, which KickJS moved toward in v1.2.6, is for the adapter to scan for decorated classes and handle registration itself. But when working with frameworks that have not made that leap yet, side-effect imports with clear comments are the pragmatic choice.
5. Test your DI from a cold start
During development, I was mostly testing with HMR -- the server was already running, and I was making incremental changes. The processors worked because they were registered during the initial cold boot. It was only when I stopped the server, cleared the Redis queue, and started fresh that I caught the timing bug.
Every time you add a new DI-dependent class, restart the server from scratch and verify it resolves. Do not trust HMR to tell you whether registration is correct.
6. The simplest fix is often explicit registration
When decorators fail, when timing is wrong, when framework magic does not fire -- just call container.register(MyClass) directly. It is one line. It is obvious. It always works. You can refactor to something more elegant later when you understand the registration lifecycle better.
The Pattern I Use Now
For every new module in Vibed, I follow this checklist:
Controllers and services with
@Autowired(): These resolve by class type.buildRoutes()handles registration. No manual step needed.Repositories with
@Inject(TOKEN)in use case constructors: Register the factory in the module'sregister()method. Always.Queue processors with
@Job(): Side-effect import in the module file, letQueueAdapterauto-register. Comment why the import exists.Framework services (mailer, queue manager): Use constructor
@Inject(SYMBOL)or@Autowired(SYMBOL). These are registered by the adapter, not by my code.Test from cold boot: After adding any new DI binding, restart the server and verify.
The decorator-based DI model is powerful when it works. When it does not, the debugging is harder than manual wiring because the registration path is invisible. Knowing where the metadata goes and who reads it turns those invisible paths into traceable ones.