Slide 1

Slide 1 text

DROP YOUR app/services! Vladimir Dementyev Evil Martians

Slide 2

Slide 2 text

! Find 2 hot takes on this slide

Slide 3

Slide 3 text

! Find 2 hot takes on this slide " Rails' MVC is not MVC " You don't need a server to run a Rails app #WASM

Slide 4

Slide 4 text

RAILS WAY Canonical examples tree -d -L 1 fizzy/app fizzy/app/ ├── assets ├── channels ├── controllers ├── helpers ├── javascript ├── jobs ├── mailers ├── models └── views 10 directories ➜ tree -d -L 1 writebook/app writebook/app/ ├── assets ├── channels ├── controllers ├── helpers ├── javascript ├── jobs ├── mailers ├── models └── views 10 directories ➜ tree -d -L 1 campfire/app campfire/app/ ├── assets ├── channels ├── controllers ├── helpers ├── javascript ├── jobs ├── models └── views 9 directories ➜ Folders are categories

Slide 5

Slide 5 text

Where do I put this? app/models app/controllers

Slide 6

Slide 6 text

➜tree -d -L 1 random-rails-app/app RAILS WAY Real life, though...

Slide 7

Slide 7 text

➜ tree -d -L 1 random-rails-app/app random-rails-app/app/ ├── assets ├── controllers ├── ... ├── mailers ├── models ├── services ├── ... └── views 18 directories RAILS WAY Real life, though... ➜ tree -d -L 1 another-rails-app/app another-rails-app/app/ ├── controllers ├── graphql ├── ... ├── models ├── services ├── ... ├── views └── workers 14 directories ➜ tree -d -L 1 one-more-rails-app/app one-more-rails-app/app/ ├── assets ├── channels ├── controllers ├── javascript ├── jobs ├── models ├── services └── views 8 directories

Slide 8

Slide 8 text

➜ tree -d -L 1 random-rails-app/app random-rails-app/app/ ├── assets ├── controllers ├── ... ├── mailers ├── models ├── services ├── ... └── views 18 directories RAILS WAY Real life, though... ➜ tree -d -L 1 another-rails-app/app another-rails-app/app/ ├── controllers ├── graphql ├── ... ├── models ├── services ├── ... ├── views └── workers 14 directories ➜ tree -d -L 1 one-more-rails-app/app one-more-rails-app/app/ ├── assets ├── channels ├── controllers ├── javascript ├── jobs ├── models ├── services └── views 8 directories

Slide 9

Slide 9 text

No content

Slide 10

Slide 10 text

No content

Slide 11

Slide 11 text

No content

Slide 12

Slide 12 text

No content

Slide 13

Slide 13 text

WHAT IS SERVICE OBJECT?

Slide 14

Slide 14 text

“Service is a standalone operation within the context of your domain. Service Object collects one or more services into an object” –Eric Evans, DDD

Slide 15

Slide 15 text

“A Service Layer defines an application's boundary and encapsulates the application's business logic, controlling transactions and coordinating responses in the implementation of its operations” –Martin Fowler

Slide 16

Slide 16 text

–(Most) Rails devs “A callable PORO lying in between controllers and models.”

Slide 17

Slide 17 text

“If it doesn't fit anywhere, put it into app/services.” –Also (most) Rails devs

Slide 18

Slide 18 text

“If it doesn't fit anywhere, put it into app/services.” –Also (most) Rails devs Stop thinking in folders, think in abstractions!

Slide 19

Slide 19 text

github.com/palkan

Slide 20

Slide 20 text

evilmartians.com

Slide 21

Slide 21 text

evilmartians.com/events

Slide 22

Slide 22 text

No content

Slide 23

Slide 23 text

“The app/services folder could be seen as a waiting room for code.”

Slide 24

Slide 24 text

THE WAITING ROOM From faceless objects to specialized abstractions

Slide 25

Slide 25 text

THE WAITING ROOM Start with faceless service objects just for the sake of separation of concerns

Slide 26

Slide 26 text

THE WAITING ROOM Start with faceless service objects just for the sake of separation of concerns Decompose app/services into specialized abstractions as the codebase evolves

Slide 27

Slide 27 text

THE WAITING ROOM Start with faceless service objects just for the sake of separation of concerns Decompose app/services into specialized abstractions as the codebase evolves Most teams stop here

Slide 28

Slide 28 text

DROP THEIR APP/SERVICES

Slide 29

Slide 29 text

DROP THEIR APP/SERVICES 5 OSS Rails apps Different products, different generations, different patterns All with the app/services waiting rooms worth sorting out Examples of misused/overused/underused services

Slide 30

Slide 30 text

RANDOM JUNK DROP CANDIDATES #1

Slide 31

Slide 31 text

RANDOM JUNK Pure infra wrappers View helpers Useless extractions Developer tools Truly random stuff Objects not even pretending to be services

Slide 32

Slide 32 text

# app/services/utils/pdf_attachment_service.rb module Utils class PdfAttachmentService < BaseService def initialize(file:, attachment:) @file = file @attachment = attachment super end def call return result.not_found_failure!(resource: "file") unl return result.not_allowed_failure!(code: "not_a_pdf_fi success = Kernel.system("pdfcpu", "attach", "add", fil if success result.file = file else result.third_party_failure!(third_party: "pdfcpu", e end result end private attr_reader :file, :attachment end end CLI wrapper No app-specific logic

Slide 33

Slide 33 text

# app/services/utils/pdf_attachment_service.rb module Utils class PdfAttachmentService < BaseService def initialize(file:, attachment:) @file = file @attachment = attachment super end def call return result.not_found_failure!(resource: "file") unl return result.not_allowed_failure!(code: "not_a_pdf_fi success = Kernel.system("pdfcpu", "attach", "add", fil if success result.file = file else result.third_party_failure!(third_party: "pdfcpu", e end result end private attr_reader :file, :attachment end end CLI wrapper No app-specific logic Uses BaseService interface (result) → not used!

Slide 34

Slide 34 text

# app/services/utils/pdf_attachment_service.rb module Utils class PdfAttachmentService < BaseService def initialize(file:, attachment:) @file = file @attachment = attachment super end def call return result.not_found_failure!(resource: "file") unl return result.not_allowed_failure!(code: "not_a_pdf_fi success = Kernel.system("pdfcpu", "attach", "add", fil if success result.file = file else result.third_party_failure!(third_party: "pdfcpu", e end result end private attr_reader :file, :attachment end end CLI wrapper No app-specific logic Uses BaseService interface (result) → not used! ↳ Demote to app/lib

Slide 35

Slide 35 text

# app/services/utils/pdf_attachment_service.rb module Utils class PdfAttachmentService < BaseService def initialize(file:, attachment:) @file = file @attachment = attachment super end def call return result.not_found_failure!(resource: "file") unl return result.not_allowed_failure!(code: "not_a_pdf_fi success = Kernel.system("pdfcpu", "attach", "add", fil if success result.file = file else result.third_party_failure!(third_party: "pdfcpu", e end result end private attr_reader :file, :attachment end end CLI wrapper No app-specific logic Uses BaseService interface (result) → not used! ↳ Demote to app/lib A typical junk drawer

Slide 36

Slide 36 text

