Slide 1

Slide 1 text

Functional Ruby RODAKASE and another world of Ruby web applications

Slide 2

Slide 2 text

Life with Rails !

Slide 3

Slide 3 text

→ MVC is too broad → APIs are bloated → Isolation is hard

Slide 4

Slide 4 text

Web apps are about TRANSFORMATIONS

Slide 5

Slide 5 text

┌─────────┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─────────┐ │ Request │ │ │ │ │ │ │ │ │ │Response │ │(params) │─>│*│─>│*│─>│*│─>│*│─>│ (HTML) │ │ │ │ │ │ │ │ │ │ │ │ │ └─────────┘ └─┘ └─┘ └─┘ └─┘ └─────────┘

Slide 6

Slide 6 text

Functional Ruby

Slide 7

Slide 7 text

→ Immutability by default → No side effects → Functions as values

Slide 8

Slide 8 text

Immutability

Slide 9

Slide 9 text

rc_cities = ["Perth", "Sydney", "Canberra"].freeze rc_cities << "???" # => RuntimeError: can't modify frozen Array require "ice_nine" IceNine.deep_freeze(rc_cities) rc_cities.last.upcase! # => RuntimeError: can't modify frozen String

Slide 10

Slide 10 text

require "adamantium" class RailsCamp include Adamantium attr_reader :city def initialize(city) @city = city end end rc18 = RailsCamp.new("Canberra") rc18.city.upcase! # => RuntimeError: can't modify frozen String

Slide 11

Slide 11 text

Avoid mutable state

Slide 12

Slide 12 text

Functional objects State is for collaborators and config only. Everything else goes to #call. #call receives input and returns output.

Slide 13

Slide 13 text

class SubscribeUser attr_reader :user def initialize(user) @user = user end def call SubscriptionRepository.save(user) end end

Slide 14

Slide 14 text

class SubscribeUser attr_reader :user def initialize(user) # State (Ƙюя) @user = user end def call # Coupled to a concrete class (Ň•́є•̀Ň) SubscriptionRepository.new.save(user) end end

Slide 15

Slide 15 text

# Throw-away object (䡞_䡞”) subscriber = SubscribeUser.new(user) subscriber.call

Slide 16

Slide 16 text

class SubscribeUser attr_reader :subscription_repository def initialize(subscription_repository) @subscription_repository = subscription_repository end def call(user) subscription_repository.save(user) end end

Slide 17

Slide 17 text

class SubscribeUser attr_reader :subscription_repository def initialize(subscription_repository) # Collaborators as the only state (๑˃̵ᴗ˂̵)و @subscription_repository = subscription_repository end def call(user) # Input passed in subscription_repository.save(user) end end

Slide 18

Slide 18 text

subscribe_user = SubscribeUser.new(subscription_repository) # Reusable (•̀ᴗ•́)൬༉ subscribe_user.call(user) subscribe_user.call(another_user)

Slide 19

Slide 19 text

Functional object composition

Slide 20

Slide 20 text

Dependency Injection

Slide 21

Slide 21 text

Objects > Classes

Slide 22

Slide 22 text

Container

Slide 23

Slide 23 text

require "dry-container" class MyContainer extend Dry::Container::Mixin end MyContainer.register "subscription_repository" do require "subscription_repository" SubcriptionRepository.new end MyContainer.register "subscribe_user" do require "subscribe_user" SubscribeUser.new(MyContainer["subscription_repository"]) end MyContainer["subscribe_user"].call(user)

Slide 24

Slide 24 text

✨ RODAKASE ✨

Slide 25

Slide 25 text

Rodakase → Container as central organisational structure → Simple, side-effect-less, functional objects → Routing/HTTP decoupled from core logic → Request processing as series of function calls

Slide 26

Slide 26 text

Roda + Container

Slide 27

Slide 27 text

Rodakase container

Slide 28

Slide 28 text

Auto-registration module Main class Container < Rodakase::Container setting :auto_register, %w( lib/main/operations lib/main/validation lib/main/views ) end # `Main::Views::Home` defined in lib/main/views/home.rb Main::Container["main.views.home"] # => #

Slide 29

Slide 29 text

Auto-import require "main/import" module Main class SubscribeUser include Main::Import("main.repositories.subscriptions") def call(user) subscriptions.save(user) end end end

Slide 30

Slide 30 text

Roda

Slide 31

Slide 31 text

class MyApp < Roda route do |r| # /hello branch r.on "hello" do # Set variable for all routes in /hello branch @greeting = 'Hello' # GET /hello/world request r.get "world" do "#{@greeting} world!" end end end end

Slide 32

Slide 32 text

Rodakase application module Main class Application < Rodakase::Application configure do |config| config.routes = "web/routes".freeze config.container = Container end use Rack::Session::Cookie, key: "my_app.session", secret: MyApp::Container.config.app.session_secret route do |r| r.multi_route end load_routes! end end

Slide 33

Slide 33 text

The HTTP ends here ⛔

Slide 34

Slide 34 text

routes/users.rb route "users" do |r| r.on "new" do r.is to: "main.views.users.new" end r.is to: "main.views.users.index" end

Slide 35

Slide 35 text

Rodakase views require "main/import" require "main/view" # In module Main::Views::Users class Index < Main::View include Main::Import("main.persistence.repositories.users") configure do |config| config.template = "users/index" end def locals(options = {}) {users: users.index(page: options[:page], per_page: options[:per_page])} end end

