Skip to content

annotation Athena::DependencyInjection::Register #

Automatically 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.
  • factory : String | Tuple(T, String) - Use a factory type/method to create the service. See the Factories section.
  • public : Bool - If the service should be directly accessible from the container. Defaults to false.
  • alias : Array(T) - Injects self when any of these types are 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.
  • calls : Array(Tuple(String, Tuple(T))) - Calls that should be made on the service after its instantiated.

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.

The ADI::AsAlias annotation can be used to define a default implementation for an interface. Checkout the annotation's docs for more information.

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.

Tip

Checkout ADI::AutoconfigureTag for an easy way to tag services.

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(public: true)]
class PartnerClient
  getter services : Enumerable(FeedPartner)

  def initialize(@[ADI::TaggedIterator(PARTNER_TAG)] @services : Enumerable(FeedPartner)); end
end

ADI.container.partner_client.services.to_a # =>
# [FeedPartner(@id=3),
#  FeedPartner(@id=1),
#  FeedPartner(@id=2),
#  FeedPartner(@id=4)]

The ADI::TaggedIterator annotation provides an easy way to inject services with a specific tag to a specific parameter.

Service Calls#

Service calls can be defined that will call a specific method on the service, with a set of arguments. Use cases for this are generally not all that common, but can sometimes be useful.

@[ADI::Register(public: true, calls: [
  {"foo"},
  {"foo", {3}},
  {"foo", {6}},
])]
class CallClient
  getter values = [] of Int32

  def foo(value : Int32 = 1)
    @values << value
  end
end

ADI.container.call_client.values # => [1, 3, 6]

Service Proxies#

In some cases, it may be a bit "heavy" to instantiate a service that may only be used occasionally. To solve this, a proxy of the service could be injected instead. The instantiation of proxied services are deferred until a method is called on it.

A service is proxied by changing the type signature of the service to be of the ADI::Proxy(T) type, where T is the service to be proxied.

@[ADI::Register]
class ServiceTwo
  getter value = 123

  def initialize
    pp "new s2"
  end
end

@[ADI::Register(public: true)]
class ServiceOne
  getter service_two : ADI::Proxy(ServiceTwo)

  # Tells `ADI` that a proxy of `ServiceTwo` should be injected.
  def initialize(@service_two : ADI::Proxy(ServiceTwo))
    pp "new s1"
  end

  def run
    # At this point service_two hasn't been initialized yet.
    pp "before value"

    # First method interaction with the proxy instantiates the service and forwards the method to it.
    pp @service_two.value
  end
end

ADI.container.service_one.run
# "new s1"
# "before value"
# "new s2"
# 123
Tagged Services Proxies#

Tagged services may also be injected as an array of proxy objects. This can be useful as an easy way to manage a collection of services where only one (or a small amount) will be used at a time.

@[ADI::Register(_services: "!some_tag")]
class SomeService
  def initialize(@services : Array(ADI::Proxy(ServiceType)))
  end
end
Proxy Metadata#

The ADI::Proxy object also exposes some metadata related to the proxied object; such as its name, type, and if it has been instantiated yet.

For example, using ServiceTwo:

# Assume this returns a `ADI::Proxy(ServiceTwo)`.
proxy = ADI.container.service_two

proxy.service_id    # => "service_two"
proxy.service_type  # => ServiceTwo
proxy.instantiated? # => false
proxy.value         # => 123
proxy.instantiated? # => true

Parameters#

Reusable configuration parameters can be injected directly into services using the same syntax as when used within ADI.configure. Parameters may be supplied either via Athena::DependencyInjection.bind or an explicit service argument.

ADI.configure({
  parameters: {
    "app.name":              "My App",
    "app.database.username": "administrator",
  },
})

ADI.bind db_username, "%app.database.username%"

@[ADI::Register(_app_name: "%app.name%", public: true)]
record SomeService, app_name : String, db_username : String

service = ADI.container.some_service
service.app_name    # => "My App"
service.db_username # => "USERNAME"

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}

Factories#

In some cases it may be necessary to use the factory design pattern to handle creating an object as opposed to creating the object directly. In this case the factory argument can be used.

Factory methods are class methods defined on some type; either the service itself or a different type. Arguments to the factory method are provided as they would if the service was being created directly. This includes auto resolved service dependencies, and scalar underscore based arguments included within the ADI::Register annotation.

Same Type#

A String factory value denotes the method name that should be called on the service itself to create the service.

# Calls `StringFactoryService.double` to create the service.
@[ADI::Register(_value: 10, public: true, factory: "double")]
class StringFactoryService
  getter value : Int32

  def self.double(value : Int32) : self
    new value * 2
  end

  def initialize(@value : Int32); end
end

ADI.container.string_factory_service.value # => 20

Using the ADI::Inject annotation on a class method is equivalent to providing that method's name as the factory value. For example, this is the same as the previous example:

@[ADI::Register(_value: 10, public: true)]
class StringFactoryService
  getter value : Int32

  @[ADI::Inject]
  def self.double(value : Int32) : self
    new value * 2
  end

  def initialize(@value : Int32); end
end

ADI.container.string_factory_service.value # => 20
Different Type#

A Tuple can also be provided as the factory value to allow using an external type's factory method to create the service. The first item represents the factory type to use, and the second item represents the method that should be called.

class TestFactory
  def self.create_tuple_service(value : Int32) : TupleFactoryService
    TupleFactoryService.new value * 3
  end
end

# Calls `TestFactory.create_tuple_service` to create the service.
@[ADI::Register(_value: 10, public: true, factory: {TestFactory, "create_tuple_service"})]
class TupleFactoryService
  getter value : Int32

  def initialize(@value : Int32); end
end

ADI.container.tuple_factory_service.value # => 30