# app/services/flavor_text_service.rb class FlavorTextService include ActionView::Helpers::NumberHelper def initialize(user: nil, env: Rails.env, deterministic: true) # ... end def generate = sample def development_flavor_texts [ "Hack the Bank Mode", # ~50 phrases ] end def birthday_flavor_texts = [...] # ~50 phrases def holiday_flavor_texts = [...] # ~50 phrases def spooky_flavor_texts = [...] # ~100 phrases def flavor_texts = [...] # ~200 phrases private def sample return development_flavor_texts.sample(random: @random) if dev? return holiday_flavor_texts.sample(random: @random) if winter? return @random.rand > 0.5 ? spooky_flavor_texts.sample(random: @random) : flavor_texts.sample(random: @random) if fall? return birthday_flavor_texts.sample(random: @random) if @user&.birthday? flavor_texts.sample(random: @random) end end Random phrase generator Corpus is embedded into code Knows a bit about a user ↳ app/helpers + config/ locales/flavor.yml

Slide 37

Slide 37 text

# app/services/wildcard_url_checker.rb module WildcardUrlChecker def self.check_url(url, url_to_check) return false if !valid_url?(url_to_check) escaped_url = Regexp.escape(url).sub("\\*", ' url_regex = Regexp.new("\\A#{escaped_url}\\z" url_to_check.match?(url_regex) end private def self.valid_url?(url) uri = URI.parse(url) uri&.scheme.present? && uri&.host.present? rescue URI::InvalidURIError false end end Just a predicate method

Slide 38

Slide 38 text

# app/services/wildcard_url_checker.rb module WildcardUrlChecker def self.check_url(url, url_to_check) return false if !valid_url?(url_to_check) escaped_url = Regexp.escape(url).sub("\\*", ' url_regex = Regexp.new("\\A#{escaped_url}\\z" url_to_check.match?(url_regex) end private def self.valid_url?(url) uri = URI.parse(url) uri&.scheme.present? && uri&.host.present? rescue URI::InvalidURIError false end end # app/models/user_api_client.rb class UserApiKeyClient < ActiveRecord::Base # ... def self.invalid_auth_redirect?(auth_redirect, clie return false if client&.auth_redirect == auth_red SiteSetting .allowed_user_api_auth_redirects .split("|") .none? { |u| WildcardUrlChecker.check_url(u, au end end Just a predicate method Used in a single model

Slide 39

Slide 39 text

# app/services/wildcard_url_checker.rb module WildcardUrlChecker def self.check_url(url, url_to_check) return false if !valid_url?(url_to_check) escaped_url = Regexp.escape(url).sub("\\*", ' url_regex = Regexp.new("\\A#{escaped_url}\\z" url_to_check.match?(url_regex) end private def self.valid_url?(url) uri = URI.parse(url) uri&.scheme.present? && uri&.host.present? rescue URI::InvalidURIError false end end # app/models/user_api_client.rb class UserApiKeyClient < ActiveRecord::Base # ... def self.invalid_auth_redirect?(auth_redirect, clie return false if client&.auth_redirect == auth_red SiteSetting .allowed_user_api_auth_redirects .split("|") .none? { |u| WildcardUrlChecker.check_url(u, au end end Just a predicate method Used in a single model ↳ Demote to a model method

Slide 40

Slide 40 text

And more random stuff...

Slide 41

Slide 41 text

# app/services/destroy_task.rb require "highline" require "io/console" class DestroyTask def initialize(io = STDOUT) @io = io @tty = IO.console @hl = HighLine.new(@tty, @io) end def destroy_topics(category, parent_category = nil, delete_system_topics = fal # ... end end And more random stuff... A Rake task handler

Slide 42

Slide 42 text

# app/services/destroy_task.rb require "highline" require "io/console" class DestroyTask def initialize(io = STDOUT) @io = io @tty = IO.console @hl = HighLine.new(@tty, @io) end def destroy_topics(category, parent_category = nil, delete_system_topics = fal # ... end end # app/utils/timezone.rb module Utils class Timezone def self.at_time_zone_sql(customer: "customers", billing_entity: "billing_en <<-SQL ::timestamptz AT TIME ZONE COALESCE(#{customer}.timezone, #{billing_enti SQL end end end And more random stuff... A Rake task handler... A SQL fragment generator???

Slide 43

Slide 43 text

# app/services/destroy_task.rb require "highline" require "io/console" class DestroyTask def initialize(io = STDOUT) @io = io @tty = IO.console @hl = HighLine.new(@tty, @io) end def destroy_topics(category, parent_category = nil, delete_system_topics = fal # ... end end # app/utils/timezone.rb module Utils class Timezone def self.at_time_zone_sql(customer: "customers", billing_entity: "billing_en <<-SQL ::timestamptz AT TIME ZONE COALESCE(#{customer}.timezone, #{billing_enti SQL end end end # app/services/markdown_scrubber.rb class MarkdownScrubber < Rails::HTML::PermitScrubber def initialize super self.tags = %w(a abbr b blockquote br code dd del div dl dt em h1 h2 h3 h4 h strike strong sub summary sup table tbody td tfoot th thead tr tt ul var) self.attributes = %w(class style href src target alt aria-label) end end And more random stuff... A Rake task handler... A SQL fragment generator??? Custom HTML sanitizer

Slide 44

Slide 44 text

# app/services/destroy_task.rb require "highline" require "io/console" class DestroyTask def initialize(io = STDOUT) @io = io @tty = IO.console @hl = HighLine.new(@tty, @io) end def destroy_topics(category, parent_category = nil, delete_system_topics = fal # ... end end # app/utils/timezone.rb module Utils class Timezone def self.at_time_zone_sql(customer: "customers", billing_entity: "billing_en <<-SQL ::timestamptz AT TIME ZONE COALESCE(#{customer}.timezone, #{billing_enti SQL end end end # app/services/markdown_scrubber.rb class MarkdownScrubber < Rails::HTML::PermitScrubber def initialize super self.tags = %w(a abbr b blockquote br code dd del div dl dt em h1 h2 h3 h4 h strike strong sub summary sup table tbody td tfoot th thead tr tt ul var) self.attributes = %w(class style href src target alt aria-label) end end And more random stuff... A Rake task handler... A SQL fragment generator??? Custom HTML sanitizer ↳ not in app/services

Slide 45

Slide 45 text

DON'T KEEP JUNK AROUND Improve signal-to-noise ratio / discoverability Ensure dependency direction (presentation → business logic → infra) Avoid false consistency tax Move your codebase to a healthy livable place

Slide 46

Slide 46 text

FALSE CONSISTENCY DROP CANDIDATES #2

Slide 47

Slide 47 text

FALSE CONSISTENCY A uniform interface across fundamentally different responsibilities SearchService::Parser.new(...).run # parse a query HcbCodeService::CanDispute.new(...).run # a system constraint predicate Decline::OutgoingCheck.new(...).run # reject a payment FlavorTextService.new.run # return a random phrase StripeCardService::Nightly.new.run # update_all on one column SendCardLockingNotification.new(...).run # orchestrate notifications Everything is #run, and #run means nothing

Slide 48

Slide 48 text

FALSE CONSISTENCY A uniform interface across fundamentally different responsibilities BillMetrics::EvaluateExpressionService.call(...) # parse + evaluate a DSL expr Analytics::MrrsService.call(...) # license check, simple query AppliedCoupons::LockService.call(...) { ... } # wrap a Postgres advisory lock Auth::SupersetService.call(...) # 200 LOC: auth, CSRF, etc Utils::PdfGenerator.call(...) # render PDF via Gotenberg PDF .call # pure math Everything is .call, and .call means nothing

