DIY DI in Ruby

103
1/103

Transcript of DIY DI in Ruby

1/103

MeNikita Shilnikov

• github.com/flash-gordon

2/103

MeNikita Shilnikov

• github.com/flash-gordon

3/103

MeNikita Shilnikov

• github.com/flash-gordon

• dry-rb and rom-rb core team member

4/103

DI in Ruby

5/103

What, why, how

6/103

What?

7/103

Inversion of Control

8/103

Event-based programming (JavaScript)

9/103

Dependency Injection

10/103

class CreateUser def call(name) user_repo.create(name: name) endend

11/103

class CreateUser def call(name) user_repo.create(name: name) endend

12/103

undefined local variable or method user_repo

13/103

undefined local variable or method user_repo

14/103

Local variable

def call(user_repo, name) user_repo.create(name: name)end

15/103

Method

def user_repo # dependency resolutionend

def call(name) user_repo.create(name: name)end

16/103

Constant (or singleton)

def user_repo UserRepo.newend

17/103

Constant

Pros:

• Dumb simple

• Easy to follow

• You don't need a method

Cons:

• High coupling

• Hard to test (?)

• Less extendable18/103

Method

attr_accessor :user_repo

def initialize(user_repo) @user_repo = user_repoend

19/103

Method

let(:fake_repo) { FakeRepo.new }let(:create_user) { CreateUser.new(fake_repo) }

example do create_user.(name: 'Jade')end

20/103

Why?

21/103

22/103

S O L I D

23/103

Dependency Inversion Principle

24/103

Dependency Inversion PrincipleA. High-level modules should not depend on low-level modules. Both should depend on abstractions.B. Abstractions should not depend on details. Details should depend on abstractions.

25/103

26/103

Think about interfaces

27/103

Think about interfaces• External services (network)

• Message queues

• Background jobs

28/103

Harder to write, easier to use and support

29/103

Not harder in Ruby

30/103

How?

31/103

class CreateUser attr_reader :user_repo

def initialize(user_repo) @user_repo = user_repo endend

32/103

ContainerContainer = { user_repo: UserRepo.new }

def initialize(user_repo = nil) @user_repo = user_repo || Container[:user_repo]end

33/103

create_user = CreateUser.new(UserRepo.new)

34/103

create_user = CreateUser.new(UserRepo.new)create_user = CreateUser.new

35/103

create_user = CreateUser.new(UserRepo.new)create_user = CreateUser.newcreate_user = CreateUser.new(FakeRepo.new)

36/103

create_user = CreateUser.new(UserRepo.new)create_user = CreateUser.newcreate_user = CreateUser.new(FakeRepo.new)

before { Container[:user_repo] = FakeRepo.new }

let(:create_user) { CreateUser.new }

example { create_user.(name: 'Jade') }

37/103

Loading order problem

38/103

Loading order

Container = { user_repo: UserRepo.new, create_user: CreateUser.new(?)}

39/103

Loading order

Conainter = { user_repo: -> { UserRepo.new }, create_user: -> { CreateUser.new }}

40/103

Loading order

Conainter = { user_repo: -> { UserRepo.new }, create_user: -> { CreateUser.new }}

user = Container[:create_user].call.call(name: 'Jade')

41/103

dry-container

42/103

dry-container

Container = Dry::Container.newContainer.register(:user_repo) { UserRepo.new }Container.register(:create_user) { CreateUser.new }

43/103

dry-container

Container[:create_user]

44/103

dry-container

Container[:create_user]CreateUser.new

45/103

dry-container

Container[:create_user]CreateUser.newContainer[:user_repo]UserRepo.new

46/103

dry-container

Container[:create_user]CreateUser.newContainer[:user_repo]UserRepo.new

47/103

dry-container

Dir['repositories/*.rb'].each do |path| key = path.sub('repositories/', '').sub('.rb', '') class_name = Inflecto.camelize(key)

Container.register(key) do require path

Inflecto.contantize(class_name).new endend

48/103

dry-container

Dir['repositories/*.rb'].each do |path| key = path.sub('repositories/', '').sub('.rb', '') class_name = Inflecto.camelize(key)

Container.register(key) do require path

Inflecto.contantize(class_name).new endend

49/103

dry-container

Dir['repositories/*.rb'].each do |path| key = path.sub('repositories/', '').sub('.rb', '') class_name = Inflecto.camelize(key)

Container.register(key) do require path

Inflecto.contantize(class_name).new endend

50/103

dry-container

Container.enable_stubs!

around do |ex| Container.stub(:user_repo, FakeRepo.new) { ex.run }end

51/103

dry-container

Container.register('user_repo', memoize: true) { UserRepo.new}

52/103

class CreateUser attr_reader :user_repo, :user_created_worker

def initialize(user_repo: Container[:user_repo], user_created_worker: Container[:user_created_worker]) @user_repo = user_repo @user_created_worker = user_created_worker endend

53/103

Boilerplate!

54/103

dry-auto_inject

55/103

• Needs a container

• Creates a constructor

56/103

dry-auto_inject

Import = Dry::AutoInject(Container)

57/103

dry-auto_inject

