annotation Athena::DependencyInjection::Register
Overview
Registers a service based on the type the annotation is applied to.
The type of the service affects how it behaves within the container. When a struct
service is retrieved or injected into a type, it will be a copy of the one in the SC (passed by value).
This means that changes made to it in one type, will NOT be reflected in other types. A class
service on the other hand will be a reference to the one in the SC. This allows it
to share state between services.
Optional Arguments
In most cases, the annotation can be applied without additional arguments. However, the annotation accepts a handful of optional arguments to fine tune how the service is registered.
name : String
- The name of the service. Should be unique. Defaults to the type's FQN snake cased.public : Bool
- If the service should be directly accessible from the container. Defaults tofalse
.public_alias : Bool
- If a service should be directly accessible from the container via an alias. Defaults tofalse
.lazy : Bool
- If the service should be lazily instantiated. I.e. only instantiated when it is first accessed; either directly or as a dependency of another service. Defaults totrue
.alias : T
- Injectsself
when this type is used as a type restriction. See the Aliasing Services example for more information.tags : Array(String | NamedTuple(name: String, priority: Int32?))
- Tags that should be assigned to the service. Defaults to an empty array. See the Tagging Services example for more information.
Examples
Basic Usage
The simplest usage involves only applying the ADI::Register
annotation to a type. If the type does not have any arguments, then it is simply registered as a service as is. If the type does have arguments, then an attempt is made to register the service by automatically resolving dependencies based on type restrictions.
@[ADI::Register]
# Register a service without any dependencies.
struct ShoutTransformer
def transform(value : String) : String
value.upcase
end
end
@[ADI::Register(public: true)]
# The ShoutTransformer is injected based on the type restriction of the `transformer` argument.
struct SomeAPIClient
def initialize(@transformer : ShoutTransformer); end
def send(message : String)
message = @transformer.transform message
# ...
end
end
ADI.container.some_api_client.send "foo" # => FOO
Aliasing Services
An important part of DI is building against interfaces as opposed to concrete types. This allows a type to depend upon abstractions rather than a specific implementation of the interface. Or in other words, prevents a singular implementation from being tightly coupled with another type.
We can use the alias
argument when registering a service to tell the container that it should inject this service when a type restriction for the aliased service is found.
# Define an interface for our services to use.
module TransformerInterface
abstract def transform(value : String) : String
end
@[ADI::Register(alias: TransformerInterface)]
# Alias the `TransformerInterface` to this service.
struct ShoutTransformer
include TransformerInterface
def transform(value : String) : String
value.upcase
end
end
@[ADI::Register]
# Define another transformer type.
struct ReverseTransformer
include TransformerInterface
def transform(value : String) : String
value.reverse
end
end
@[ADI::Register(public: true)]
# The `ShoutTransformer` is injected because the `TransformerInterface` is aliased to the `ShoutTransformer`.
struct SomeAPIClient
def initialize(@transformer : TransformerInterface); end
def send(message : String)
message = @transformer.transform message
# ...
end
end
ADI.container.some_api_client.send "foo" # => FOO
Any service that uses TransformerInterface
as a dependency type restriction will get the ShoutTransformer
.
However, it is also possible to use a specific implementation while still building against the interface. The name of the constructor argument is used in part to resolve the dependency.
@[ADI::Register(public: true)]
# The `ReverseTransformer` is injected because the constructor argument's name matches the service name of `ReverseTransformer`.
struct SomeAPIClient
def initialize(reverse_transformer : TransformerInterface)
@transformer = reverse_transformer
end
def send(message : String)
message = @transformer.transform message
# ...
end
end
ADI.container.some_api_client.send "foo" # => oof
Scalar Arguments
The auto registration logic as shown in previous examples only works on service dependencies. Scalar arguments, such as Arrays, Strings, NamedTuples, etc, must be defined manually.
This is achieved by using the argument's name prefixed with a _
symbol as named arguments within the annotation.
@[ADI::Register(_shell: ENV["SHELL"], _config: {id: 12_i64, active: true}, public: true)]
struct ScalarClient
def initialize(@shell : String, @config : NamedTuple(id: Int64, active: Bool)); end
end
ADI.container.scalar_client # => ScalarClient(@config={id: 12, active: true}, @shell="/bin/bash")
Arrays can also include references to services by prefixing the name of the service with an @
symbol.
module Interface; end
@[ADI::Register]
struct One
include Interface
end
@[ADI::Register]
struct Two
include Interface
end
@[ADI::Register]
struct Three
include Interface
end
@[ADI::Register(_services: ["@one", "@three"], public: true)]
struct ArrayClient
def initialize(@services : Array(Interface)); end
end
ADI.container.array_client # => ArrayClient(@services=[One(), Three()])
While scalar arguments cannot be auto registered by default, the Athena::DependencyInjection.bind
macro can be used to support it. For example: ADI.bind shell, "bash"
.
This would now inject the string "bash"
whenever an argument named shell
is encountered.
Tagging Services
Services can also be tagged. Service tags allows another service to have all services with a specific tag injected as a dependency.
A tag consists of a name, and additional metadata related to the tag.
Currently the only supported metadata value is priority
, which controls the order in which the services are injected; the higher the priority
the sooner in the array it would be. In the future support for custom tag metadata will be implemented.
The Athena::DependencyInjection.auto_configure
macro may also be used to make working with tags easier.
PARTNER_TAG = "partner"
@[ADI::Register(_id: 1, name: "google", tags: [{name: PARTNER_TAG, priority: 5}])]
@[ADI::Register(_id: 2, name: "facebook", tags: [PARTNER_TAG])]
@[ADI::Register(_id: 3, name: "yahoo", tags: [{name: "partner", priority: 10}])]
@[ADI::Register(_id: 4, name: "microsoft", tags: [PARTNER_TAG])]
# Register multiple services based on the same type. Each service must give define a unique name.
record FeedPartner, id : Int32
@[ADI::Register(_services: "!partner", public: true)]
# Inject all services with the `"partner"` tag into `self`.
class PartnerClient
def initialize(@services : Array(FeedPartner)); end
end
ADI.container.partner_client # =>
# #<PartnerClient:0x7f43c0a1ae60
# @services=
# [FeedPartner(@id=3, @name="Yahoo"),
# FeedPartner(@id=1, @name="Google"),
# FeedPartner(@id=2, @name="Facebook"),
# FeedPartner(@id=4, @name="Microsoft")]>
While tagged services cannot be injected automatically by default, the Athena::DependencyInjection.bind
macro can be used to support it. For example: ADI.bind partners, "!partner"
.
This would now inject all services with the partner
tagged when an argument named partners
is encountered.
A type restriction can also be added to the binding to allow reusing the name. See the documentation for Athena::DependencyInjection.bind
for an example.
Optional Services
Services defined with a nillable type restriction are considered to be optional. If no service could be resolved from the type, then nil
is injected instead.
Similarly, if the argument has a default value, that value would be used instead.
struct OptionalMissingService
end
@[ADI::Register]
struct OptionalExistingService
end
@[ADI::Register(public: true)]
class OptionalClient
getter service_missing, service_existing, service_default
def initialize(
@service_missing : OptionalMissingService?,
@service_existing : OptionalExistingService?,
@service_default : OptionalMissingService | Int32 | Nil = 12
); end
end
ADI.container.optional_client
# #<OptionalClient:0x7fe7de7cdf40
# @service_default=12,
# @service_existing=OptionalExistingService(),
# @service_missing=nil>
Generic Services
Generic arguments can be provided as positional arguments within the ADI::Register
annotation.
NOTE Services based on generic types MUST explicitly provide a name via the name
field within the ADI::Register
annotation
since there wouldn't be a way to tell them apart from the class name alone.
@[ADI::Register(Int32, Bool, name: "int_service", public: true)]
@[ADI::Register(Float64, Bool, name: "float_service", public: true)]
struct GenericService(T, B)
def type
{T, B}
end
end
ADI.container.int_service.type # => {Int32, Bool}
ADI.container.float_service.type # => {Float64, Bool}