Slide 49

Slide 49 text

# app/services/charge_models/amount_details/range_graduated_service. class < ::BaseService def initialize(range:, total_units:, adjacent_model: false) # ... end def call { from_value:, to_value:, flat_unit_amount:, per_unit_amount:, units: BigDecimal(units).to_s, per_unit_total_amount:, total_with_flat_amount: } end # ... end Just a calculator Uses BaseService (because lives in app/services)

Slide 50

Slide 50 text

# app/services/charge_models/amount_details/range_graduated_service. class < ::BaseService def initialize(range:, total_units:, adjacent_model: false) # ... end def call { from_value:, to_value:, flat_unit_amount:, per_unit_amount:, units: BigDecimal(units).to_s, per_unit_total_amount:, total_with_flat_amount: } end # ... end Just a calculator Uses BaseService (because lives in app/services) Inherits all the service machinery! # app/services/base_service.rb class BaseService include AfterCommitEverywhere use(Middlewares::LogTracerMiddleware) use(Middlewares::DatadogMiddleware) def self.call(*, **, &) new(*, **).call_with_middlewares(&) end def call_with_middlewares(&block) chain = init_middlewares chain.call { call(&block) } end # ... end

Slide 51

Slide 51 text

# app/services/charge_models/amount_details/range_graduated_service. class < ::BaseService def initialize(range:, total_units:, adjacent_model: false) # ... end def call { from_value:, to_value:, flat_unit_amount:, per_unit_amount:, units: BigDecimal(units).to_s, per_unit_total_amount:, total_with_flat_amount: } end # ... end Just a calculator Uses BaseService (because lives in app/services) Inherits all the service machinery! All other calculators use a hack—#apply! # app/services/base_service.rb class BaseService include AfterCommitEverywhere use(Middlewares::LogTracerMiddleware) use(Middlewares::DatadogMiddleware) def self.call(*, **, &) new(*, **).call_with_middlewares(&) end def call_with_middlewares(&block) chain = init_middlewares chain.call { call(&block) } end # ... end

Slide 52

Slide 52 text

# app/services/charge_models/amount_details/range_graduated_service. class < ::BaseService def initialize(range:, total_units:, adjacent_model: false) # ... end def call { from_value:, to_value:, flat_unit_amount:, per_unit_amount:, units: BigDecimal(units).to_s, per_unit_total_amount:, total_with_flat_amount: } end # ... end Just a calculator Uses BaseService (because lives in app/services) Inherits all the service machinery! All other calculators use a hack—#apply! # app/services/base_service.rb class BaseService include AfterCommitEverywhere use(Middlewares::LogTracerMiddleware) use(Middlewares::DatadogMiddleware) def self.call(*, **, &) new(*, **).call_with_middlewares(&) end def call_with_middlewares(&block) chain = init_middlewares chain.call { call(&block) } end # ... end module ChargeModels class BaseService < ::BaseService Result = BaseResult[...] def self.apply(...) new(...).apply end def apply # custom interface end end end module ChargeModels class GroupedService < ChargeModels::BaseService Result = BaseResult[...] def apply # ... end end end module ChargeModels class GraduatedService < ChargeModels::BaseService protected # ... end end

Slide 53

Slide 53 text

DON'T TRUST THE INTERFACE Interface should not erase intent Different responsibilities need different shared machinery Consistency for the sake of consistency is harmful Misused abstraction is worse than no abstraction

Slide 54

Slide 54 text

SINK HOLES DROP CANDIDATES #3

Slide 55

Slide 55 text

SINKHOLE SERVICES DDD (Dogma-Driven Design) class DismissNotificationRequestService < BaseService def call(request) FilteredNotificationCleanupWorker.perform_async( request.account_id, request.from_account_id ) request.destroy! end end

Slide 56

Slide 56 text

SINKHOLE SERVICES DDD (Dogma-Driven Design) class DismissNotificationRequestService < BaseService def call(request) FilteredNotificationCleanupWorker.perform_async( request.account_id, request.from_account_id ) request.destroy! end end class Api::V1::Notifications::RequestsController < Api::BaseController def dismiss DismissNotificationRequestService.new.call(@request) render_empty end # ... end

Slide 57

Slide 57 text

SINKHOLE SERVICES DDD (Dogma-Driven Design) class AfterUnallowDomainService < BaseService def call(domain) return if domain.blank? Account.remote.where(domain: domain).find_each do |account| DeleteAccountService.new.call(account, reserve_username: false) end end end

Slide 58

Slide 58 text

SINKHOLE SERVICES DDD (Dogma-Driven Design) class AfterUnallowDomainService < BaseService def call(domain) return if domain.blank? Account.remote.where(domain: domain).find_each do |account| DeleteAccountService.new.call(account, reserve_username: false) end end end class AfterUnallowDomainWorker include Sidekiq::Worker def perform(domain) AfterUnallowDomainService.new.call(domain) end end Anemic job detected!

Slide 59

Slide 59 text

SINKHOLE SERVICES Sometimes they look like honest services class DeleteCollectionService < BaseService def call(collection) @collection = collection collection.destroy! ActivityPub::RawDistributionWorker.perform_async( activity_json, collection.account.id ) end private def activity_json # serialize collection via AMS end end

Slide 60

Slide 60 text

SINKHOLE SERVICES Sometimes they look like honest services class YetAnotherService < BaseService def call(...) model.some_operation create_notification if account.activitypub? end private def create_notification ActivityPub::DeliveryWorker.perform_async(activity_json, ...) end def activity_json # serialize collection via AMS end end The pattern appears in 15+ services

Slide 61

Slide 61 text

SINKHOLE SERVICES Sometimes they look like honest services class Api::V1Alpha::CollectionsController < Api::BaseController def destroy authorize @collection, :destroy? recording_activity(:remove_featured_collection, @collection) do @collection.destroy! end head 200 end end Replace with a single abstraction

Slide 62

Slide 62 text

DON'T BE DOGMATIC A service that makes no decisions adds no value One rich object beats a chain of anemic ones Repeated side effects deserve their own home Overused abstraction is worse than no abstraction

Slide 63

Slide 63 text

DOMAIN OBJECTS IN DISGUISE DROP CANDIDATES #4

Slide 64

Slide 64 text

dev.37signals.com/vanilla-rails-is-plenty/ We don’t separate application-level and domain-level artifacts. Instead, we have a set of domain models (both Active Records and POROs) exposing public interfaces to be invoked from the system boundaries, typically controllers or jobs

Slide 65

Slide 65 text

We use domain services and controllers as orchestrators. We care about reducing solutions to its essence.

Slide 66

Slide 66 text