class CreateUserWithAccount include Import['repo.user_repo', 'workers.user_created']

def call(name) user = user_repo.create(name: name) user_created.(user.user_id) user endend

58/103

dry-auto_inject

class CreateUserWithAccount include Import['repo.user_repo', 'workers.user_created']

def call(name) user = user_repo.create(name: name) user_created.(user.user_id) user endend

59/103

dry-auto_inject

Plays nice with inheritance

60/103

dry-auto_inject

Compatible with existing constructors

61/103

dry-auto_inject

class CreateUserFromParams attr_reader :params

def initialize(params) @param = params end

def create UserRepo.new.create(params) endend

62/103

dry-auto_inject

class CreateUserFromParams include Import['user_repo']

attr_reader :params

def initialize(params, **other) super(other)

@param = params endend

63/103

Cost

• One line with explicitly defined dependencies

64/103

Benefits

• You don't need to think about building objects

• All code is built on the same principles

• It's far easier to write testable code

• You, most likely, will write functional-ish code

• Speeds up tests in a natural way

65/103

What if?

66/103

dry-system

67/103

dry-systemContainer + injector + loading mechanism

68/103

dry-system• Extends $LOAD_PATH

• Uses require to load files

• Registers dependencies automatically

• Builds dependencies using name conventions

• Can split an application into sub-apps

69/103

dry-systemclass Application < Dry::System::Container configure do |config| config.root = Pathname('./app') endend

70/103

dry-systemclass Application < Dry::System::Container configure do |config| config.root = Pathname('./app')

config.auto_register = 'lib' end

load_paths!('lib')end

71/103

dry-systemApplication.register('utils.logger', Logger.new($stdout))Application.finalize!Application['utils.logger']

72/103

dry-systemImport = Application.injector

class CreateUser include Import['repos.user_repo', 'utils.logger']

def call(name) # ... endend

73/103

Stateful componentsApplication.finalize(:persistence) do |container| start do require 'sequel' conn = Sequel.connect(ENV['DB_URL']) container.register('persistence.db', conn) end

stop do db.disconnect endend

74/103

Stateful componentsbefore(:all) do Application.start(:persistence)end

75/103

dry-system• May not play nice with Rails (but who knows)

• Good for new projects

76/103

Future• Instrumenting code

• Debugging tools

77/103

Application graph

78/103

Application graph

79/103

Application graph• web.services.create_user [640ms]

• user_repo.create [10ms]• web.view.user_created [215ms]

• external.billing.create_user [278ms]

80/103

Bonus

81/103

Singleton containerImport = Dry::AutoInject(Container)

class CreateUser include Import['repo.user_repo']end

82/103

Sharing the contextlogger = TaggedLogger.new(env['X-Request-ID'])

83/103

Sharing the contextApplication.finalize!logger = TaggedLogger.new(env['X-Request-ID'])Application.register('logger', logger) # => Error

84/103

Sharing the contextApplication.register_abstract('logger')Application.finalize!

85/103

Sharing the contextApplication.register_abstract('logger')Application.finalize!logger = TaggedLogger.new(env['X-Request-ID'])AppWithLogger = Application.provide(logger: logger)

86/103

Sharing the contextAppWithLogger[:create_user] # => #<CreateUser logger=#<Logger>>

87/103

Sharing the contextImport = Dry::AutoInject(Application)

class CreateUser include Import['logger']end

88/103

Passing the container as a dependency

Container.register('create_user') { CreateUser.new}

89/103

Passing the container as a dependency

Container.register('create_user') { |app_with_logger| CreateUser.new(container: app_with_logger)}

90/103

Zero global state(literally)

91/103

92/103

Cons:

• Can't be memoized

• Can violate boundaries

93/103

Cons:

• Can't be memoized

• Can violate boundaries

Pros:

94/103

Cons:

• Can't be memoized

• Can violate boundaries

Pros:

• Reduces code duplcation

95/103

Cons:

• Can't be memoized

• Can violate boundaries

Pros:

• Reduces code duplcation

• Simplifies context sharing

96/103

Cons:

• Can't be memoized

• Can violate boundaries

Pros:

• Reduces code duplcation (no need to pass arguments)

• Simplifies context sharing

• Kills global state

97/103

Recap

98/103

Recap• DI is a way to implement the dependency inversion

principle

• This makes your code easier to test and write

• Can break an application into sub-apps

• Can remove the global state as a whole (100% FP)

• Can be done in Ruby in one evening

• Doesn't have any major disadvantages *99/103

DI 101• Replace hard dependencies with interfaces (plain Ruby)

• Put dependencies into a container (dry-container)

• Inject dependencies automatically (dry-auto_inject)

• Organize application code in modules (dry-system)

100/103

Caveats

• Don't put every single thing into a container

• Make dependencies pure (aka thread-safe)

• Keep interfaces simple

• Injecting a dozen of deps at once is bad, m'kay?

• Fear new abstractions

101/103

Try it!

102/103

Thank you

• github.com/flash-gordon

• dry-rb.org

• dry-rb/dry-container

• dry-rb/dry-auto_inject

• dry-rb/dry-system

• dry-rb/dry-web-blog

103/103