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

[BalkanRuby 2026] Drop your app/services!

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.

[BalkanRuby 2026] Drop your app/services!

Why did service objects become popular citizens of Rails codebases, what even is a service object in the end, and what are the alternatives?

https://evilmartians.com/events/balkan-ruby-vladimir-dementyev

Avatar for Vladimir Dementyev

Vladimir Dementyev

May 16, 2026

More Decks by Vladimir Dementyev

Other Decks in Programming

Transcript

  1. ! Find 2 hot takes on this slide " Rails'

    MVC is not MVC " You don't need a server to run a Rails app #WASM
  2. 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
  3. ➜ 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
  4. ➜ 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
  5. “Service is a standalone operation within the context of your

    domain. Service Object collects one or more services into an object” –Eric Evans, DDD
  6. “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
  7. “If it doesn't fit anywhere, put it into app/services.” –Also

    (most) Rails devs Stop thinking in folders, think in abstractions!
  8. 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
  9. 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
  10. 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
  11. RANDOM JUNK Pure infra wrappers View helpers Useless extractions Developer

    tools Truly random stuff Objects not even pretending to be services
  12. # 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
  13. # 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!
  14. # 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
  15. # 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
  16. # 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 [ "<s>Hack the Bank Mode</s>", # ~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
  17. # 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
  18. # 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
  19. # 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
  20. # 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
  21. # 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???
  22. # 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
  23. # 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
  24. 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
  25. 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
  26. 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
  27. # 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)
  28. # 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
  29. # 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
  30. # 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
  31. 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
  32. 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
  33. 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
  34. 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
  35. 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!
  36. 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
  37. 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
  38. 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
  39. 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
  40. 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
  41. We use domain services and controllers as orchestrators. We care

    about reducing solutions to its essence.
  42. 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
  43. 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
  44. 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
  45. 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
  46. 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
  47. 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
  48. 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
  49. 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
  50. 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
  51. 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
  52. 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
  53. 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
  54. 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
  55. 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
  56. 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
  57. 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
  58. 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
  59. 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
  60. 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
  61. 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
  62. 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
  63. 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
  64. 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
  65. ⚠ 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
  66. 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
  67. 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
  68. 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
  69. 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
  70. 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...?
  71. 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
  72. 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
  73. 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
  74. 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
  75. ПРИКАЗКА ЗА БЪЧВАТА Your code is the barrel The wooden

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

    planks are the abstractions The hoops are the conventions Wisdom older than software
  77. ПРИКАЗКА ЗА БЪЧВАТА 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
  78. 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
  79. 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
  80. 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
  81. 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
  82. 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
  83. LLMS ❤ SERVICE OBJECTS AI is an amplifier: garbage in,

    garbage out AI learned "best practices" from us
  84. 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
  85. 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
  86. SKILLS vs SERVICES github.com/palkan/skills ▐▛███▜▌ Клод Код v16.05.2026 ▝▜█████▛▘ Vovas

    3.7 (1M context) · Claude Max ▘▘ ▝▝ > /layered-rails:analyze-services ✽ Hannabarbering…
  87. 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
  88. 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)