Rails Service Objects di 2026: Design Patterns, PORO dan Pertanyaan Interview Teknis

Kuasai service objects di Rails dengan pola PORO, Result monad, dan clean architecture. Dilengkapi pertanyaan interview nyata dan contoh kode production-ready untuk Rails 8.

Ilustrasi design patterns Rails Service Objects dan clean architecture

Service objects di Rails mengekstrak business logic dari controllers dan models ke dalam Plain Old Ruby Objects (POROs). Pola ini menjaga aplikasi Rails tetap maintainable seiring pertumbuhannya, dan interviewer sering mengujinya untuk menilai pemahaman kandidat tentang clean architecture.

Yang diharapkan interviewer

Service object mengenkapsulasi satu operasi bisnis. Class tersebut memiliki satu method publik (biasanya call), menerima dependencies melalui constructor, dan mengembalikan result yang dapat diprediksi. Kandidat yang bisa menjelaskan mengapa ini penting, bukan hanya cara mengimplementasikannya, akan lebih menonjol.

Mengapa Service Objects Menyelesaikan Masalah Fat Model dan Fat Controller

Rails mendorong penempatan logic di suatu tempat. Tanpa panduan, tim memasukkannya ke models ("fat models, skinny controllers") sampai class ActiveRecord membengkak menjadi ribuan baris. Yang lain membiarkannya di controllers, membuat actions tidak mungkin ditest secara terpisah.

Service objects menawarkan jalur ketiga: domain logic berada di class-class khusus yang tidak bergantung pada apapun dari Rails kecuali yang secara eksplisit diperlukan. Decoupling ini membuat kode dapat ditest tanpa memuat seluruh framework, portable di berbagai entry points (controllers, background jobs, console), dan readable karena setiap class melakukan satu hal.

Rails Guides tidak meresepkan service objects, tetapi komunitas telah menstandarkan pola ini. Penekanan Rails 8 pada simplicity, dengan Solid Queue dan Solid Cache menggantikan external dependencies, membuat POROs semakin menarik: lebih sedikit gems, lebih banyak plain Ruby.

Anatomi Service Object yang Production-Ready

Service object membutuhkan tiga hal: constructor yang menerima dependencies, satu method publik call, dan return type yang menandakan success atau failure.

ruby
# app/services/users/create_account.rb
module Users
  class CreateAccount
    def initialize(user_repo: User, mailer: UserMailer)
      @user_repo = user_repo
      @mailer = mailer
    end

    def call(params)
      user = @user_repo.new(params)
      return Result.failure(:validation_failed, user.errors) unless user.valid?

      user.save!
      @mailer.welcome_email(user).deliver_later
      Result.success(user)
    rescue ActiveRecord::RecordNotUnique
      Result.failure(:email_taken, "Email already registered")
    end
  end
end

Constructor menerima user_repo dan mailer dengan nilai default. Kode production menggunakan defaults; tests meng-inject mocks. Method call memvalidasi, menyimpan, mengirim email, dan membungkus setiap outcome dalam object Result.

Membangun Class Result Minimal Tanpa Gems

Beberapa tim langsung menggunakan dry-monads. Gem ini solid, tetapi menambah learning curve. Class Result 30 baris mencakup sebagian besar kebutuhan:

ruby
# app/services/result.rb
class Result
  attr_reader :value, :error, :code

  def initialize(success:, value: nil, error: nil, code: nil)
    @success = success
    @value = value
    @error = error
    @code = code
  end

  def success? = @success
  def failure? = !@success

  def self.success(value) = new(success: true, value: value)
  def self.failure(code, error = nil) = new(success: false, code: code, error: error)

  def on_success
    yield(value) if success?
    self
  end

  def on_failure
    yield(code, error) if failure?
    self
  end
end

Result ini mendukung chaining dengan on_success dan on_failure, mengekspos structured error codes, dan tidak memerlukan dependencies. Controllers mengkonsumsinya dengan bersih:

ruby
# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def create
    result = Users::CreateAccount.new.call(user_params)

    result
      .on_success { |user| redirect_to user, notice: "Account created" }
      .on_failure { |code, error| render :new, status: :unprocessable_entity }
  end

  private

  def user_params
    params.require(:user).permit(:email, :password, :name)
  end
end
Kapan menggunakan dry-monads

