Slide 1

Slide 1 text

Vladimir Dementyev Evil Martians RAILS AS A PIECE OF BIRTHDAY CAKE

Slide 2

Slide 2 text

palkan_tula palkan 2 guides.rubyonrails.org

Slide 3

Slide 3 text

palkan_tula palkan –Application Programming in Smalltalk-80 (TM), Steve Burbeck, 1987 “In the MVC paradigm the user input, the modeling of the external world, and the visual feedback to the user are explicitly separated and handled by three types of object, each specialized for its task.” 3

Slide 4

Slide 4 text

The model class concerns itself only with the application's state and logic

Slide 5

Slide 5 text

The view class concerns itself only with creating the user interface

Slide 6

Slide 6 text

The controller class is occupied solely with translating user input into updates that it passes to the model

Slide 7

Slide 7 text

palkan_tula palkan 5 Rails vs MVC Model View Controller

Slide 8

Slide 8 text

palkan_tula palkan 5 Rails vs MVC Model View Controller

Slide 9

Slide 9 text

palkan_tula palkan 6 Rails vs MVC View Controller Model

Slide 10

Slide 10 text

palkan_tula palkan 6 Rails vs MVC View Controller Model Un-separation of concerns

Slide 11

Slide 11 text

7

Slide 12

Slide 12 text

palkan_tula palkan 8 MVC cake

Slide 13

Slide 13 text

palkan_tula palkan Mature MVC cake 8

Slide 14

Slide 14 text

palkan_tula palkan 9 Beyond MVC cake

Slide 15

Slide 15 text

palkan_tula palkan 10 github.com/palkan

Slide 16

Slide 16 text

palkan_tula palkan 11 github.com/palkan

Slide 17

Slide 17 text

12

Slide 18

Slide 18 text

13

Slide 19

Slide 19 text

evilmartians.com/events

Slide 20

Slide 20 text

Layers on Rails

Slide 21

Slide 21 text

palkan_tula palkan 16 Rails Way Request Response

Slide 22

Slide 22 text

palkan_tula palkan 17 Rails Way Model Controller View Request Response

Slide 23

Slide 23 text

palkan_tula palkan 18 Extended Rails Way Model Controller View ? Request Response

Slide 24

Slide 24 text

palkan_tula palkan 19 Maintainability Readability Testability Coupling Cohesion Extensibility Flexibility Complexity Reusability

Slide 25

Slide 25 text

20

Slide 26

Slide 26 text

palkan_tula palkan 21 Bad abstractions Good abstractions

Slide 27

Slide 27 text

Abstractions on Rails Railroad at Murnau, Wassily Kandinsky

Slide 28

Slide 28 text

23 Abstraction layer for Rails cake

Slide 29

Slide 29 text

palkan_tula palkan 24 Abstraction Generalization Encapsulation Loose Coupling Testability Centralization Simplification Single Responsibility Reusability

Slide 30

Slide 30 text

palkan_tula palkan 25 Rails Abstraction Generalization Encapsulation Loose Coupling Testability Centralization Simplification Single Responsibility Reusability Conventions

Slide 31

Slide 31 text

26 Abstraction layer for Rails cake 1. Rails conventions Learn how Rails work, re-use patterns and building blocks.

Slide 32

Slide 32 text

palkan_tula palkan 27 Layered Architecture Presentation Application Domain Infrastructure

Slide 33

Slide 33 text

palkan_tula palkan 28 Layered Architecture Presentation Application Domain Infrastructure !

Slide 34

Slide 34 text

29

Slide 35

Slide 35 text

palkan_tula palkan 30 Layered Architecture Presentation Application Domain Infrastructure

Slide 36

Slide 36 text

palkan_tula palkan 30 Layered Architecture Presentation Application Domain Infrastructure

Slide 37

Slide 37 text

Controllers Presentation Channels Views Application Jobs Mailers Domain Infrastructure Models Adapters (DB, mail) API clients

Slide 38

Slide 38 text

