Simple and powerful dependency injection container for with strong type checking system.
- Motivation
- Features
- When to use
- Architecture
- Usage
- Typescript type resolution
- Dependency declaration
- Wiki
Popular dependency injection libraries use reflect-metadata
that allows to fetch argument types and based on
those types and do autowiring. Autowiring is a nice feature but the trade-off is decorators.
@injectable()
class Foo {}
Why component Foo should know that it's injectable?
Your business logic depends on a specific framework that is not part of your domain model and can change.
More thoughts in a dedicated article
- Simple but powerful
- Does not requires decorators
- Great types resolution
- Works great with both javascript and typescript
RSDI
is most effective in complex applications. When the complexity of your application is high, it becomes necessary to
break up huge components into smaller ones to control the complexity. You have components that use other components that
use other components. You have application layers and a layer hierarchy. There is a need to transfer dependencies from
the upper layers to the lower ones.
You like and respect and use Dependency Injection and TDD. You have to use Dependency Injection in order to have proper unit tests. Tests that test only one module - class, component, function, but not integration with nested dependencies.
RSDI
expects (but does not require) that you build all your dependencies into a dependency tree. Let's take a typical
web application as an example. Given that your application is quite large and has many layers:
- controllers (REST or graphql handlers)
- domain model handlers (your domain models, various managers, use-cases etc)
- DB repositories,
- Low level services
An application always has an entry point, whether it is a web application or a CLI application. This is the only place where you should configure your dependency injection container. The top level components will then have the lower level components injected.
Let's take a simple web application as an example. We will cut into a small part of the application that registers a new user. A real application will consist of dozens of components. The logic of the components will be much more complicated. This is just a demo. It's up to you to use classes or factory functions for the demonstration, and we'll use both.
// 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: Knex): UserRepository {
return {
async saveNewUser(userAccountData: SignupData): Promise<void> {
await this.db("insert").insert(userAccountData);
},
};
}
export function buildDbConnection(): Knex {
return knex({
/* db credentials */
});
}
Now we need to configure the dependency injection container before use. Dependencies are declared and not really initiated
until the application really needs them. Your DI container initialization function - configureDI
will include:
import DIContainer, { object, use, factory, func, IDIContainer } from "rsdi";
export default function configureDI() {
const container = new DIContainer();
container.add({
buildDbConnection: factory(() => {
buildDbConnection();
}),
[MyDbProviderUserRepository.name]: func(
MyDbProviderUserRepository,
use(buildDbConnection)
),
[UserRegistrator.name]: object(UserRegistrator).construct(
use(MyDbProviderUserRepository.name)
),
[UserController.name]: func(
UserController,
use(UserRegistrator.name),
use(MyDbProviderUserRepository.name)
),
});
return container;
}
All resolvers are resolved only once and their result persists over the life of the container.
Let's map our web application routes to configured controllers
// configure Express router
export default function configureRouter(
app: core.Express,
diContainer: IDIContainer
) {
const usersController = diContainer.get(UsersController);
app
.route("/users")
.get(usersController.list.bind(usersController))
.post(usersController.create.bind(usersController));
}
Add configureDI
in the entry point of your application.
// express.ts
const app = express();
const diContainer = configureDI();
configureRouter(app, diContainer);
app.listen(8000, () => {
console.log(`⚡️[server]: Server is running`);
});
The complete web application example can be found here
Dependencies are set as raw values. Container keeps and return raw values.
import DIContainer from "rsdi";
const container = new DIContainer();
container.add({
ENV: "PRODUCTION",
HTTP_PORT: 3000,
storage: new CookieStorage(),
});
const env: string = container.get("ENV");
const port: number = container.get("HTTP_PORT");
const authStorage: AuthStorage = container.get(AuthStorage); // instance of AuthStorage
object(ClassName)
- constructs an instance of the given class. The simplest scenario it calls the class constructor new ClassName()
.
When you need to pass arguments to the constructor, you can use construct
method. You can refer to the already defined
dependencies via the use
helper, or you can pass raw values.
If you need to call object method after initialization you can use method
it will be called after constructor.
class ControllerContainer {
constructor(authStorage: AuthStorage, logger: Logger) {}
add(controller: Controller) {
this.controllers.push(controller);
}
}
// container
const container = new DIContainer();
container.add({
Storage: object(CookieStorage), // constructor without arguments
AuthStorage: object(AuthStorage).construct(
use(Storage) // refers to existing dependency
),
UsersController: object(UserController),
PostsController: object(PostsController),
ControllerContainer: object(MainCliCommand)
.construct(use(AuthStorage), new Logger()) // use existing dependency, or pass raw values
.method("add", use(UsersController)) // call class method after initialization
.method("add", use(PostsController)),
});
Function resolver allows declaring lazy functions. Function will be called when it's actually needed.
function UsersRepoFactory(knex: Knex): UsersRepo {
return {
async findById(id: number) {
await knex("users").where({ id });
},
};
}
const container = new DIContainer();
container.add({
DBConnection: knex(/* ... */),
UsersRepoFactory: func(UsersRepoFactory, use("DBConnection")),
});
const userRepo = container.get(UsersRepoFactory);
Factory resolver is similar to a Function resolver. You can use factory resolver when you need more flexibility
during initialization. container: IDIContainer
will be passed in as an argument to the factory method. You can
resolve other dependencies inside the factory function and have conditions inside of it.
const container = new DIContainer();
container.add({
BrowserHistory: factory(configureHistory),
});
function configureHistory(container: IDIContainer): History {
const history = createBrowserHistory();
const env = container.get("ENV");
if (env === "production") {
// do what you need
}
return history;
}
const history = container.get("BrowserHistory");
container.get
- return type based on declaration.
const container: DIContainer = new DIContainer();
container.add({
strVal: "raw string value",
numberVal: 123,
boolVal: true,
objectVal: object(Buzz), // resolvers to object of class Buzz
dateVal: func(function () {
return new Date(); // resolves to ReturnType of the function
}),
});
const strVal = container.get("strVal"); // strVal: string
const numberVal = container.get("numberVal"); // numberVal: number
const boolVal = container.get("boolVal"); // boolVal: boolean
const objectVal = container.get("objectVal"); // boolVal: Buzz
const dateVal = container.get("dateVal"); // dateVal: Date
contrainer.use
- allows to reference declared dependency with respect to types.
export class Foo {
constructor(name: string, bar: Bar) {}
}
const container: DIContainer = new DIContainer();
container.add({
bar: new ObjectResolver(Bar),
key1: new RawValueResolver("value1"),
// `bar` dependency cannot be referenced in the same `add` call
// Argument of type 'string' is not assignable to parameter of type... '
// foo: new ObjectResolver(Foo).construct("foo", container.use("bar")),
});
container.add({
foo: new ObjectResolver(Foo).construct("foo", container.use("bar")),
});
To support lazy loading construct
method changes original Foo constructor to expect
(string | ReferenceResolver<string>, Bar | ReferenceResolver<Bar>)
.
container.use("bar")
- will return object ReferenceResolver<Bar>
to respect safe types.
use
helper
import { use } from "rsdi";
use
helper is less strict version of container.use
. It allows to reference dependencies that will be defined relying
on convention over configuration rule.
- if given name is a class - instance of the class
- if given name is a function - return type of the function
- if custom type is provided - return ReferenceResolver
- otherwise - any
class Foo {
constructor(private readonly bar: Bar) {}
}
function returnBoolean() {
return true;
}
const container: DIContainer = new DIContainer();
container.add({
Bar: new Bar(),
Foo: object(Foo).construct(use(Bar)), // resolves Bar
expectBoolFunc: func(function (a: boolean) {
return null;
}, use(returnBoolean)), // resolves to ReturnType of returnBoolean function
expectDateFunc: func(function (a: Date) {
return null;
}, use<Date>("date")), // resolves to Date
expectDateFunc2: func(function (a: Date) {
return null;
}, use("date")), // resolves to any
});
Use string names
const container: DIContainer = new DIContainer();
container.add({
bar: new ObjectResolver(Bar),
key1: new RawValueResolver("value1"),
});
container.add({
foo: new ObjectResolver(Foo).construct("foo", container.use("bar")),
});
Use function and class names by declaring them as [MyClass.name]
. In this case, it is safe to rename functions and
classes in the IDE. IDE will identify all usages and rename them in the container as well. You can declare all dependencies
in a single add
method and use use
helper to reference other dependencies.
container.add({
[Foo.name]: new Foo(),
[MyFactory.name]: MyFactory(),
[Foo.name]: object(Foo).construct(use(Bar)),
});
const foo = container.get(Foo);
const buzz = container.get(MyFactory);