app/models/charge ├── amount_details │ ├── range_graduated_percentage_calculator.rb │ └── range_graduated_calculator.rb ├── base_calculator.rb ├── build_default_properties_calculator.rb ├── custom_calculator.rb ├── dynamic_calculator.rb ├── factory.rb ├── filter_properties │ ├── base_calculator.rb │ ├── charge_calculator.rb │ └── fixed_charge_calculator.rb ├── filter_properties_calculator.rb ├── graduated_percentage_calculator.rb ├── graduated_calculator.rb ├── grouped_calculator.rb ├── package_calculator.rb ├── percentage_calculator.rb ├── prorated_graduated_calculator.rb ├── standard_calculator.rb └── volume_calculator.rb app/services/charge_models ├── amount_details │ ├── range_graduated_percentage_service.rb │ └── range_graduated_service.rb ├── base_service.rb ├── build_default_properties_service.rb ├── custom_service.rb ├── dynamic_service.rb ├── factory.rb ├── filter_properties │ ├── base_service.rb │ ├── charge_service.rb │ └── fixed_charge_service.rb ├── filter_properties_service.rb ├── graduated_percentage_service.rb ├── graduated_service.rb ├── grouped_service.rb ├── package_service.rb ├── percentage_service.rb ├── prorated_graduated_service.rb ├── standard_service.rb └── volume_service.rb

Slide 67

Slide 67 text

app/models/charge ├── amount_details │ ├── range_graduated_percentage_calculator.rb │ └── range_graduated_calculator.rb ├── base_calculator.rb ├── build_default_properties_calculator.rb ├── custom_calculator.rb ├── dynamic_calculator.rb ├── factory.rb ├── filter_properties │ ├── base_calculator.rb │ ├── charge_calculator.rb │ └── fixed_charge_calculator.rb ├── filter_properties_calculator.rb ├── graduated_percentage_calculator.rb ├── graduated_calculator.rb ├── grouped_calculator.rb ├── package_calculator.rb ├── percentage_calculator.rb ├── prorated_graduated_calculator.rb ├── standard_calculator.rb └── volume_calculator.rb app/services/charge_models ├── amount_details │ ├── range_graduated_percentage_service.rb │ └── range_graduated_service.rb ├── base_service.rb ├── build_default_properties_service.rb ├── custom_service.rb ├── dynamic_service.rb ├── factory.rb ├── filter_properties │ ├── base_service.rb │ ├── charge_service.rb │ └── fixed_charge_service.rb ├── filter_properties_service.rb ├── graduated_percentage_service.rb ├── graduated_service.rb ├── grouped_service.rb ├── package_service.rb ├── percentage_service.rb ├── prorated_graduated_service.rb ├── standard_service.rb └── volume_service.rb

Slide 68

Slide 68 text

app/services/charge_models ├── amount_details │ ├── range_graduated_percentage_service.rb │ └── range_graduated_service.rb ├── base_service.rb ├── build_default_properties_service.rb ├── custom_service.rb ├── dynamic_service.rb ├── factory.rb ├── filter_properties │ ├── base_service.rb │ ├── charge_service.rb │ └── fixed_charge_service.rb ├── filter_properties_service.rb ├── graduated_percentage_service.rb ├── graduated_service.rb ├── grouped_service.rb ├── package_service.rb ├── percentage_service.rb ├── prorated_graduated_service.rb ├── standard_service.rb └── volume_service.rb app/models/charge ├── amount_details │ ├── range_graduated_percentage_calculator.rb │ └── range_graduated_calculator.rb ├── base_calculator.rb ├── build_default_properties_calculator.rb ├── custom_calculator.rb ├── dynamic_calculator.rb ├── factory.rb ├── filter_properties │ ├── base_calculator.rb │ ├── charge_calculator.rb │ └── fixed_charge_calculator.rb ├── filter_properties_calculator.rb ├── graduated_percentage_calculator.rb ├── graduated_calculator.rb ├── grouped_calculator.rb ├── package_calculator.rb ├── percentage_calculator.rb ├── prorated_graduated_calculator.rb ├── standard_calculator.rb └── volume_calculator.rb

Slide 69

Slide 69 text

THE ESSENCE This is the Way # Service Objects Way result = ChargeModels::Factory.new_instance( chargeable: charge, **options ).apply advance_model = ChargeModels::Factory.in_advance_charge_model_class(chargeable: charge) # Rails Way result = charge.calculate(**options) advance_calculator = charge.in_advance_calculator

Slide 70

Slide 70 text

THE ESSENCE This is the Way # Service Objects Way result = ChargeModels::Factory.new_instance( chargeable: charge, **options ).apply advance_model = ChargeModels::Factory.in_advance_charge_model_class(chargeable: charge) # Rails Way result = charge.calculate(**options) advance_calculator = charge.in_advance_calculator Collaborate with active_record-associated_object # " Gems are overrated

Slide 71

Slide 71 text

app/services/badges/ ├── award.rb ├── award_beloved_comment.rb ├── award_community_wellness.rb ├── award_contributor.rb ├── award_contributor_from_github.rb ├── award_eight_week_streak.rb ├── award_fab_five.rb ├── award_first_post.rb ├── award_sixteen_week_streak.rb ├── award_streak.rb ├── award_tag.rb ├── award_thumbs_up.rb ├── award_top_seven.rb └── award_yearly_club.rb

Slide 72

Slide 72 text

module Badges class AwardBelovedComment BADGE_SLUG = "beloved-comment".freeze def call return unless (badge_id = Badge.id_for_slug(BADGE_SLUG)) Comment.includes(:user).where(public_reactions_count: comment_count..).find_each do |comment| achievement = BadgeAchievement.create( user_id: comment.user_id, badge_id: badge_id, rewarding_context_message_markdown: generate_message(comment), ) comment.user.touch if achievement.valid? end end end end

Slide 73

Slide 73 text

module Badges class AwardBelovedComment BADGE_SLUG = "beloved-comment".freeze def call return unless (badge_id = Badge.id_for_slug(BADGE_SLUG)) Comment.includes(:user).where(public_reactions_count: comment_count..).find_each do |comment| achievement = BadgeAchievement.create( user_id: comment.user_id, badge_id: badge_id, rewarding_context_message_markdown: generate_message(comment), ) comment.user.touch if achievement.valid? end end end end 1

Slide 74

Slide 74 text

module Badges class AwardBelovedComment BADGE_SLUG = "beloved-comment".freeze def call return unless (badge_id = Badge.id_for_slug(BADGE_SLUG)) Comment.includes(:user).where(public_reactions_count: comment_count..).find_each do |comment| achievement = BadgeAchievement.create( user_id: comment.user_id, badge_id: badge_id, rewarding_context_message_markdown: generate_message(comment), ) comment.user.touch if achievement.valid? end end end end 1 2

Slide 75

Slide 75 text

class BadgeAchievement < ApplicationRecord after_commit :bust_user_cache, on: %i[create destroy] # ... end module Badges class AwardBelovedComment BADGE_SLUG = "beloved-comment".freeze def call return unless (badge_id = Badge.id_for_slug(BADGE_SLUG)) Comment.includes(:user).where(public_reactions_count: comment_count..).find_each do |comment| achievement = BadgeAchievement.create( user_id: comment.user_id, badge_id: badge_id, rewarding_context_message_markdown: generate_message(comment), ) comment.user.touch if achievement.valid? end end end end 1 2 3

Slide 76

Slide 76 text

class BadgeAchievement < ApplicationRecord after_commit :bust_user_cache, on: %i[create destroy] # ... end module Badges class AwardBelovedComment BADGE_SLUG = "beloved-comment".freeze def call return unless (badge_id = Badge.id_for_slug(BADGE_SLUG)) Comment.includes(:user).where(public_reactions_count: comment_count..).find_each do |comment| achievement = BadgeAchievement.create( user_id: comment.user_id, badge_id: badge_id, rewarding_context_message_markdown: generate_message(comment), ) comment.user.touch if achievement.valid? end end end end module Badges class AwardStreak def call badge_slug = "#{weeks}-week-streak" return unless (badge_id = Badge.id_for_slug(badge_slug)) users = User.where(id: article_user_ids).where("articles_count >= ?", weeks) users.find_each do |user| count = weeks.times.count { |i| published_x_weeks_ago?(user, i + 1) } next unless count >= weeks user.badge_achievements.create( badge_id: badge_id, rewarding_context_message_markdown: generate_message, ) end end end end 1 2 3 2 1