Slide 36

Slide 36 text

Templates / users/index.slim .users == users.table / users/index/_table.slim table - users.each do |user| == user.row / users/index/_row.slim tr td = user.name td = user.email

Slide 37

Slide 37 text

Transactions route "users" do |r| r.post do r.resolve "main.transactions.create_user" do |create_user| create_user.(r[:user]) do |m| m.success do r.redirect "/users" end m.failure do |errors| r.resolve "main.views.users.new" do |view| view.(params: r[:user], errors: errors) end end end end end end

Slide 38

Slide 38 text

Multi-step transaction using Call Sheet Main::Transactions.define do |t| t.define "main.transactions.create_user" do step :persist, with: "main.operations.create_user" tee :deliver_email, with: "main.operations.deliver_welcome_email" end end

Slide 39

Slide 39 text

require "kleisli" class CreateUser include Main::Import("main.validation.user_form_schema", "persistence.create_user") def call(params = {}) validation = user_form_schema.(params) if validation.messages.any? Left(validation.messages) else result = create_user.(validation.params) Right(Entities::User.new(result)) end end end

Slide 40

Slide 40 text

A short journey to Neckbeard-land

Slide 41

Slide 41 text

A short journey to Monad-land

Slide 42

Slide 42 text

That Pythagoreans say the first thing that came into existence was the monad. The monad begat the dyad, which begat the numbers, which begat the point, begetting lines or finiteness, etc. !

Slide 43

Slide 43 text

Either Left // Right

Slide 44

Slide 44 text

route "users" do |r| r.post do r.resolve "main.transactions.create_user" do |create_user| create_user.(r[:user]) do |m| m.success do r.redirect "/users" end m.failure do |errors| r.resolve "main.views.users.new" do |view| view.(params: r[:user], errors: errors) end end end end end end

Slide 45

Slide 45 text

Main::Transactions.define do |t| t.define "main.transactions.create_user" do step :persist, with: "main.operations.create_user" tee :deliver_email, with: "main.operations.deliver_welcome_email" end end

Slide 46

Slide 46 text

Persistence with ROM

Slide 47

Slide 47 text

Relations module Persistence module Relations class Users < ROM::Relation[:sql] dataset :users register_as :users use :pagination per_page 40 def by_id(id) where(id: id) end end end end

Slide 48

Slide 48 text

Repositories require "main/entities/user" module Main module Persistence module Repositories class Users < ROM::Repository relations :users def [](id) users.by_id(id).as(Entities::User).one end def by_email(email) users.where(email: email).as(Entities::User).first end def index(page: 1, per_page: 40) users.per_page(per_page).page(page).as(Entities::User) end end end end end

Slide 49

Slide 49 text

Commands require "dry-data" module Persistence module Commands class CreateUser < ROM::Commands::Create[:sql] input Dry::Data["hash"].schema( name: "string", email: "string", encrypted_password: "string", ) relation :users register_as :create result :one end end end

Slide 50

Slide 50 text

Validation

Slide 51

Slide 51 text

dry-validation require "dry-validation" require "dry/validation/schema/form" module Main module Validation class UserFormSchema < Dry::Validation::Schema::Form key(:name) { |name| name.filled? } key(:email) { |email| email.filled? } key(:age) { |age| age.int? & age.filled? & age.gt?(20) } key(:admin) { |admin| admin.bool? } confirmation(:password) end end end

Slide 52

Slide 52 text

key(:age) { |age| age.int? & age.filled? & age.gt?(18) } key(:admin) { |admin| admin.bool? }

Slide 53

Slide 53 text

result = my_schema.call("age" => "15", "admin" => "1")

Slide 54

Slide 54 text

result.messages # => {:age=>[["age must be greater than 18", 15]]}

Slide 55

Slide 55 text

result.params # => {:age => 15, :admin => true} !

Slide 56

Slide 56 text

Domain entities

Slide 57

Slide 57 text

dry-data require "dry-data" require "dry-equalizer" module Main module Entities class User < Dry::Data::Struct include Dry::Equalizer.new(:id, :name, :email, :encrypted_password) attribute :id, "int" attribute :name, "string" attribute :email, "string" attribute :encrypted_password, "string" end end end

Slide 58

Slide 58 text

Isolation

Slide 59

Slide 59 text

% rake spec Finished in 0.74344 seconds (files took 1.1 seconds to load) 11 examples, 0 failures 1.1 seconds

Slide 60

Slide 60 text

% rspec spec/unit/main/authentication/authenticate_spec.rb Finished in 0.04362 seconds (files took 0.53826 seconds to load) 3 examples, 0 failures 0.5 seconds

Slide 61

Slide 61 text

spec/spec_helper.rb spec/db_helper.rb spec/app_helper.rb

Slide 62

Slide 62 text

MyApp::Container.boot :rom

Slide 63

Slide 63 text

So many wins → Testing → Console driving → Separation of HTTP handling → Standalone views → Design → Maintainability

Slide 64

Slide 64 text

Choice

Slide 65

Slide 65 text

❤ Ruby

Slide 66

Slide 66 text

$ gem install rraygun $ rraygun -p icelab/rodakase-skeleton my_app

Slide 67

Slide 67 text

icelab/rodakase-skeleton icelab/alpinist solnic/rodakase-blog ❇ solnic/rodakase jeremyevans/roda rom-rb/rom dryrb/dry-data dryrb/dry-validation icelab/call_sheet