Tim yang sudah menggunakan ekosistem dry-rb, atau yang membutuhkan notasi Do untuk chaining multiple operations, akan mendapat manfaat dari dry-monads. Gem ini menyediakan tipe Success, Failure, dan Maybe dengan dukungan pattern matching di Ruby 3.x.

Konvensi Penamaan yang Menandakan Intent

Dua konvensi mendominasi:

StyleContohKapan digunakan
Verb phraseCreateAccount, SendInvoice, RefundPaymentCommands yang mengubah state
Noun dengan -orAccountCreator, InvoiceSender, PaymentRefunderKurang umum, beberapa tim lebih suka

Style verb phrase terbaca natural: Users::CreateAccount.new.call(params). Suffix -or berfungsi tetapi menambah suku kata tanpa kejelasan.

Struktur direktori juga penting. Pengelompokan berdasarkan domain menjaga services yang terkait tetap bersama:

text
app/services/
  users/
    create_account.rb
    reset_password.rb
    update_profile.rb
  orders/
    place_order.rb
    cancel_order.rb
    calculate_totals.rb
  result.rb

Siap menguasai wawancara Ruby on Rails Anda?

Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.

Dependency Injection untuk Services yang Testable

Hardcoded dependencies membuat testing menyakitkan. Service ini tidak bisa ditest tanpa mengakses database dan mengirim email sungguhan:

ruby
# Hindari: hardcoded dependencies
class CreateAccount
  def call(params)
    user = User.create!(params)        # Direct model call
    UserMailer.welcome(user).deliver   # Direct mailer call
  end
end

Injection melalui constructor menyelesaikan ini:

ruby
# Lebih baik: injectable dependencies
class CreateAccount
  def initialize(user_repo: User, mailer: UserMailer)
    @user_repo = user_repo
    @mailer = mailer
  end

  def call(params)
    user = @user_repo.create!(params)
    @mailer.welcome(user).deliver_later
    Result.success(user)
  end
end

Tests sekarang dapat mengganti dengan fakes:

ruby
# spec/services/users/create_account_spec.rb
RSpec.describe Users::CreateAccount do
  let(:fake_repo) { class_double(User) }
  let(:fake_mailer) { class_double(UserMailer) }
  let(:service) { described_class.new(user_repo: fake_repo, mailer: fake_mailer) }

  it "returns success with valid params" do
    user = instance_double(User, valid?: true)
    allow(fake_repo).to receive(:new).and_return(user)
    allow(user).to receive(:save!)
    allow(fake_mailer).to receive_message_chain(:welcome_email, :deliver_later)

    result = service.call(email: "test@example.com", password: "secure123")

    expect(result).to be_success
  end
end

Tanpa database, tanpa emails, eksekusi cepat. Dokumentasi RSpec membahas class_double dan instance_double untuk type-verified mocks.

Pertanyaan Interview tentang Rails Service Objects

Pertanyaan-pertanyaan ini muncul dalam interview Rails tingkat menengah dan senior. Siapkan jawaban konkret dengan contoh kode.

T: Kapan business logic sebaiknya berada di model versus service object?

Models memiliki validations, associations, scopes, dan single-record behavior. Service objects menangani operasi yang melintasi multiple models, memerlukan external calls, atau membutuhkan explicit transaction boundaries. Model User memvalidasi format email; service CreateAccount membuat user, mengirim welcome email, dan menyediakan default settings.

T: Bagaimana menangani errors di service objects tanpa exceptions?

Kembalikan object Result dengan states success/failure. Ini membuat error handling eksplisit di caller, menghindari exception-based control flow, dan menyediakan structured error codes untuk berbagai failure modes. Exceptions tetap appropriate untuk kondisi yang benar-benar exceptional (database down, network failure) bukan business rule violations.

T: Apa perbedaan antara service object dan interactor?

Interactors (dari gems seperti interactor) adalah service objects dengan interface spesifik: mereka menerima hash context, memutasinya, dan menandakan failure melalui context.fail!. Service objects sebagai POROs tidak memiliki interface yang ditentukan, memberikan tim lebih banyak fleksibilitas tetapi kurang konsistensi.

T: Bagaimana cara mentest service object yang memanggil external APIs?

Inject HTTP client sebagai dependency. Dalam tests, berikan stub yang mengembalikan canned responses. Tools seperti WebMock atau VCR dapat merekam dan memutar ulang HTTP interactions, tetapi constructor injection menghindari network calls sepenuhnya dalam unit tests.