palkan_tula palkan 32 class Authenticator def call(request) auth_header = request.headers["Authorization"] raise "Missing auth header" unless auth_header token = auth_header.split(" ").last raise "No token found" unless token JWT.decode( token, Rails.application.secrets.secret_key_base ).then do User.find(_1["user_id"]) if _1["user_id"] end end end Layer: ?

Slide 39

Slide 39 text

palkan_tula palkan 33 class Authenticator def call(request) auth_header = request.headers["Authorization"] raise "Missing auth header" unless auth_header token = auth_header.split(" ").last raise "No token found" unless token JWT.decode( token, Rails.application.secrets.secret_key_base ).then do User.find(_1["user_id"]) if _1["user_id"] end end end Presentation Layer: Presentation

Slide 40

Slide 40 text

palkan_tula palkan 34 class Authenticator def call(request) auth_header = request.headers["Authorization"] raise "Missing auth header" unless auth_header token = auth_header.split(" ").last raise "No token found" unless token JWT.decode( token, Rails.application.secrets.secret_key_base ).then do User.find(_1["user_id"]) if _1["user_id"] end end end Presentation Infrastructure Layer: Presentation ???

Slide 41

Slide 41 text

palkan_tula palkan 35 class Authenticator def call(token) raise "No token found" unless token JWT.decode( token, Rails.application.secrets.secret_key_base ).then do User.find(_1["user_id"]) if _1["user_id"] end end end Layer: Application

Slide 42

Slide 42 text

palkan_tula palkan An object belongs to the highest architecture layer among all its inputs and dependencies 36

Slide 43

Slide 43 text

37 Abstraction layer for Rails cake 1. Rails conventions Learn how Rails work, re-use patterns and building blocks. 2. Layered architecture ideas Identify the target arch. layer; avoid mixing too many layers.

Slide 44

Slide 44 text

palkan_tula palkan How to choose new abstractions? 38

Slide 45

Slide 45 text

palkan_tula palkan How to choose new extract abstractions? 39

Slide 46

Slide 46 text

40 Abstraction layer for Rails cake 1. Rails conventions Learn how Rails work, re-use patterns and building blocks. 2. Layered architecture ideas Identify the target arch. layer; avoid mixing too many layers. 3. Сodebase extracts, no artificial concepts Perform complexity analysis, analyze and extract abstractions.

Slide 47

Slide 47 text

Extraction Time

Slide 48

Slide 48 text

class GithooksController < ApplicationController def create event = JSON.parse(request.raw_post, symbolize_names: true) login = event.dig(:issue, :user, :login) || event.dig(:pull_request, :user, :login) User.find_by(gh_id: login) &.handle_github_event(event) if login head :ok end end 42 Case #1: Models vs. webhooks

Slide 49

Slide 49 text

class User < ApplicationRecord def handle_github_event(event) case event in type: "issue", action: "opened", issue: {user: {login:}, title:, body:} issues.create!(title:, body:) in type: "pull_request", action: "opened", pull_request: { user: {login:}, base: {label:}, title:, body: } pull_requests.create!(title:, body:, branch:) end end end 43

Slide 50

Slide 50 text

class User < ApplicationRecord def handle_github_event(event) case event in type: "issue", action: "opened", issue: {user: {login:}, title:, body:} issues.create!(title:, body:) in type: "pull_request", action: "opened", pull_request: { user: {login:}, base: {label:}, title:, body: } pull_requests.create!(title:, body:, branch:) end end end Hash originated in the outer world 43

Slide 51

Slide 51 text