Slide 77

Slide 77 text

module Badges class AwardStreak def call badge_slug = "#{weeks}-week-streak" return unless (badge_id = Badge.id_for_slug(badge_slug)) users = User.where(id: article_user_ids).where("articles_count >= ?", weeks) users.find_each do |user| count = weeks.times.count { |i| published_x_weeks_ago?(user, i + 1) } next unless count >= weeks user.badge_achievements.create( badge_id: badge_id, rewarding_context_message_markdown: generate_message, ) end end end end module Badges class AwardBelovedComment BADGE_SLUG = "beloved-comment".freeze def call return unless (badge_id = Badge.id_for_slug(BADGE_SLUG)) Comment.includes(:user).where(public_reactions_count: comment_count..).find_each do |comment| achievement = BadgeAchievement.create( user_id: comment.user_id, badge_id: badge_id, rewarding_context_message_markdown: generate_message(comment), ) comment.user.touch if achievement.valid? end end end end module Badges class AwardFabFive BADGE_SLUG = "fab-5".freeze def self.call(usernames, message_markdown = default_message_markdown) ::Badges::Award.call( User.where(username: usernames), BADGE_SLUG, message_markdown, ) end end class Award def self.call(user_relation, slug, message_markdown, include_default_description: true) return unless (badge_id = Badge.id_for_slug(slug)) user_relation.find_each do |user| achievement = user.badge_achievements.create( badge_id: badge_id, rewarding_context_message_markdown: message_markdown, include_default_description:, ) user.touch if achievement.persisted? end end end end class BadgeAwardWorker include Sidekiq::Job def perform(usernames, badge_slug, message, include_default_description = true) if (award_class = "Badges::#{badge_slug.classify}".safe_constantize) award_class.call else Badges::Award.call(User.where(username: usernames), badge_slug, message, include_default_description: ...) end end end 1 2 3

Slide 78

Slide 78 text

app/models/badges/ ├── beloved_comment.rb ├── community_wellness.rb ├── contributor.rb ├── contributor_from_github.rb ├── fab_five.rb ├── first_post.rb ├── streak.rb # Streak.new(weeks: 4/8/16) ├── tag.rb ├── thumbs_up.rb ├── top_seven.rb └── yearly_club.rb app/services/badges/ └── grant.rb app/services/badges/ ├── award.rb ├── award_beloved_comment.rb ├── award_community_wellness.rb ├── award_contributor.rb ├── award_contributor_from_github.rb ├── award_eight_week_streak.rb ├── award_fab_five.rb ├── award_first_post.rb ├── award_sixteen_week_streak.rb ├── award_streak.rb ├── award_tag.rb ├── award_thumbs_up.rb ├── award_top_seven.rb └── award_yearly_club.rb

Slide 79

Slide 79 text

app/models/badges/ ├── beloved_comment.rb ├── community_wellness.rb ├── contributor.rb ├── contributor_from_github.rb ├── fab_five.rb ├── first_post.rb ├── streak.rb # Streak.new(weeks: 4/8/16) ├── tag.rb ├── thumbs_up.rb ├── top_seven.rb └── yearly_club.rb app/services/badges/ └── grant.rb app/services/badges/ ├── award.rb ├── award_beloved_comment.rb ├── award_community_wellness.rb ├── award_contributor.rb ├── award_contributor_from_github.rb ├── award_eight_week_streak.rb ├── award_fab_five.rb ├── award_first_post.rb ├── award_sixteen_week_streak.rb ├── award_streak.rb ├── award_tag.rb ├── award_thumbs_up.rb ├── award_top_seven.rb └── award_yearly_club.rb

Slide 80

Slide 80 text

class Badges::Streak def initialize(weeks:) = @weeks = weeks def slug = "#{@weeks}-week-streak" def qualifying_users ids = Article.published .where("published_at > ? AND score > ?", 1.week.ago, -25) .pluck(:user_id) User.where(id: ids).where("articles_count >= ?", @weeks) .select { |u| consecutive_weeks?(u) } end def message_for(_user) = I18n.t("...message", count: @weeks * 2) end

Slide 81

Slide 81 text

class Badges::Streak def initialize(weeks:) = @weeks = weeks def slug = "#{@weeks}-week-streak" def qualifying_users ids = Article.published .where("published_at > ? AND score > ?", 1.week.ago, -25) .pluck(:user_id) User.where(id: ids).where("articles_count >= ?", @weeks) .select { |u| consecutive_weeks?(u) } end def message_for(_user) = I18n.t("...message", count: @weeks * 2) end class Badges::Grant def self.call(badge, users: nil, message: nil) return unless (badge_id = Badge.id_for_slug(badge.slug)) (users || badge.qualifying_users).find_each do |user| next if user.banished? user.badge_achievements.create( badge_id: badge_id, rewarding_context_message_markdown: message || badge.message_for(user), ) end end end 1 2

Slide 82

Slide 82 text

DON'T NAME NOUNS AS VERBS Domain objects are not only Active Record models Stick to the Rails Way for as long as possible Think in objects first, functions/operations second If it quacks like a model it's a model

Slide 83

Slide 83 text

EMERGING ABSTRACTIONS DROP CANDIDATES #5

Slide 84

Slide 84 text

WHAT IF IT'S A SERVICE? Encapsulates a complex business operation Orchestrates multiple collaborators with side effects Communicates with external systems Not owned by a single model No junk, no sink, no disguise

Slide 85

Slide 85 text

Emerging Notifications layer app/models/ └── notification.rb app/services/ ├── notifications.rb ├── notifications/ │ ├── new_comment/send.rb │ ├── new_follower/send.rb │ ├── new_follower/follow_data.rb │ ├── reactions/send.rb │ ├── reactions/reaction_data.rb │ ├── new_mention/send.rb │ ├── notifiable_action/send.rb │ ├── milestone/send.rb │ ├── moderation/send.rb │ ├── new_badge_achievement/send.rb │ ├── welcome_notification/send.rb │ ├── organization_deverification/send.rb │ ├── subforem_change_notification/send.rb │ ├── tag_adjustment_notification/send.rb │ ├── remove_all.rb │ ├── remove_all_by_action.rb │ ├── remove_by_spammer.rb │ └── update.rb └── push_notifications/send.rb app/workers/notifications/ ├── new_follower_worker.rb ├── new_reaction_worker.rb ├── mention_worker.rb ├── milestone_worker.rb ├── new_badge_achievement_worker.rb ├── notifiable_action_worker.rb ├── organization_deverification_worker.rb ├── subforem_change_notification_worker.rb ├── tag_adjustment_notification_worker.rb └── welcome_notification_worker.rb

Slide 86

Slide 86 text

