Dependency Injection with LittleBoxes
- Manuel Morales
- Lead Ruby development at Workshare
- 7+ years experience Ruby
- manuelmorales@gmail.com
Dependency Injection with LittleBoxes
UsersController
User
- Big classes
- Highly coupled (non re-usable)
- Adding features by changing these classes
how I started writing Ruby
Cost of change
- Many small objects
- Single Responsibility Principle
- Extend by adding objects
- Not changing them
- Open Closed Principle
a completely different picture
mantainability cost
¯\_(ツ)_/¯
you choose
SignedHttpClient
AnalyticsClient
HttpClient
AnalyticsClient
- We have one AnalyticsClient
- We have to extend the behavior to support signed requests
Dependency Injection
decoupling and re-using
HttpClient
how do we inject?
AnalyticsClient.http_client = Typhoeus
Configuring the class
analytics_client.submit(name: 'SignUp', http_client: Typhoeus)
As an argument
client = AnalyticsClient.new(http_client: Typhoeus)
Configuring the instance
The caller decides
Common in Rails with initializers
Classes are singletons
Most flexible
assumption
-
We build structure that represents your application config.
-
Let this structure build the instances
-
And configure them
-
We can achieve the picture that we imagined before
enter LittleBoxes
# Gemfile
gem 'little_boxes'
- Light library ~300 lines of code
- Provides a dependency tree that represents your application configuration
- Automatically configures your dependencies
- Lazy-loads by default
- In production for more than a year
- 8 services running
- Go-to library for new μservices in Ruby
let() is for memoizing
# box.rb
require 'little_boxes'
class MainBox
include LittleBoxes::Box
let(:logger) { Logger.new('application.log') }
end
> MainBox.new
=> #<MainBox :logger>
> box.logger
=> #<Logger:0x0056366a76c810 ...>
> box.logger
=> #<Logger:0x0056366a76c810 ...>
analytics reporter example
require 'typhoeus'
class AnalyticsService
def submit(event)
response = Typhoeus.post(
'http://example.com/events',
body: event
)
response.code == 201
end
end
> service = AnalyticsService.new
=> #<AnalyticsService:0x0055fd585398e0>
> service.submit(name: "SignUp")
=> true
manual injection
class AnalyticsService
attr_accessor :http_client
attr_accessor :server_url
def initialize(http_client:, server_url:)
@http_client = http_client
@server_url = server_url
end
def submit(event)
response = http_client.post(
"#{server_url}/events",
body: event
)
response.code == 201
end
end
> require 'typhoeus'
> service = AnalyticsService.new(http_client: Typhoeus, server_url: "http://example.com")
=> #<AnalyticsService:0x00561d6851b860 @http_client=Typhoeus, @server_url="http://example.com">
> service.submit(name: "SignUp")
=> true
Where do I put this?
config-logic separation
class AnalyticsService
attr_accessor :http_client
attr_accessor :server_url
def initialize(http_client:, server_url:)
@http_client = http_client
@server_url = server_url
end
def submit(event)
response = http_client.post(
"#{server_url}/events",
body: event
)
response.code == 201
end
end
> box = MainBox.new
=> #<MainBox :analytics_service, :http_client, :server_url>
> box.analytics_service.submit(name: "SignUp")
=> true
class MainBox
include LittleBoxes::Box
let(:analytics_service) do |box|
AnalyticsService.new(
http_client: box.http_client,
server_url: box.server_url
)
end
let(:http_client) do
require 'typhoeus'
Typhoeus
end
let(:server_url) { "https://example.com" }
end
way too much typing!
Image courtesy of giphy
way too much typing!
letc() auto-configures
class AnalyticsService
include LittleBoxes::Configurable
dependency :http_client
dependency :server_url
def submit(event)
response = http_client.post(
"#{server_url}/events",
body: event
)
response.code == 201
end
end
> box = MainBox.new
=> #<MainBox :analytics_service, :http_client, :server_url>
> box.analytics_service.submit(name: "SignUp")
=> true
class MainBox
include LittleBoxes::Box
letc(:analytics_service) do
AnalyticsService.new
end
let(:http_client) do
require 'typhoeus'
Typhoeus
end
let(:server_url) { "https://example.com" }
end
Image courtesy of giphy
class AnalyticsService
attr_accessor :http_client
attr_accessor :server_url
def initialize(http_client:, server_url:)
@http_client = http_client
@server_url = server_url
end
def submit(event)
response = http_client.post(
"#{server_url}/events",
body: event
)
response.code == 201
end
end
class MainBox
include LittleBoxes::Box
let(:analytics_service) do |box|
AnalyticsService.new(
http_client: box.http_client,
server_url: box.server_url
)
end
let(:http_client) do
require 'typhoeus'
Typhoeus
end
let(:server_url) { "https://example.com" }
end
How does this work?
> box = MainBox.new
=> #<MainBox :analytics_service, :http_client, :server_url>
class MainBox
include LittleBoxes::Box
letc(:analytics_service) { AnalyticsService.new }
let(:http_client) { require 'typhoeus'; Typhoeus }
let(:server_url) { "https://example.com" }
end
> service = box.analytics_service
=> #<AnalyticsService:0x00563aa942c1f8 @config={:box=>#<MainBox...>}>
> service.submit({})
> service
=> #<AnalyticsService:0x00563aa942c1f8 @config={:box=>#<MainBox...>,
:http_client=>Typhoeus, :server_url=>"https://example.com"}>
# class_dependency() for class dependencies
class_dependency(:logger)
# default values with callbacks
dependency(:log) { Logger.new('/dev/null') }
dependency(:log) { |box| box.logger }
# then() callbacks to override dependencies by hand
letc(:analytics) { AnalyticsService.new }.then do |service, box|
service.logger = box.debug_logger
end
# get() and getc() don't memoize
get(:service_url) { 'http://example.com' }
# eager_let, eager_get, etc. to force them to run at boot time
eager_let(:aws_sdk) do
require 'aws-sdk'
Aws.use_bundled_cert!
Aws
end
Other features
Tree structure
class MainBox
include LittleBoxes::Box
let(:logger) { Logger.new('./todos.log') }
let(:http_client) { require 'typhoeus'; Typhoeus }
let(:todos_model) { Todo }
let(:todos_create_endpoint) { TodosCreateEndpoint.new }
let(:todos_list_endpoint) { TodosListEndpoint.new }
let(:todos_delete_endpoint) { TodosDeleteEndpoint.new }
letc(:analytics_service) { AnalyticsService.new }
let(:analytics_url) { "https://example.com" }
end
> box = MainBox.new
=> #<MainBox :logger, :todos_model, :todos_create_endpoint, :todos_list_endpoint,
:todos_delete_endpoint, :analytics_service, :analytics_url, :http_client>
Tree structure
class MainBox
include LittleBoxes::Box
let(:logger) { Logger.new('./todos.log') }
let(:http_client) { require 'typhoeus'; Typhoeus }
box(:todos) do
let(:model) { Todo }
box(:endpoints) do
let(:create) { TodosCreateEndpoint.new }
let(:list) { TodosListEndpoint.new }
let(:delete) { TodosDeleteEndpoint.new }
end
end
box(:analytics) do
letc(:service) { AnalyticsService.new }
let(:url) { "https://example.com" }
end
end
> box = MainBox.new
=> #<MainBox :logger, :todos, :analytics, :http_client>
> box.todos
=> #<Box[todos] :model, :endpoints>
MainBox
todos
model
create
endpoints
list
delete
client
logger
service
url
analytics
MainBox
╟─ logger
╟─ todos
║ ╟─ model
║ ╙─ endpoints
║ ╟─ create
║ ╟─ list
║ ╙─ delete
╙─ analytics
╟─ service
╟─ client
╙─ url
Tree structure
class TodosBox
include LittleBoxes::Box
let(:model) { Todo }
box(:endpoints) do
let(:create) { TodosCreateEndpoint.new }
let(:list) { TodosListEndpoint.new }
let(:delete) { TodosDeleteEndpoint.new }
end
end
class MainBox
include LittleBoxes::Box
box :todos, TodosBox
box :analytics, AnalyticsBox
let(:http_client) { require 'typhoeus'; Typhoeus }
let(:logger) { Logger.new('./todos.log') }
end
> box = MainBox.new
=> #<MainBox :logger, :todos, :analytics, :http_client>
> box.todos
=> #<Box[todos] :model, :endpoints>
- Easy to explore
- No namespacing
- Resolution bubbles up
- Overrides at sub-tree
Demo app
Demo app
manuel@manuel-XPS-L322X:~/ws/todo$ ls -l
total 44
-rwxr-xr-x 1 manuel manuel 99 Jan 13 14:49 cli
-rw-rw-r-- 1 manuel manuel 56 Mar 5 08:31 config.ru
-rw-rw-r-- 1 manuel manuel 44 Mar 4 19:17 config-sample.yml
-rw-rw-r-- 1 manuel manuel 44 Mar 4 19:34 config.yml
-rw-rw-r-- 1 manuel manuel 213 Mar 5 08:42 Gemfile
-rw-rw-r-- 1 manuel manuel 1157 Mar 5 08:39 Gemfile.lock
drwxrwxr-x 3 manuel manuel 4096 Mar 4 21:10 lib
-rw-rw-r-- 1 manuel manuel 31 Mar 5 08:31 Procfile
-rw-rw-r-- 1 manuel manuel 256 Mar 5 08:55 Rakefile
-rw-rw-r-- 1 manuel manuel 1251 Mar 5 09:02 README.md
drwxrwxr-x 4 manuel manuel 4096 Mar 12 21:46 spec
manuel@manuel-XPS-L322X:~/ws/todo$ ./cli test
....................................................
Finished in 0.14894 seconds (files took 0.17862 seconds to load)
52 examples, 0 failures
manuel@manuel-XPS-L322X:~/ws/todo$ ./cli console
Use `box = Todo::Box.new` to inspect the app itself
[1] pry(main)> box = Todo::Box.new
=> #<Todo::Box :app, :cfg, :rack, :todos>
[2] pry(main)> box.todos
=> #<Box[todos] :entity, :repo, :endpoints>
[3] pry(main)> box.todos.repo
=> #<Todo::TodosRepo:0x0055ac0f718db8 @config={:new_todo=>#<Method: Class#new>}>
[4] pry(main)> box.todos.repo.find_all
=> []
How to Test?
Unit tests
Integration tests
- TodosAppBox
- TodosBox
- UsersBox
- TodosMapper
- TodosRepo
- UsersMapper
- UsersRepo
RSpec.describe AnalyticsService do
let(:service) do
AnalyticsService.new http_client: http_client, server_url: url
end
let(:url) { 'http://example.com' }
let(:event) { {name: 'UserCreated'} }
let(:http_client) { double(:http_client, post: response) }
let(:response) { double(:response, code: 201) }
it 'posts the event' do
expect(http_client).
to receive(:post).
with('http://example.com/events', body: event)
service.submit event
end
end
Unit testing
- Test business logic
- Fully isolated
- Superfast
require_relative '../helper'
require 'rack/test'
RSpec.describe 'API' do
include Rack::Test::Methods
let(:box) { TodosBox.new }
let(:app) { box.rack_app }
describe 'GET /todos' do
it 'returns 200' do
get '/todos'
expect(last_response.status).to eq 200
end
end
end
Integration testing
- Test the configuration indirectly
- The box is new for each test
- There cannot be leaks of configuration
- Lazy-loading allows the tests to be fast
Drawbacks
- Debugging and exploring code becomes more tedious
- Class => Class => Class
- Class => Box => Class => Box => Class
- Takes time to get used to
Summary
- All dependencies injected at an application level by default
- Objects define what they need
- Separates configuration from business logic
- Behavior is extended by adding objects, not changing them
- Behavior is changed by moving objects and configuring them
- Load things only when needed, not all at the beginning
- We rely on instances much more than we do in classes
...
a completely different way
Questions?
- manuelmorales@gmail.com
- @manuelmorales twitter
- github.com/manuelmorales
Dependency Injection with Little Boxes
By Manuel Morales
Dependency Injection with Little Boxes
A brief description of the benefits of dependency injection and how to gain them with Little Boxes.
- 837