A dependency injection container for Haxe. Supports singleton, transient, and scoped services.
- Three service lifecycles: Singleton, Transient, and Scoped
- Configuration handling and configurable dependencies
- Dependencies can be segregated by service type
- Dependencies can be bound to names for chaining
- Lifecycle management through
Destructable
interface - Basic handling of services with type parameters
- Concrete self-binding
- Iterable support
Allows you to define abstract services, whose concrete implementations can handle platform-specific code, which can then be injected into high-level code. This allows one to take advantage of the dependency inversion and single responsibility principles resulting in a cleaner application architecture. It exists as a natural fit to the Haxe programming language as it allows one to define different implementations per target.
First you define the abstraction that represents your given service. This could be an abstract class, interface, or the class itself. To identify a service, it must extend/implement the hx.injection.Service interface:
import hx.injection.Service;
interface TestService extends Service {
public function sayWord() : Void;
}
Then create a concrete implementation of that abstraction:
import hx.injection.Service;
class LoudTestService implements TestService {
public function new() {}
public function sayWord() : Void {
trace("HELLO");
}
}
During start up:-
- Create a service collection. Here, you can define what services your application will depend upon:
var collection = new ServiceCollection();
collection.addSingleton(TestService, NormalTestService);
collection.addSingleton(MyService);
- You can also supply optional configuration objects:
collection.addConfig(new TestConfig());
- These will be automatically injected into classes whose constructor defines the service as a dependency:
import hx.injection.Service;
class MyService implements Service {
// Will recieve an instance of `TestConfig` and `MyService` automatically:
public function new(config : TestConfig, service : MyService) {
}
}
- After defining your application dependencies, you can create the service provider for injecting the services into your application:
var provider = collection.createProvider();
var testService = provider.getService(MyService);
// Do stuff with testService
- Singleton, Transient, and Scoped services are supported. Singleton returns the same instance on every getService call, Transient will return a new instance every time, and Scoped will return the same instance within a scope:
var collection = new ServiceCollection();
collection.addSingleton(TestService, NormalTestService);
collection.addTransient(TestService, LoudTestService);
collection.addScoped(TestService, LoudTestService);
Suppose we want to make a program that runs both on Windows and on Chrome. We can do this at start up:
public static function main() {
var collection = new ServiceCollection();
#if js
collection.addSingleton(RenderService, WebGLRenderService);
#else
collection.addTransient(RenderService, OpenGL3RenderService);
#end
var provider = collection.createProvider();
var renderer = provider.getService(RenderService);
renderer.run();
}
While this may seem overkill for a simple application such as this, the true benefits of this library become apparent as an application grows in complexity. It prevents the laborious effort of manually typing out the dependency chain.
Furthermore, this approach satisfies the SOLID principles and prevents platform-specific branching from being hidden away in functions; here it is exposed at the top level in the composition root (in this case, Main
) and makes it very obvious what the capabilities of your application are. It also makes for extending your application to new platforms trivial.
- Allow for configuration encryption and different configuration implementations per platform
- Move away from reflection and build the dependency trees at compile time using macros
- Convince the Haxe Foundation to add a DI container to the standard library
It is possible to add services of the same type to a collection by simply registering multiple dependencies like so:
public static function main() {
var collection = new ServiceCollection();
collection.addSingleton(MyService, ConcreteService1);
collection.addSingleton(MyService, ConcreteService2);
collection.addSingleton(SomeService);
var provider = collection.createProvider();
}
During constructor resolution, if you reference the type via an iterable like so:
final class SomeService implements Service {
public function new(services : Iterable<MyService>) {
trace(services);
}
}
it will resolve to an iterator of all implementations of that service, i.e. [ConcreteService1, ConcreteService2]
.
Occasionally useful for resolving scoped services such as in web applications and event busses, one can inject the ServiceProvider itself into any dependency:
final class SomeService implements Service {
public function new(provider : ServiceProvider) {
trace(provider); // Singleton instance of the provider as created by the collection
}
}
This should be avoided lest we stumble into the Service Locator anti-pattern.
One can construct a service and add it to the collection like so:
public static function main() {
var collection = new ServiceCollection();
collection.addSingleton(MyService, new ConcreteService());
var provider = collection.createProvider();
}
It is useful to be able to configure our application with respect to a collection of .json
files and environment variables. This can be done by adding a folder of a chosen name to your project root, and then pointing to the root using the configuration builder like so:
var builder = ConfigurationBuilder.from('MyConfigFolder');
Jsons and environment variables can then be added to the builder:
builder.addJson('test.json');
builder.addEnvVar('haxepath');
We can then build our configuration file using builder.build()
and then retrieve values like so:
var config = builder.build();
var myString = config.getString('key.nestedkey');
var myIntArray = config.getIntArray('key.array');
var haxePath = config.getString('haxepath');
where nested values from the Json file can be retrieved using a key1.key2.key3
syntax and so on.
Services that expect type parameters, such as MyService<Int, Float>
can be added to the service collection like so:
collection.addSingleton(Generic.of(MyService, Int, Float), SubService);
The provider will then map these services into classes that require MyService
with the same type signature:
class SomeService implements Service {
// Will recieve SubService...
public function new(service : MyService<Int, Float>) {}
}
While the term generic
is used, this library currently does not support generics and is used as a placeholder for when it does.
Sometimes it is useful to generate instances based on scope, like so:
var collection = new ServiceCollection();
collection.addScoped(AService);
collection.addSingleton(BService);
var provider = collection.createProvider();
// Same instance
provider.getService(AService); // ID: 123
// Same instance
provider.getService(BService); // ID: 764
provider.getService(BService); // ID: 764
var scope = provider.newScope();
// Same instance
scope.getService(AService); // ID: 123
// Same instance
scope.getService(BService); // ID: 945
scope.getService(BService); // ID: 945
Essentially, new instances of scoped services are created on newScope
and act like singletons within that scope.
Suppose we want to chain together services of the same type, like so:
class MyFirstService implements SomeService {
}
class MyOtherService implements SomeService {
public function new(firstService : SomeService) {}
}
// Will fail:
var collection = new ServiceCollection();
collection.addSingleton(SomeService, MyFirstService)
collection.addSingleton(SomeService, MyOtherService)
Such a definition would cause the provider throw a recursive parameter
definition. We can remedy this by binding the constructor dependencies to implementations like so:
class MyFirstService implements SomeService {
}
class MyOtherService implements SomeService {
@:binding(firstService, MyFirstService)
public function new(firstService : SomeService) {}
}
// Will WORK:
var collection = new ServiceCollection();
collection.addSingleton(SomeService, MyFirstService).asBinding()
collection.addSingleton(SomeService, MyOtherService)
The provider will use the @:binding
metadata to map the argument of name firstService
to a given implementation. We can then assign this implementation as a binding in the service collection using asBinding
.
Any service that implements the Destructable
interface, like so:
class MyFirstService implements SomeService implements Destructable {
}
will have Destroy()
called when the provider goes out of scope or is itself destroyed.