Emerging Notifications layer Events, delivery channels, management app/models/ └── notification.rb app/services/ ├── notifications.rb ├── notifications/ │ ├── new_comment/send.rb │ ├── new_follower/send.rb │ ├── new_follower/follow_data.rb │ ├── reactions/send.rb │ ├── reactions/reaction_data.rb │ ├── new_mention/send.rb │ ├── notifiable_action/send.rb │ ├── milestone/send.rb │ ├── moderation/send.rb │ ├── new_badge_achievement/send.rb │ ├── welcome_notification/send.rb │ ├── organization_deverification/send.rb │ ├── subforem_change_notification/send.rb │ ├── tag_adjustment_notification/send.rb │ ├── remove_all.rb │ ├── remove_all_by_action.rb │ ├── remove_by_spammer.rb │ └── update.rb └── push_notifications/send.rb app/workers/notifications/ ├── new_follower_worker.rb ├── new_reaction_worker.rb ├── mention_worker.rb ├── milestone_worker.rb ├── new_badge_achievement_worker.rb ├── notifiable_action_worker.rb ├── organization_deverification_worker.rb ├── subforem_change_notification_worker.rb ├── tag_adjustment_notification_worker.rb └── welcome_notification_worker.rb

Slide 87

Slide 87 text

Emerging Notifications layer Events, delivery channels, management (Anemic) workers app/models/ └── notification.rb app/services/ ├── notifications.rb ├── notifications/ │ ├── new_comment/send.rb │ ├── new_follower/send.rb │ ├── new_follower/follow_data.rb │ ├── reactions/send.rb │ ├── reactions/reaction_data.rb │ ├── new_mention/send.rb │ ├── notifiable_action/send.rb │ ├── milestone/send.rb │ ├── moderation/send.rb │ ├── new_badge_achievement/send.rb │ ├── welcome_notification/send.rb │ ├── organization_deverification/send.rb │ ├── subforem_change_notification/send.rb │ ├── tag_adjustment_notification/send.rb │ ├── remove_all.rb │ ├── remove_all_by_action.rb │ ├── remove_by_spammer.rb │ └── update.rb └── push_notifications/send.rb app/workers/notifications/ ├── new_follower_worker.rb ├── new_reaction_worker.rb ├── mention_worker.rb ├── milestone_worker.rb ├── new_badge_achievement_worker.rb ├── notifiable_action_worker.rb ├── organization_deverification_worker.rb ├── subforem_change_notification_worker.rb ├── tag_adjustment_notification_worker.rb └── welcome_notification_worker.rb module Notifications class NewFollowerWorker include Sidekiq::Job def perform(follow_data) Notifications::NewFollower::Send.call(follow_data) end end end

Slide 88

Slide 88 text

Emerging Notifications layer Events, delivery channels, management (Anemic) workers (Bloated) model app/models/ └── notification.rb app/services/ ├── notifications.rb ├── notifications/ │ ├── new_comment/send.rb │ ├── new_follower/send.rb │ ├── new_follower/follow_data.rb │ ├── reactions/send.rb │ ├── reactions/reaction_data.rb │ ├── new_mention/send.rb │ ├── notifiable_action/send.rb │ ├── milestone/send.rb │ ├── moderation/send.rb │ ├── new_badge_achievement/send.rb │ ├── welcome_notification/send.rb │ ├── organization_deverification/send.rb │ ├── subforem_change_notification/send.rb │ ├── tag_adjustment_notification/send.rb │ ├── remove_all.rb │ ├── remove_all_by_action.rb │ ├── remove_by_spammer.rb │ └── update.rb └── push_notifications/send.rb app/workers/notifications/ ├── new_follower_worker.rb ├── new_reaction_worker.rb ├── mention_worker.rb ├── milestone_worker.rb ├── new_badge_achievement_worker.rb ├── notifiable_action_worker.rb ├── organization_deverification_worker.rb ├── subforem_change_notification_worker.rb ├── tag_adjustment_notification_worker.rb └── welcome_notification_worker.rb class Notification < ApplicationRecord # ... class << self def send_new_follower_notification(follow) def send_new_follower_notification_without_delay(follow) def send_to_mentioned_users_and_followers(notifiable) def send_to_followers(notifiable, action = nil) def send_new_comment_notifications_without_delay(comment) def send_new_badge_achievement_notification(badge_achievement) def send_reaction_notification(reaction, rcvr) def send_reaction_notification_without_delay(reaction, rcvr) # ~10 more send_whatever methods end end

Slide 89

Slide 89 text

Emerging Notifications layer Events, delivery channels, management (Anemic) workers (Bloated) model Payload generation app/models/ └── notification.rb app/services/ ├── notifications.rb ├── notifications/ │ ├── new_comment/send.rb │ ├── new_follower/send.rb │ ├── new_follower/follow_data.rb │ ├── reactions/send.rb │ ├── reactions/reaction_data.rb │ ├── new_mention/send.rb │ ├── notifiable_action/send.rb │ ├── milestone/send.rb │ ├── moderation/send.rb │ ├── new_badge_achievement/send.rb │ ├── welcome_notification/send.rb │ ├── organization_deverification/send.rb │ ├── subforem_change_notification/send.rb │ ├── tag_adjustment_notification/send.rb │ ├── remove_all.rb │ ├── remove_all_by_action.rb │ ├── remove_by_spammer.rb │ └── update.rb └── push_notifications/send.rb app/workers/notifications/ ├── new_follower_worker.rb ├── new_reaction_worker.rb ├── mention_worker.rb ├── milestone_worker.rb ├── new_badge_achievement_worker.rb ├── notifiable_action_worker.rb ├── organization_deverification_worker.rb ├── subforem_change_notification_worker.rb ├── tag_adjustment_notification_worker.rb └── welcome_notification_worker.rb module Notifications def self.user_data(user) { id: user.id, name: user.name, username: user.username, profile_image_90: user.profile_image_90, comments_count: user.comments_count } end def self.comment_data(comment) = { ... } def self.article_data(article) = { ... } def self.organization_data(organization) = { ... } end

Slide 90

Slide 90 text

⚠ Logic spread across many files/layers ⚠ No clear convention ⚠ Tons of boilerplate app/models/ └── notification.rb app/services/ ├── notifications.rb ├── notifications/ │ ├── new_comment/send.rb │ ├── new_follower/send.rb │ ├── new_follower/follow_data.rb │ ├── reactions/send.rb │ ├── reactions/reaction_data.rb │ ├── new_mention/send.rb │ ├── notifiable_action/send.rb │ ├── milestone/send.rb │ ├── moderation/send.rb │ ├── new_badge_achievement/send.rb │ ├── welcome_notification/send.rb │ ├── organization_deverification/send.rb │ ├── subforem_change_notification/send.rb │ ├── tag_adjustment_notification/send.rb │ ├── remove_all.rb │ ├── remove_all_by_action.rb │ ├── remove_by_spammer.rb │ └── update.rb └── push_notifications/send.rb app/workers/notifications/ ├── new_follower_worker.rb ├── new_reaction_worker.rb ├── mention_worker.rb ├── milestone_worker.rb ├── new_badge_achievement_worker.rb ├── notifiable_action_worker.rb ├── organization_deverification_worker.rb ├── subforem_change_notification_worker.rb ├── tag_adjustment_notification_worker.rb └── welcome_notification_worker.rb

Slide 91

Slide 91 text

