Upgrade to Pro — share decks privately, control downloads, hide ads and more …

[RailsConf 2023] Rails as a piece of cake

[RailsConf 2023] Rails as a piece of cake

Video: https://www.youtube.com/watch?v=fANjY7Hn_ig

---

Ruby on Rails as a framework follows the Model-View-Controller design pattern. Three core elements, like the number of layers in a traditional birthday cake, are enough to “cook” web applications. However, on the long haul, the Rails cake often resembles a crumble cake with the layers smeared and crumb-bugs all around the kitchen-codebase.

Similarly to birthday cakes, adding new layers is easier to do and maintain as the application grows than increasing the existing layers in size.

How to extract from or add new layers to a Rails application? What considerations should be taken into account? Why is rainbow cake the king of layered cakes? Join my talk to learn about the layering Rails approach to keep applications healthy and maintainable.

Vladimir Dementyev

April 24, 2023
Tweet

More Decks by Vladimir Dementyev

Other Decks in Programming

Transcript

  1. 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
  2. 7

  3. 12

  4. 13

  5. 20

  6. palkan_tula palkan 25 Rails Abstraction Generalization Encapsulation Loose Coupling Testability

    Centralization Simplification Single Responsibility Reusability Conventions
  7. 26 Abstraction layer for Rails cake 1. Rails conventions Learn

    how Rails work, re-use patterns and building blocks.
  8. 29

  9. 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: ?
  10. 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
  11. 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 ???
  12. 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
  13. 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.
  14. 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.
  15. 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
  16. 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
  17. 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
  18. 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
  19. 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
  20. 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
  21. 49

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

    — "app/services"—bag of random objects —Intermediate stage until the final abstraction emerges 51
  23. 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
  24. 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
  25. 56

  26. 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
  27. 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 #
  28. 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
  29. 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
  30. 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
  31. 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 $
  32. Jobs Service Objects Application Mailers Domain Infrastructure Models DB /

    API / etc Controllers Presentation Channels Views
  33. 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
  34. 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
  35. 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
  36. 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
  37. 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
  38. palkan_tula palkan — Type casting, validations — Trigger side actions

    on successful submission — Compatibility with the view layer 71 Form object → Rails abstraction
  39. 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
  40. 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
  41. 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)
  42. 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
  43. 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
  44. 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
  45. 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 %> <form action="/invitations" method="post">
  46. 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
  47. 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
  48. 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
  49. 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
  50. 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)
  51. 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
  52. 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
  53. 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
  54. 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.
  55. 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!
  56. Controllers Channels Presentation Views Application Jobs Presenters Form objects Filter

    objects Deliveries Authorization Policies Event Listeners Interactors
  57. Mailers Domain Infrastructure Models Adapters (DB, mail) API clients Deliveries

    Notifiers Interactors Query objects Configuration objects Value objects Service objects
  58. 84