holoscope v0.9.0
Dependency injection tool for Typescript projects.
Inspired by Awilix and AWS CDK Constructs.
Installation
With npm:
npm install holoscope
Getting Started
If you're familiar with Awilix or similar IoC libraries, you should check out this section
Holoscope has two key concepts: Scopes
and Resolvers
. You wrap your dependencies (service classes, configuration objects, etc) in an appropriate resolver and pass them to a Scope
, which manages passing peer dependencies to each resolver. Take this simple example:
import { asClass, Scope } from 'holoscope'
class PostService {
constructor(private container: { database: Database }) {}
async getPosts() {
return this.container.database.posts.getAll()
}
}
const scope = new Scope({
database: asClass(Database, { cached: true }),
postService: asClass(PostService, { cached: true }),
})
const posts = await scope.container.postService.getPosts()
Scope.container
is a proxy that handles dependency resolution at access-time. It is passed to each resolver's resolve
method. asFunction
and asClass
resolvers pass the container to the provided factory function or class respectively.
In the example above, the database
is instantiated inside the getPosts
method of PostService
. After that, it gets cached, and reused in any subsequent access through the container.
Note: values that are not a
Resolver
are wrapped inasValue()
at registration. This allows to register plain values without wrapping them.If you want to create your own resolver with custom logic, look here.
Building your scopes
The Scope
class should be extended to build your scopes:
type GeneralContainer = {
database: Database
postService: PostService
}
class GeneralScope extends Scope<GeneralContainer> {
constructor() {
super({
database: asClass(Database, { cached: true }),
postService: asClass(PostService, { cached: true }),
})
}
}
const scope = new GeneralScope()
Extending scopes
Notice that the values in the container type provided to scope's generic parameter (GeneralContainer
above) don't have to be specific implementations, but interfaces instead.
This allows to extend and swap out the dependencies:
class DevelopmentScope extends GeneralScope {
constructor() {
super()
this.register({
// DevelopmentDatabase satisfies Database
database: asClass(DevelopmentDatabase, { cached: true }),
})
}
}
Expanding scopes
To expand a scope (extend and add dependencies, not just swap them out), consider using ExtendedInjection
generic type as a constructor input in the following pattern:
interface BaseContainer {
name: string
}
class BaseScope<TExtended extends ExampleContainer> extends Scope<TExtended> {
constructor(extended: ExtendedInjection<ExampleContainer, TExtended>) {
const registrations: Injection<ExampleContainer> = {
}
super({
name: 'example',
...extended,
} as Injection<TExtended>)
}
}
class ExtendedScope extends BaseScope<ExtendedContainer> {
constructor() {
super({
greeting: asFunction(({ name }) => `Hello, ${name}!`),
})
}
}
new ExtendedScope().container.greeting // 'Hello, World!'
Disposing scopes and their resolvers
For asFunction
and asClass
resolvers (as well as possibly for custom resolvers), there is an option to provide a disposer
function.
Scope.dispose()
is an async method, which internally awaits every resolver's disposer.
This is useful to close database connections, write logs, etc. at the end of a process.
For
asFunction
andasClass
resolvers withcached: true
, disposers are only called if the dependency was resolved before.
Comparison with Awilix
- Different terminology for similar concepts/types:
awilix.Container -> holoscope.Scope
;awilix.Container.cradle -> holoscope.Scope.container
. - All registration must be provided at
Scope
init, ensuring type-safety. - Factory resolvers (
asFunction
,asClass
) are not bound to the scope. Cache is handled inside the resolvers themselves. - No auto-loading modules.
- Instead of "child" containers, scopes can be extended, overwriting and adding new dependencies in a flat internal structure.
Built-in resolvers and helper functions
Holoscope includes the following general-purpose resolvers:
asValue
- used internally to wrap non-resolver values. Can be used explicitly.asFunction
,asClass
- factory resolvers. Optionally handle cache and disposing. Allow injecting per-dependency peers withinject
option.aliasTo
- returns a dependency of a passed name from the container. Beware recursive loops - do not pass the name this resolver is registered itself.asResolvers
- pass an object of resolvers. Used to nest dependencies.
There are also helper functions asCachedFunction
and asCachedClass
, that are simple shorthands for as___(factory, { cached: true })
Custom resolvers
You can create your own resolvers with custom logic, e.g.:
import { Container, IS_RESOLVER, Resolver } from 'holoscope'
class CustomResolver<T = unknown> implements Resolver<T> {
readonly [IS_RESOLVER] = true
resolve(container: Container): T {
// custom logic, that returns T
}
}
Resolvers have to:
- have a
resolve
method that accepts a container and returns the value - have
IS_RESOLVER
property set totrue
. Resolver doesn't necessarily have to be a class instance.NOTE:
IS_RESOLVER
is a Symbol imported from the library — this prevents prop name conflicts. Make sure you are setting[IS_RESOLVER] = true
and notIS_RESOLVER = true
.
A resolver can have an optional dispose
method that accepts the entire container. However, it is recommended to only interact with the dependency the resolver represents in custom disposers, to avoid accessing a peer dependency after it already was disposed.
API Reference
TODO
Contributing
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
5 months ago
6 months ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
3 years ago
3 years ago
3 years ago
4 years ago
4 years ago