app/notifiers/ ├── application_notifier.rb # #deliver, #deliver_later, etc ├── new_comment_notifier.rb ├── new_follower_notifier.rb ├── reaction_notifier.rb ├── new_mention_notifier.rb ├── article_published_notifier.rb ├── milestone_notifier.rb ├── moderation_notifier.rb ├── new_badge_achievement_notifier.rb ├── welcome_notifier.rb ├── organization_deverification_notifier.rb ├── subforem_change_notifier.rb └── tag_adjustment_notifier.rb app/models/concerns/notification/ └── maintenance.rb # remove_all, remove_all_by_action, etc app/workers/ └── notification_worker.rb lib/push_notifications/ └── sender.rb app/models/ └── notification.rb app/services/ ├── notifications.rb ├── notifications/ │ ├── new_comment/send.rb │ ├── new_follower/send.rb │ ├── new_follower/follow_data.rb │ ├── reactions/send.rb │ ├── reactions/reaction_data.rb │ ├── new_mention/send.rb │ ├── notifiable_action/send.rb │ ├── milestone/send.rb │ ├── moderation/send.rb │ ├── new_badge_achievement/send.rb │ ├── welcome_notification/send.rb │ ├── organization_deverification/send.rb │ ├── subforem_change_notification/send.rb │ ├── tag_adjustment_notification/send.rb │ ├── remove_all.rb │ ├── remove_all_by_action.rb │ ├── remove_by_spammer.rb │ └── update.rb └── push_notifications/send.rb app/workers/notifications/ ├── new_follower_worker.rb ├── new_reaction_worker.rb ├── mention_worker.rb ├── milestone_worker.rb ├── new_badge_achievement_worker.rb ├── notifiable_action_worker.rb ├── organization_deverification_worker.rb ├── subforem_change_notification_worker.rb ├── tag_adjustment_notification_worker.rb └── welcome_notification_worker.rb

Slide 92

Slide 92 text

app/notifiers/ ├── application_notifier.rb # #deliver, #deliver_later, etc ├── new_comment_notifier.rb ├── new_follower_notifier.rb ├── reaction_notifier.rb ├── new_mention_notifier.rb ├── article_published_notifier.rb ├── milestone_notifier.rb ├── moderation_notifier.rb ├── new_badge_achievement_notifier.rb ├── welcome_notifier.rb ├── organization_deverification_notifier.rb ├── subforem_change_notifier.rb └── tag_adjustment_notifier.rb app/models/concerns/notification/ └── maintenance.rb # remove_all, remove_all_by_action, etc app/workers/ └── notification_worker.rb lib/push_notifications/ └── sender.rb app/models/ └── notification.rb app/services/ ├── notifications.rb ├── notifications/ │ ├── new_comment/send.rb │ ├── new_follower/send.rb │ ├── new_follower/follow_data.rb │ ├── reactions/send.rb │ ├── reactions/reaction_data.rb │ ├── new_mention/send.rb │ ├── notifiable_action/send.rb │ ├── milestone/send.rb │ ├── moderation/send.rb │ ├── new_badge_achievement/send.rb │ ├── welcome_notification/send.rb │ ├── organization_deverification/send.rb │ ├── subforem_change_notification/send.rb │ ├── tag_adjustment_notification/send.rb │ ├── remove_all.rb │ ├── remove_all_by_action.rb │ ├── remove_by_spammer.rb │ └── update.rb └── push_notifications/send.rb app/workers/notifications/ ├── new_follower_worker.rb ├── new_reaction_worker.rb ├── mention_worker.rb ├── milestone_worker.rb ├── new_badge_achievement_worker.rb ├── notifiable_action_worker.rb ├── organization_deverification_worker.rb ├── subforem_change_notification_worker.rb ├── tag_adjustment_notification_worker.rb └── welcome_notification_worker.rb

Slide 93

Slide 93 text

app/notifiers/ ├── application_notifier.rb # #deliver, #deliver_later, etc ├── new_comment_notifier.rb ├── new_follower_notifier.rb ├── reaction_notifier.rb ├── new_mention_notifier.rb ├── article_published_notifier.rb ├── milestone_notifier.rb ├── moderation_notifier.rb ├── new_badge_achievement_notifier.rb ├── welcome_notifier.rb ├── organization_deverification_notifier.rb ├── subforem_change_notifier.rb └── tag_adjustment_notifier.rb app/models/concerns/notification/ └── maintenance.rb # remove_all, remove_all_by_action, etc app/workers/ └── notification_worker.rb lib/push_notifications/ └── sender.rb

Slide 94

Slide 94 text

app/notifiers/ ├── application_notifier.rb # #deliver, #deliver_later, etc ├── new_comment_notifier.rb ├── new_follower_notifier.rb ├── reaction_notifier.rb ├── new_mention_notifier.rb ├── article_published_notifier.rb ├── milestone_notifier.rb ├── moderation_notifier.rb ├── new_badge_achievement_notifier.rb ├── welcome_notifier.rb ├── organization_deverification_notifier.rb ├── subforem_change_notifier.rb └── tag_adjustment_notifier.rb app/models/concerns/notification/ └── maintenance.rb # remove_all, remove_all_by_action, etc app/workers/ └── notification_worker.rb lib/push_notifications/ └── sender.rb class NewCommentNotifier < ApplicationNotifier channels :database, :push before_delivery do throw :abort if blocking?( commentable.user_id, comment.user_id ) end def recipients # scope or inline query end def notification_data # serializer or inline Hash end end NewCommentNotifier.new(comment).deliver_later

Slide 95

Slide 95 text

app/notifiers/ ├── application_notifier.rb # #deliver, #deliver_later, etc ├── new_comment_notifier.rb ├── new_follower_notifier.rb ├── reaction_notifier.rb ├── new_mention_notifier.rb ├── article_published_notifier.rb ├── milestone_notifier.rb ├── moderation_notifier.rb ├── new_badge_achievement_notifier.rb ├── welcome_notifier.rb ├── organization_deverification_notifier.rb ├── subforem_change_notifier.rb └── tag_adjustment_notifier.rb app/models/concerns/notification/ └── maintenance.rb # remove_all, remove_all_by_action, etc app/workers/ └── notification_worker.rb lib/push_notifications/ └── sender.rb class NewCommentNotifier < ApplicationNotifier channels :database, :push before_delivery do throw :abort if blocking?( commentable.user_id, comment.user_id ) end def recipients # scope or inline query end def notification_data # serializer or inline Hash end end NewCommentNotifier.new(comment).deliver_later Have you Noticed...?

Slide 96

Slide 96 text

MORE THAN A SERVICE Form and query objects Notifications (events, delivery channels) Authorization (policies, etc.) AI agents API clients and Webhook handlers Common service specializations in Rails apps

Slide 97

Slide 97 text

MORE THAN A SERVICE Form and query objects Notifications (events, delivery channels) Authorization (policies, etc.) AI agents API clients and Webhook handlers Common service specializations in Rails apps You can do better with RubyLLM

Slide 98

Slide 98 text

DON'T IGNORE YOUR CODE Identify and extract specialization clusters Give a cluster a name and recognizable shape, not just a place (a folder) and a generic contract Don't overpromote, keep under app/services, namespaced It's telling you what it wants to become

Slide 99

Slide 99 text

DON'T IGNORE YOUR CODE Identify and extract specialization clusters Give a cluster a name and recognizable shape, not just a place (a folder) and a generic contract Don't overpromote, keep under app/services, namespaced It's telling you what it wants to become Abstract=extracted from a larger work

Slide 100

Slide 100 text

SERVICES DONE RIGHT KEEP CANDIDATES #1

