Shirinji
Dependencies Injection made clean and easy for Ruby.
Supported ruby versions
Principles
Remove hard dependencies between your objects and delegate object tree building
to an unobtrusive framework with cool convention over configuration.
Shirinji relies on a mapping of beans and a resolver. When you resolve a bean,
it will return (by default) an instance of the class associated to the bean,
with all the bean dependencies resolved.
class FooService
attr_reader :bar_service
def initialize(bar_service:)
@bar_service = bar_service
end
def call(obj)
obj.foo = 123
bar_service.call(obj)
end
end
map = Shirinji::Map.new do
bean(:foo_service, klass: 'FooService')
bean(:bar_service, klass: 'BarService')
end
resolver = Shirinji::Resolver.new(map)
resolver.resolve(:foo_service)
Shirinji is unobtrusive. Basically, any of your objects can be used
outside of its context.
bar_service = BarService.new
foo_service = FooService.new(bar_service: bar_service)
RSpec.describe FooService do
let(:bar_service) { double(call: nil) }
let(:service) { described_class.new(bar_service: bar_service) }
describe '.call' do
end
end
Constructor arguments
Shirinji relies on constructor to inject dependencies. It's considering that
objects that receive dependencies should be immutables and those dependencies
should not change during your program lifecycle.
Shirinji doesn't accept anything else than named parameters. This way,
arguments order doesn't matter and it makes everybody's life easier.
Name resolution
By default, when you try to resolve a bean, Shirinji will look for a bean named
accordingly for each constructor parameter.
It's possible to locally override this behaviour though by using attr
macro.
class FooService
attr_reader :bar_service
def initialize(my_service:)
@bar_service = my_service
end
end
map = Shirinji::Map.new do
bean(:foo_service, klass: 'FooService') do
attr :my_service, ref: :bar_service
end
bean(:bar_service, klass: 'BarService')
end
resolver = Shirinji::Resolver.new(map)
resolver.resolve(:foo_service)
Caching and singletons
Shirinji provides a caching mecanism to help you improve memory consumption.
This cache is safe as long as your beans remains immutable (they should always
be).
The consequence is that any cached instance is actually a singleton. Singleton
is no more a property of your class but of it's environment, improving the
reusability of your code.
Singleton is the default access mode for a bean.
map = Shirinji::Map.new do
bean(:bar_service, klass: 'BarService', access: :instance)
bean(:foo_service, klass: 'FooService', access: :singleton)
end
resolver = Shirinji::Resolver.new(map)
resolver.resolve(:foo_service).object_id
resolver.resolve(:foo_service).object_id
resolver.resolve(:bar_service).object_id
resolver.resolve(:bar_service).object_id
Cache can be reset with the simple command resolver.reset_cache
, which can be
useful when using a development console like rails console (shirinji-rails is attaching cache reset to reload!
command).
Other type of beans
Dependencies injection doesn't apply only to classes. You can actually inject
anything and therefore, Shirinji allows you to declare anything as a dependency.
To achieve that, use the key value
instead of class
.
module MyApp
def self.config
@config
end
def self.load!
@config = OpenStruct.new
end
end
class FooService
attr_reader :config
def initialize(config:)
@config = config
end
end
MyApp.load!
map = Shirinji::Map.new do
bean(:config, value: Proc.new { MyApp.config })
bean(:foo_service, klass: 'FooService')
end
resolver = Shirinji::Resolver.new(map)
resolver.resolve(:foo_service)
A value can be anything. Proc
will be lazily evaluated. It also obeys the
cache mechanism described before.
Skip construction mechanism
In some cases, you need a dependency to be injected as a class and not an
instance. In such case, you could use value beans, returning the class itself,
but you would lose the benefit of scopes (see below).
Instead, Shirinji provides a parameter to skip the object construction.
A real life example is a Job where deliver_now
and deliver_later
are
class methods.
map = Shirinji::Map.new do
bean(:foo_job, klass: 'FooJob', construct: false)
end
resolver = Shirinji::Resolver.new(map)
resolver.resolve(:foo_job)
Scopes
Building complex objects mapping leads to lot of repetition. That's why Shirinji
also provides a scope mechanism to help you dry your code.
map = Shirinji::Map.new do
scope module: :Services, suffix: :service, klass_suffix: :Service do
bean(:foo, klass: 'Foo')
scope module: :User, prefix: :user do
bean(:bar, klass: 'Bar')
end
end
end
Scopes also come with an auto_klass
attribute to save even more time for
common cases
map = Shirinji::Map.new do
scope module: :Services,
suffix: :service,
klass_suffix: :Service,
auto_klass: true do
bean(:foo)
end
end
Scopes also provides an auto_prefix
option
map = Shirinji::Map.new do
scope module: :Services,
suffix: :service,
klass_suffix: :Service,
auto_klass: true do
scope auto_prefix: true do
bean(:foo)
scope module: :User do
bean(:bar)
end
end
end
end
Finally, for mailers / jobs ..., Scopes allow you to specify a global value
for construct
map = Shirinji::Map.new do
scope module: :Jobs,
suffix: :job,
klass_suffix: :Job,
auto_klass: true,
construct: false do
bean(:foo)
end
end
Scopes do not carry property access
Code splitting
When a project grows, dependencies grows too. Keeping them into one single file
leads to headaches. One possible solution to keep everything under control is
to split your dependencies into many files.
To include a "sub-map" into another one, you can use include_map
method.
Shirinji::Map.new do
bean(:foo_service, klass: 'FooService')
end
Shirinji::Map.new do
bean(:foo_query, klass: 'FooQuery')
end
root = Pathname.new(File.expand_path('../dependencies', __FILE__))
Shirinji::Map.new do
bean(:config, value: -> { MyApp.config })
include_map(root.join('queries.rb'))
include_map(root.join('services.rb'))
end
Notes
- It is absolutely mandatory for your beans to be stateless to use the singleton
mode. If they're not, you will probably run into trouble as your objects
behavior will depend on their history, leading to unpredictable effects.
- Shirinji only works with named arguments. It will raise
ArgumentError
if you
try to use it with "standard" method arguments.
TODOS
- solve absolute paths problems for
include_map
(instance_eval
is a problem)
Contributing
Bug reports and pull requests are welcome on GitHub at
https://github.com/fdutey/shirinji.