Untuk lebih banyak persiapan interview Rails, lihat panduan pertanyaan interview Rails yang membahas RSpec dan testing patterns.

Railway-Oriented Design untuk Workflow Kompleks

Railway-oriented programming memperlakukan workflow sebagai dua jalur parallel: success melanjutkan ke depan, failure keluar segera. Setiap langkah baik melanjutkan di jalur success atau beralih ke jalur failure.

ruby
# app/services/orders/place_order.rb
module Orders
  class PlaceOrder
    def initialize(
      inventory: InventoryService.new,
      payment: PaymentService.new,
      fulfillment: FulfillmentService.new
    )
      @inventory = inventory
      @payment = payment
      @fulfillment = fulfillment
    end

    def call(cart:, payment_method:)
      validate_cart(cart)
        .then { |items| reserve_inventory(items) }
        .then { |reservation| charge_payment(reservation, payment_method) }
        .then { |charge| create_fulfillment(charge) }
    end

    private

    def validate_cart(cart)
      return Result.failure(:empty_cart, "Cart is empty") if cart.items.empty?
      Result.success(cart.items)
    end

    def reserve_inventory(items)
      @inventory.reserve(items)
    end

    def charge_payment(reservation, payment_method)
      result = @payment.charge(reservation.total, payment_method)
      return result if result.failure?

      Result.success({ reservation: reservation, charge: result.value })
    end

    def create_fulfillment(data)
      @fulfillment.create(data[:reservation], data[:charge])
    end
  end
end

Class Result membutuhkan method then yang hanya melanjutkan pada success:

ruby
# Tambahkan ke app/services/result.rb
def then
  return self if failure?
  yield(value)
end

Jika inventory reservation gagal, payment tidak pernah berjalan. Jika payment gagal, fulfillment tidak pernah berjalan. Setiap langkah mengembalikan Result, dan chain short-circuits pada failure pertama.

Kapan Service Objects Menjadi Anti-Pattern

Service objects menyelesaikan masalah nyata tetapi dapat berkembang biak secara tidak perlu. Perhatikan warning signs berikut:

  • Single-line services: Jika service hanya memanggil satu method model, service menambah indirection tanpa value. User.authenticate(email, password) lebih baik daripada AuthenticateUser.new.call(email, password) ketika logic-nya sederhana.

  • Services yang hanya ada untuk testing: Meng-inject dependencies membuat testing lebih mudah, tetapi membuat services hanya untuk membungkus model methods demi testability menunjukkan masalah sebenarnya adalah test setup, bukan architecture.

  • Anemic services tanpa logic: Services yang hanya mendelegasikan ke models tanpa menambah behavior hanyalah ceremony. Blog engineering Netguru membahas masalah over-extraction ini.

Alternatifnya: gunakan POROs secara selektif bersama Concerns untuk shared model behavior, Value Objects untuk domain concepts seperti Money atau DateRange, dan model methods untuk single-record operations.

Poin-Poin Penting untuk Design Rails Service Object

  • Ekstrak ke service object ketika logic melintasi multiple models, memerlukan external calls, atau membutuhkan explicit error handling
  • Gunakan satu method publik call yang mengembalikan object Result, bukan raw values atau exceptions
  • Inject dependencies melalui constructor dengan sensible defaults untuk production use
  • Beri nama services dengan verb phrases seperti CreateAccount atau PlaceOrder yang mendeskripsikan action
  • Organisir services berdasarkan domain (users/, orders/) bukan flat directory structure
  • Mulai dengan class Result minimal sebelum menambahkan gems seperti dry-monads
  • Simpan exceptions untuk infrastructure failures, bukan business rule violations
  • Hindari membuat services untuk single-line operations yang ditangani models secara natural

Mulai berlatih!

Uji pengetahuan Anda dengan simulator wawancara dan tes teknis kami.

Tantangan harian

Bisakah kamu menemukan bug di Ruby on Rails?

Satu potongan kode nyata, satu bug tersembunyi, satu percobaan per hari. Tanpa akun untuk mencoba.

Anthony Fillion-Maillet

Ditulis oleh

Anthony Fillion-Maillet

Pendiri SharpSkill

Developer fullstack selama lebih dari 10 tahun. Ia menjalankan SharpSkill dan bertanggung jawab atas semua yang diterbitkan di sini.

Diperbarui 20 Agustus 2026

Tag

#ruby-on-rails
#design-patterns
#service-objects
#poro
#clean-architecture

Bagikan

Artikel terkait