Manage your dependencies with ease and safety. RSDI is a minimal, powerful DI container with full TypeScript support — no decorators or metadata required.
Most DI libraries rely on reflect-metadata and decorators to auto-wire dependencies. But this tightly couples your business logic to a framework — and adds complexity:
@injectable()
class Foo {
constructor(@inject("Database") private database?: Database) {}
}
// Notice how in order to allow the use of the empty constructor new Foo(),
// we need to make the parameters optional, e.g. database?: Database.Why should your core logic even know it’s injectable?
RSDI avoids this by using explicit factory functions — keeping your code clean, framework-agnostic, and easy to test.
- No decorators
- Strong TypeScript support
- Simple API
- No runtime dependencies
- Easy to mock and test
Use RSDI when your app grows in complexity:
- You break big modules into smaller ones
- You have deep dependency trees (A → B → C)
- You want to pass dependencies across layers:
- Controllers
- Domain managers
- Repositories
- Infrastructure services
RSDI works best when you organize your app as a dependency tree.
A typical backend app might have:
- Controllers (REST or GraphQL)
- Domain managers (use-cases, handlers)
- Repositories (DB access)
- Infrastructure (DB pools, loggers)
Set up your DI container at the app entry point — from there, all other parts can pull in what they need.
const container = new DIContainer()
.add("a", () => "name1")
.add("bar", () => new Bar())
.add("foo", ({ a, bar}) => new Foo(a, bar));
const { foo } = container; // alternatively container.get("foo");// sample web application components
export function UserController(
userRegistrator: UserRegistrator,
userRepository: UserRepository,
) {
return {
async create(req: Request, res: Response) {
const user = await userRegistrator.register(req.body);
res.send(user);
},
async list(req: Request) {
const users = await userRepository.findAll(req.body);
res.send(users);
},
};
}
export class UserRegistrator {
public constructor(public readonly userRepository: UserRepository) {}
public async register(userData: SignupData) {
// validate and send sign up email
return this.userRepository.saveNewUser(userData);
}
}
export function MyDbProviderUserRepository(db: DbConnection): UserRepository {
return {
async saveNewUser(userAccountData: SignupData): Promise<void> {
await this.db("insert").insert(userAccountData);
},
};
}
export function buildDbConnection(): DbConnection {
return connectToDb({
/* db credentials */
});
}Now let’s configure the dependency injection container. Dependencies are only created when they’re actually needed.
Your configureDI function will declare and connect everything in one place.
import { DIContainer } from "rsdi";
export type AppDIContainer = ReturnType<typeof configureDI>;
export default function configureDI() {
return new DIContainer()
.add("dbConnection", buildDbConnection())
.add("userRepository", ({ dbConnection }) =>
MyDbProviderUserRepository(dbConnection),
)
.add("userRegistrator", ({ userRepository }) => new UserRegistrator(userRepository))
.add("userController", ({ userRepository, userRegistrator}) =>
UserController(userRepository, userRegistrator),
);
}When a resolver runs for the first time, its result is cached and reused for future calls.
By default, you should always use .add() to register dependencies. If you need to replace an existing one — usually in tests — you can use .update() instead. This avoids accidental overwrites and keeps your setup predictable.
Let's map our web application routes to configured controllers
// configure Express router
export default function configureRouter(
app: core.Express,
diContainer: AppDIContainer,
) {
const { usersController } = diContainer;
app
.route("/users")
.get(usersController.list)
.post(usersController.create);
}Add configureDI() in your app’s entry point:
// express.ts
const app = express();
const diContainer = configureDI();
configureRouter(app, diContainer);
app.listen(8000);🔗 Full example: Express + RSDI
RSDI uses TypeScript’s type system to validate dependency trees at compile time, not runtime.
This gives you autocomplete and safety without decorators or metadata hacks.
As your application grows, it’s a good idea to split your DI container setup into smaller, focused modules. This keeps your codebase easier to navigate and maintain.
A common pattern is to keep a main diContainer.ts file that configures the base container and delegate domain-specific
dependencies to separate files like dataAccess.ts, validators.ts, or controllers.ts.
This modular structure improves testability, readability, and clarity on how dependencies are wired across your app.
You can extend a container with more dependencies using .extend(). This is ideal for building up your container in logical steps.
// diContainer.ts
export const configureDI = async () => {
return (await buildDatabaseDependencies())
.extend(addDataAccessDependencies)
.extend(addValidators);
};// addDataAccessDependencies.ts
export type DIWithPool = Awaited<ReturnType<typeof buildDatabaseDependencies>>;
export const addDataAccessDependencies = async () => {
const pool = await createDatabasePool();
const longRunningPool = await createLongRunningDatabasePool();
return new DIContainer()
.add("databasePool", () => pool)
.add("longRunningDatabasePool", () => longRunningPool);
};// addValidators.ts
export type DIWithValidators = ReturnType<typeof addValidators>;
export const addValidators = (container: DIWithPool) => {
return container
.add("myValidatorA", ({ a, b, c }) => new MyValidatorA(a, b, c))
.add("myValidatorB", ({ a, b, c }) => new MyValidatorB(a, b, c));
};You can merge two containers to combine their resolvers and resolved values.
- Dependencies from both containers are preserved.
- If both define the same key, the merging container’s value takes precedence.
- Already resolved values are reused — not re-created.
const containerA = new DIContainer()
.add("a", () => "1")
.add("bar", () => new Bar());
const containerB = new DIContainer()
.add("b", () => "b")
.add("buzz", () => new Buzz("buzz"));
const finalContainer = containerA.merge(containerB);
console.log(finalContainer.a); // "1"
console.log(finalContainer.b); // "b"
console.log(finalContainer.bar instanceof Bar); // true
console.log(finalContainer.buzz.name); // "buzz"Use .clone() to create a new container that shares resolvers and already resolved values with the original.
This is useful for creating isolated execution contexts while preserving the base setup.
const containerA = new DIContainer()
.add("a", () => "1")
.add("bar", () => new Bar())
.add("buzz", () => new Buzz("buzz"));
const containerB = containerA.clone();
console.log(containerB.a); // "1"
console.log(containerB.bar instanceof Bar); // true
console.log(containerB.buzz.name); // "buzz"
