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