Slide 101

Slide 101 text

ПРИКАЗКА ЗА БЪЧВАТА Wisdom older than software

Slide 102

Slide 102 text

ПРИКАЗКА ЗА БЪЧВАТА Your code is the barrel Wisdom older than software

Slide 103

Slide 103 text

ПРИКАЗКА ЗА БЪЧВАТА Your code is the barrel The wooden planks are the abstractions Wisdom older than software

Slide 104

Slide 104 text

ПРИКАЗКА ЗА БЪЧВАТА Your code is the barrel The wooden planks are the abstractions The hoops are the conventions Wisdom older than software

Slide 105

Slide 105 text

ПРИКАЗКА ЗА БЪЧВАТА Your code is the barrel The wooden planks are the abstractions The hoops are the conventions The wine is the developer happiness of working with maintainable code Wisdom older than software

Slide 106

Slide 106 text

Outgoing Webhooks (sub-) layer Custom base class and API Test utils (shared examples and the context) ⚠ Still the same #call and -Service app/services/webhooks ├── base_service.rb ├── credit_notes │ ├── created_service.rb │ ├── generated_service.rb ├── customers │ ├── created_service.rb │ ├── updated_service.rb │ └── vies_check_service.rb ├── features │ ├── created_service.rb │ ├── deleted_service.rb │ └── updated_service.rb ├── fees │ └── pay_in_advance_created_service.rb ├── integrations ├── ... └── wallets ├── created_service.rb ├── depleted_ongoing_balance_service.rb ├── terminated_service.rb └── updated_service.rb spec/support ├── shared_contexts │ └── webhook_tracking.rb └── shared_examples └── creates_webhook.rb

Slide 107

Slide 107 text

SERVICE OBJECTS FRAMEWORK class Chat::TrashMessage include Service::Base params do attribute :message_id, :integer attribute :channel_id, :integer validates :message_id, presence: true validates :channel_id, presence: true end model :message policy :invalid_access model :pinned_message, optional: true transaction do step :trash_message step :destroy_pin step :destroy_notifications step :update_thread_reply_cache end step :publish_events end

Slide 108

Slide 108 text

SERVICE OBJECTS FRAMEWORK class Chat::TrashMessage include Service::Base params do attribute :message_id, :integer attribute :channel_id, :integer validates :message_id, presence: true validates :channel_id, presence: true end model :message policy :invalid_access model :pinned_message, optional: true transaction do step :trash_message step :destroy_pin step :destroy_notifications step :update_thread_reply_cache end step :publish_events end # controller def destroy Chat::TrashMessage.call(service_params) do on_success { render(json: success_json) } on_model_not_found(:message) { raise Discourse::NotFound } on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess } on_failed_contract { |contract| render_error(contract.errors) } end end # specs context "when params are not valid" do it { is_expected.to fail_a_contract } end context "when the user has permission" do it { is_expected.to run_successfully } it "trashes the message" end

Slide 109

Slide 109 text

SERVICE OBJECTS FRAMEWORK class Chat::TrashMessage include Service::Base params do attribute :message_id, :integer attribute :channel_id, :integer validates :message_id, presence: true validates :channel_id, presence: true end model :message policy :invalid_access model :pinned_message, optional: true transaction do step :trash_message step :destroy_pin step :destroy_notifications step :update_thread_reply_cache end step :publish_events end # controller def destroy Chat::TrashMessage.call(service_params) do on_success { render(json: success_json) } on_model_not_found(:message) { raise Discourse::NotFound } on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess } on_failed_contract { |contract| render_error(contract.errors) } end end # specs context "when params are not valid" do it { is_expected.to fail_a_contract } end context "when the user has permission" do it { is_expected.to run_successfully } it "trashes the message" end Smells like Trailblazer::Operation

Slide 110

Slide 110 text

SERVICE OBJECTS FRAMEWORK class Chat::TrashMessage include Service::Base params do attribute :message_id, :integer attribute :channel_id, :integer validates :message_id, presence: true validates :channel_id, presence: true end model :message policy :invalid_access model :pinned_message, optional: true transaction do step :trash_message step :destroy_pin step :destroy_notifications step :update_thread_reply_cache end step :publish_events end --- name: discourse-service-authoring description: Use when creating, editing, or reviewing Discourse service objects that include Service::Base --- ## Phase 4: Quality Review the service against these structural rules. List every violation found. - Service name describes the core business concept; namespace freely - Steps are simple with one concern each - Step names describe domain behavior, not code (revoke_previous_accepted_answer not destroy_old_record) - transaction wraps ONLY DB writes. Side effects live outside. - No return if/return unless at the top of step methods — use only_if wrappers - NEVER have utility methods in a service, use model, step or a dedicated action instead - Instead of manipulating data from the contract directly in various steps, extract logic on the contract itself - Each side effect is its own step with its own only_if wrapper — never bundle conditional side effects - No over-protection with try — trust internal bang methods - Mutation steps ordered so rollback leaves consistent state ... Version 2026

Slide 111

Slide 111 text

SERVICES IN THE AI ERA How to unteach LLMs generating faceless services

Slide 112

Slide 112 text

LLMS ❤ SERVICE OBJECTS AI learned "best practices" from us

Slide 113

Slide 113 text

LLMS ❤ SERVICE OBJECTS AI is an amplifier: garbage in, garbage out AI learned "best practices" from us

Slide 114

Slide 114 text

LLMS ❤ SERVICE OBJECTS AI is an amplifier: garbage in, garbage out It consumed a lot of "slim controllers, slim models, fat services" stuff during training AI learned "best practices" from us

Slide 115

Slide 115 text

LLMS ❤ SERVICE OBJECTS AI is an amplifier: garbage in, garbage out It consumed a lot of "slim controllers, slim models, fat services" stuff during training Harness is required to generate undroppable app/services AI learned "best practices" from us

Slide 116

Slide 116 text

SKILLS vs SERVICES github.com/palkan/skills ▐▛███▜▌ Клод Код v16.05.2026 ▝▜█████▛▘ Vovas 3.7 (1M context) · Claude Max ▘▘ ▝▝ > /layered-rails:analyze-services ✽ Hannabarbering…

Slide 117

Slide 117 text

SKILLS vs SERVICES github.com/palkan/skills ▐▛███▜▌ Клод Код v16.05.2026 ▝▜█████▛▘ Vovas 3.7 (1M context) · Claude Max ▘▘ ▝▝ > Service Object Analysis — Mastodon TL;DR Extract a reusable ActivityPub distribution abstraction — a recording_activity service + controller helper — the single highest-leverage move; closes the presentation-layer leak in BaseService and dissolves a cluster of thin-wrapper services whose only value-add is federation boilerplate. In this project, a Service Object is … A class inheriting BaseService that wraps a single federated domain operation — typically an account relationship action or content mutation — exposing a uniform #call entry point that takes domain objects as positional arguments and an options hash, performs the mutation, then distributes ActivityPub payloads and enqueues workers for side effects. /layered-rails:analyze-services

Slide 118

Slide 118 text

DROP YOUR app/services Having proper app/services is fine But there's a high chance that your waiting room is overcrowded Specialized abstractions are likely to exist in your codebase. Find and promote them! And populate it from scratch (if needed)

Slide 119

Slide 119 text

CREDITS

Slide 120

Slide 120 text

THANK YOU x.com/palkan_tula x.com/evilmartians