class GitHubEvent def self.parse(raw_event) parsed = JSON.parse(raw_event, symbolize_names: true) case parsed[:type] when "issue" Issue.new( # ... ) when "pull_request" PR.new( # ... ) end rescue JSON::ParserError nil end Issue = Data.define(:user_id, :action, :title, :body) PR = Data.define(:user_id, :action, :title, :body, :branch) end 44

Slide 52

Slide 52 text

class GithooksController < ApplicationController def create event = GitHubEvent.parse(request.raw_post) User.find_by(gh_id: event.user_id) &.handle_github_event(event) if event head :ok end end 45

Slide 53

Slide 53 text

class User < ApplicationRecord def handle_github_event(event) case event in GitHubEvent::Issue[action: "opened", title:, body:] issues.create!(title:, body:) in GitHubEvent::PR[ action: "opened", title:, body:, branch: ] pull_requests.create!(title:, body:, branch:) end end end 46

Slide 54

Slide 54 text

palkan_tula palkan Maintainability " — Controller: not ad-hoc hacks, less churn — Model: no knowledge of the outer world — Webhook payload access is encapsulated and localized 47

Slide 55

Slide 55 text

palkan_tula palkan Should a model be responsible for handling webhooks at all? 48

Slide 56

Slide 56 text

49

Slide 57

Slide 57 text

Controllers Presentation Channels Views Application Jobs Mailers Domain Infrastructure Models Adapters (DB, mail) API clients

Slide 58

Slide 58 text

Controllers Presentation Channels Views Application Jobs Mailers Domain Infrastructure Models Adapters (DB, mail) Service Objects

Slide 59

Slide 59 text

palkan_tula palkan Service Objects — Pseudo abstraction layer (generalization, consistency) 51

Slide 60

Slide 60 text

palkan_tula palkan Service Objects — Pseudo abstraction layer (generalization, consistency) — "app/services"—bag of random objects 51

Slide 61

Slide 61 text

palkan_tula palkan Service Objects — Pseudo abstraction layer (generalization, consistency) — "app/services"—bag of random objects —Intermediate stage until the final abstraction emerges 51

Slide 62

Slide 62 text

palkan_tula palkan 52 Service objects ~ waiting room sms_sender.rb rss_service.rb remind_user.rb auth_service.rb post/publish.rb

Slide 63

Slide 63 text

palkan_tula palkan To "app/services" or not to "app/services"? — Don't start early with abstractions → better generalization requires a bit of aging — Don't overcrowd "app/services" 53

Slide 64

Slide 64 text

class GithooksController < ApplicationController def create event = GitHubEvent.parse(request.raw_post) GithubEventHandler.call(event) if event head :ok end end 54

Slide 65

Slide 65 text

class GithubEventHandler def self.call(event) user = User.find_by(gh_id: event.user_id) return false unless user case event in GitHubEvent::Issue[action: "opened", title:, body:] user.issues.create!(title:, body:) in GitHubEvent::PR[ action: "opened", title:, body:, branch: ] user.pull_requests.create!(title:, body:, branch:) else # ignore unknown events end true end end 55

Slide 66

Slide 66 text

56

Slide 67

Slide 67 text

palkan_tula palkan 57 Case #2: Models vs. forms

Slide 68

Slide 68 text

58 class User < ApplicationRecord attribute :should_send_invitation, :boolean after_commit :send_invitation, if: :should_send_invitation, on: :create def send_invitation UserMailer.invite(self).deliver_later end end

Slide 69

Slide 69 text

59 class User < ApplicationRecord attribute :should_send_invitation, :boolean after_commit :send_invitation, if: :should_send_invitation, on: :create def send_invitation UserMailer.invite(self).deliver_later end end Layered Arch: Sending emails from models? Is it even legal #

Slide 70

Slide 70 text

60 class User < ApplicationRecord attribute :should_send_invitation, :boolean after_commit :send_invitation, if: :should_send_invitation, on: :create def send_invitation UserMailer.invite(self).deliver_later end end Layered Arch: Sending emails from models? Is it even legal # Context-specific information now a part of the domain model

Slide 71

Slide 71 text

61 class User < ApplicationRecord attribute :should_send_invitation, :boolean after_commit :send_invitation, if: :should_send_invitation, on: :create def send_invitation UserMailer.invite(self).deliver_later end end >= Application Layer

Slide 72

Slide 72 text

class InvitationsController < ApplicationController def create @user = User.new(params.require(:user).permit(:email)) @user.should_send_invitation = true if @user.save if params[:send_copy] == "1" UserMailer.invite_copy(current_user, @user) .deliver_later end redirect_to root_path, notice: "Invited!" else render :new end end end 62

Slide 73

Slide 73 text

class InvitationsController < ApplicationController def create @user = User.new(params.require(:user).permit(:email)) @user.should_send_invitation = true if @user.save if params[:send_copy] == "1" UserMailer.invite_copy(current_user, @user) .deliver_later end redirect_to root_path, notice: "Invited!" else render :new end end end 63 Leaking abstraction $

Slide 74

Slide 74 text

palkan_tula palkan Where can we localize the invitation form logic? 64

Slide 75

Slide 75 text

Jobs Service Objects Application Mailers Domain Infrastructure Models DB / API / etc Controllers Presentation Channels Views

Slide 76

Slide 76 text

Jobs Service Objects Application Mailers Domain Models Form objects Controllers Presentation Channels Views

Slide 77

Slide 77 text

class UserInvitationForm attr_reader :user, :send_copy, :sender def initialize(email, send_copy: false, sender: nil) @user = User.new(email:) @send_copy = send_copy.in?(%w[1 t true]) @sender = sender end def save return false unless user.valid? user.save! deliver_notifications! true end def deliver_notifications! UserMailer.invite(user).deliver_later if send_copy UserMailer.invite_copy(sender, user).deliver_later end end end

Slide 78

Slide 78 text

class UserInvitationForm attr_reader :user, :send_copy, :sender def initialize(email, send_copy: false, sender: nil) @user = User.new(email:) @send_copy = send_copy.in?(%w[1 t true]) @sender = sender end def save return false unless user.valid? user.save! deliver_notifications! true end def deliver_notifications! UserMailer.invite(user).deliver_later if send_copy UserMailer.invite_copy(sender, user).deliver_later end end end Manual type-casting

Slide 79

Slide 79 text

palkan_tula palkan class InvitationsController < ApplicationController def create form = UserInvitationForm.new( params.require(:user).permit(:email)[:email], params[:send_copy], current_user ) if form.save redirect_to root_path, notice: "Invited!" else @user = form.user render :new end end end 68

Slide 80

Slide 80 text

class InvitationsController < ApplicationController def create form = UserInvitationForm.new( params.require(:user).permit(:email)[:email], params[:send_copy], current_user ) if form.save redirect_to root_path, notice: "Invited!" else @user = form.user render :new end end end 69 Two sets of params Hack to re-use templates + leaking internals

Slide 81

Slide 81 text

class InvitationsController < ApplicationController def create form = UserInvitationForm.new( params.require(:user).permit(:email)[:email], params[:send_copy], current_user ) if form.save redirect_to root_path, notice: "Invited!" else @user = form.user render :new end end end 70 Two sets of params Hack to re-use templates + leaking internals

Slide 82

Slide 82 text

palkan_tula palkan — Type casting, validations — Trigger side actions on successful submission — Compatibility with the view layer 71 Form object → Rails abstraction

Slide 83

Slide 83 text

palkan_tula palkan Form object → Rails abstraction — Type casting, validations → ActiveModel::API + ActiveModel::Attributes — Trigger side actions on successful submission → ActiveSupport::Callbacks — Compatibility with the view layer → ActiveModel::Name + conventions 72

Slide 84

Slide 84 text

class InvitationForm < ApplicationForm attribute :email attribute :send_copy, :boolean attr_accessor :sender validates :email, presence: true after_commit :deliver_invitation after_commit :deliver_invitation_copy, if: :send_copy def submit! @user = User.new(email:) @user.save! end def deliver_invitation UserMailer.invite(@user).deliver_later end def deliver_invitation_copy UserMailer.invite_copy(sender, @user).deliver_later if sender end end

Slide 85

Slide 85 text

class InvitationForm < ApplicationForm attribute :email attribute :send_copy, :boolean attr_accessor :sender validates :email, presence: true after_commit :deliver_invitation after_commit :deliver_invitation_copy, if: :send_copy def submit! @user = User.new(email:) @user.save! end def deliver_invitation UserMailer.invite(@user).deliver_later end def deliver_invitation_copy UserMailer.invite_copy(sender, @user).deliver_later if sender end end Form fields (w/types)

Slide 86

Slide 86 text

class InvitationForm < ApplicationForm attribute :email attribute :send_copy, :boolean attr_accessor :sender validates :email, presence: true after_commit :deliver_invitation after_commit :deliver_invitation_copy, if: :send_copy def submit! @user = User.new(email:) @user.save! end def deliver_invitation UserMailer.invite(@user).deliver_later end def deliver_invitation_copy UserMailer.invite_copy(sender, @user).deliver_later if sender end end Form fields (w/types) Core logic

Slide 87

Slide 87 text

class InvitationForm < ApplicationForm attribute :email attribute :send_copy, :boolean attr_accessor :sender validates :email, presence: true after_commit :deliver_invitation after_commit :deliver_invitation_copy, if: :send_copy def submit! @user = User.new(email:) @user.save! end def deliver_invitation UserMailer.invite(@user).deliver_later end def deliver_invitation_copy UserMailer.invite_copy(sender, @user).deliver_later if sender end end Form fields (w/types) Core logic Trigger actions

Slide 88

Slide 88 text

palkan_tula palkan 74 class InvitationsController < ApplicationController def new @invitation_form = InvitationForm.new end def create @invitation_form = InvitationForm.new( params.require(:invitation).permit(:email, :send_copy) ) @invitation_form.sender = current_user if @invitation_form.save redirect_to root_path else render :new, status: :unprocessable_entity end end end

Slide 89

Slide 89 text

palkan_tula palkan 75 <%= form_for(@invitation_form) do |form| %> <%= form.label :email %> <%= form.text_field :email %> <%= form.label :send_copy, "Send me the copy" %> <%= form.check_box :send_copy %> <%= form.submit "Invite" %> <% end %>

Slide 90

Slide 90 text

class ApplicationForm include ActiveModel::API include ActiveModel::Attributes define_callbacks :save, only: :after define_callbacks :commit, only: :after def save return false unless valid? with_transaction do AfterCommitEverywhere.after_commit { run_callbacks(:commit) } run_callbacks(:save) { submit! } end end def model_name ActiveModel::Name.new(nil, nil, self.class.name.sub(/Form$/, "")) end private def with_transaction(&) = ApplicationRecord.transaction(&) def submit! raise NotImplementedError end end

Slide 91

Slide 91 text

class ApplicationForm include ActiveModel::API include ActiveModel::Attributes define_callbacks :save, only: :after define_callbacks :commit, only: :after def save return false unless valid? with_transaction do AfterCommitEverywhere.after_commit { run_callbacks(:commit) } run_callbacks(:save) { submit! } end end def model_name ActiveModel::Name.new(nil, nil, self.class.name.sub(/Form$/, "")) end private def with_transaction(&) = ApplicationRecord.transaction(&) def submit! raise NotImplementedError end end Types and validations

Slide 92

Slide 92 text

class ApplicationForm include ActiveModel::API include ActiveModel::Attributes define_callbacks :save, only: :after define_callbacks :commit, only: :after def save return false unless valid? with_transaction do AfterCommitEverywhere.after_commit { run_callbacks(:commit) } run_callbacks(:save) { submit! } end end def model_name ActiveModel::Name.new(nil, nil, self.class.name.sub(/Form$/, "")) end private def with_transaction(&) = ApplicationRecord.transaction(&) def submit! raise NotImplementedError end end Types and validations Callbacks

Slide 93

Slide 93 text

class ApplicationForm include ActiveModel::API include ActiveModel::Attributes define_callbacks :save, only: :after define_callbacks :commit, only: :after def save return false unless valid? with_transaction do AfterCommitEverywhere.after_commit { run_callbacks(:commit) } run_callbacks(:save) { submit! } end end def model_name ActiveModel::Name.new(nil, nil, self.class.name.sub(/Form$/, "")) end private def with_transaction(&) = ApplicationRecord.transaction(&) def submit! raise NotImplementedError end end Types and validations Callbacks Transaction-awareness

Slide 94

Slide 94 text

class ApplicationForm include ActiveModel::API include ActiveModel::Attributes define_callbacks :save, only: :after define_callbacks :commit, only: :after def save return false unless valid? with_transaction do AfterCommitEverywhere.after_commit { run_callbacks(:commit) } run_callbacks(:save) { submit! } end end def model_name ActiveModel::Name.new(nil, nil, self.class.name.sub(/Form$/, "")) end private def with_transaction(&) = ApplicationRecord.transaction(&) def submit! raise NotImplementedError end end Types and validations Callbacks Transaction-awareness Action View compatibility (InvitationForm → /invitations)

Slide 95

Slide 95 text

class ApplicationForm include ActiveModel::API include ActiveModel::Attributes define_callbacks :save, only: :after define_callbacks :commit, only: :after def save return false unless valid? with_transaction do AfterCommitEverywhere.after_commit { run_callbacks(:commit) } run_callbacks(:save) { submit! } end end def model_name ActiveModel::Name.new(nil, nil, self.class.name.sub(/Form$/, "")) end private def with_transaction(&) = ApplicationRecord.transaction(&) def submit! raise NotImplementedError end end Types and validations Callbacks Transaction-awareness Action View compatibility (InvitationForm → /invitations) Interface

Slide 96

Slide 96 text

class ApplicationForm include ActiveModel::API include ActiveModel::Attributes define_callbacks :save, only: :after define_callbacks :commit, only: :after def save return false unless valid? with_transaction do AfterCommitEverywhere.after_commit { run_callbacks(:commit) } run_callbacks(:save) { submit! } end end def model_name ActiveModel::Name.new(nil, nil, self.class.name.sub(/Form$/, "")) end private def with_transaction(&) = ApplicationRecord.transaction(&) def submit! raise NotImplementedError end end Types and validations Callbacks Transaction-awareness Action View compatibility Interface

Slide 97

Slide 97 text

palkan_tula palkan — Type casting, validations ✅ — Trigger side actions on successful submission ✅ — Compatibility with the view layer ✅ — Strong parameters compatibility — DX (test matchers, generators) 78 Form object → Rails abstraction

Slide 98

Slide 98 text

79 Abstraction layer for Rails cake 1. Rails conventions Learn how Rails work, re-use patterns and building blocks. 2. Layered architecture ideas Identify the target arch. layer; avoid mixing too many layers. 3. Сodebase extracts, no artificial concepts Perform complexity analysis, analyze and extract abstractions.

Slide 99

Slide 99 text

79 Abstraction layer for Rails cake 1. Rails conventions Learn how Rails work, re-use patterns and building blocks. 2. Layered architecture ideas Identify the target arch. layer; avoid mixing too many layers. 3. Сodebase extracts, no artificial concepts Perform complexity analysis, analyze and extract abstractions. Feel free to experiment and add ingredients from other paradigms and ecosystems!

Slide 100

Slide 100 text

palkan_tula palkan How many layers is enough? 80

Slide 101

Slide 101 text

Controllers Channels Presentation Views Application Jobs Presenters Form objects Filter objects Deliveries Authorization Policies Event Listeners Interactors

Slide 102

Slide 102 text

Mailers Domain Infrastructure Models Adapters (DB, mail) API clients Deliveries Notifiers Interactors Query objects Configuration objects Value objects Service objects

Slide 103

Slide 103 text

84

Slide 104

Slide 104 text

The Book Coming Oct 2023

Slide 105

Slide 105 text

@palkan @palkan_tula evilmartians.com @evilmartians Thanks!