Skip to content
← Wszystkie wpisy
4 min czytania Dawid Skłodowski

Obiekty usługowe: ujarzmianie grubych modeli Rails

Chude kontrolery wepchnęły całą logikę w grube modele, a teraz są nie do opanowania. Obiekty usługowe to najpopularniejsza poprawka — oto jak używać ich dobrze, kiedy pomagają i jak zamieniają się w gorszy bałagan, gdy przesadzisz.

Rails nauczył pokolenie programistów „chude kontrolery, grube modele”, i to była dobra rada — aż do momentu, gdy modele zrobiły się za grube. Dojrzała aplikacja Rails niezawodnie wyhodowuje model User na dwa tysiące linii: trwałość, walidacje, callbacki, reguły biznesowe, logika powiadomień, logika płatności, helpery raportowe, wszystko zwalone do jednej klasy, bo „to dotyczy użytkownika”. Ta klasa jest nie do utrzymania w głowie, przerażająca do zmiany i wolna do testowania. Obiekty usługowe to najpopularniejsza odpowiedź na ten problem, i dobra — ale tylko jeśli rozumiesz, do czego naprawdę służą. Oto jak ich używamy i jak idą źle.

Problem: gdzie idzie logika biznesowa?

Rails daje jasny dom dwóm rodzajom kodu: trwałość i reguły danych idą do modelu, obsługa żądania/odpowiedzi do kontrolera. Czego wyraźnie nie daje, to dom dla procesów biznesowych — wielokrokowych operacji będących faktycznym sensem twojej aplikacji. „Złóż zamówienie” to nie właściwość rekordu Order; to proces, który tworzy zamówienie, obciąża kartę, zmniejsza stan magazynu, wysyła potwierdzenie i być może powiadamia magazyn. Wepchnij to w model, a puchnie; wepchnij w kontroler, a nie da się tego reużyć ani przetestować w izolacji. Nie ma oczywistego miejsca, więc się rozłazi.

Obiekt usługowy to po prostu zwykła klasa Ruby, która daje temu procesowi dom. Nic bardziej egzotycznego niż to.

Podstawowy kształt

Obiekt usługowy to PORO (zwykły obiekt Ruby) nazwany od akcji, którą wykonuje, z jedną publiczną metodą, która ją wykonuje:

class PlaceOrder
  def initialize(cart, payment_method)
    @cart = cart
    @payment_method = payment_method
  end

  def call
    ActiveRecord::Base.transaction do
      order = create_order
      charge_payment(order)
      decrement_stock(order)
      order
    end
  end

  private

  attr_reader :cart, :payment_method

  def create_order
    Order.create!(line_items: cart.line_items, total: cart.total)
  end

  def charge_payment(order)
    PaymentGateway.charge(order.total, payment_method)
  end

  def decrement_stock(order)
    order.line_items.each { |li| li.product.decrement!(:stock, li.quantity) }
  end
end

Kontroler staje się naprawdę chudy — zbiera dane wejściowe, woła usługę i renderuje wynik:

def create
  order = PlaceOrder.new(current_cart, params[:payment_method]).call
  redirect_to order
rescue PaymentError => e
  redirect_to cart_path, alert: e.message
end

Konwencje, które ustaliły się wokół tego: nazwij klasę frazą czasownikową (PlaceOrder, CancelSubscription, ImportCsv), wystaw jedną publiczną metodę (call jest idiomatyczne), trzymaj resztę prywatną i pozwól konstruktorowi wziąć współpracowników. Częstą wygodą jest metoda klasowa, by wołający pisali PlaceOrder.call(cart, pm).

Dlaczego to lepsze

Trzy konkretne wygrane, nie tylko schludność:

  • Proces biznesowy ma nazwę i miejsce. „Jak składamy zamówienie?” ma jedną odpowiedź: przeczytaj PlaceOrder. Logika nie jest rozrzucona po callbacku modelu, kontrolerze i obserwatorze.
  • Jest testowalny w izolacji. Możesz unit-testować PlaceOrder wprost z koszykiem i metodą płatności — bez kontrolera, bez HTTP, bez widoku. Szybkie testy dla logiki, która liczy się najbardziej.
  • Model wraca na dietę. Order staje się odpowiedzialny za bycie zamówieniem — swoje dane i niezmienniki — a nie za całą choreografię zamawiania. Taniec wielu modeli żyje w usłudze, która je koordynuje.

Transakcje i porażka są pierwszoklasowe

Obiekty usługowe to naturalny dom dla granic transakcji, i to jedna z ich najlepszych cech. Proces biznesowy dotykający kilku rekordów zwykle musi być wszystko-albo-nic — a usługa to dokładnie właściwy zakres do owinięcia w blok transaction, by porażka w połowie wycofała wszystko. Usługa daje też jedno jasne miejsce do zdefiniowania, jak proces zawodzi: podnieś błąd domenowy albo zwróć obiekt wyniku, który wołający inspekcjonuje. Skłaniamy się ku jawnemu wynikowi dla oczekiwanych porażek zamiast wyjątków-jako-sterowania-przepływem:

Result = Struct.new(:success?, :order, :error)

def call
  order = nil
  ActiveRecord::Base.transaction do
    order = create_order
    charge_payment(order)
    decrement_stock(order)
  end
  Result.new(true, order, nil)
rescue PaymentError => e
  Result.new(false, nil, e.message)
end

Wołający robi result = PlaceOrder.call(...); if result.success? — porażka jest częścią interfejsu, nie niespodzianką uciekającą w górę.

Gdzie idzie źle

Obiekty usługowe łatwo kopiować bezmyślnie, a przesadzone produkują bazę kodu gorszą niż gruby model, przed którym uciekałeś. Tryby awarii, których nauczyliśmy się wypatrywać:

  • Jedna usługa na akcję kontrolera, odruchowo. Jeśli UpdateUserName tylko woła user.update(name: ...), dodałeś warstwę pośredniości, która nic nie kupuje. Obiekty usługowe są do procesów — wiele kroków, wiele obiektów, realna koordynacja. Jednolinijkowe opakowanie metody modelu to czysta ceremonia; pozwól kontrolerowi wołać model.
  • Anemiczne modele. Wypchnij wszystko do usług, a twoje modele stają się workami kolumn bazy bez zachowania, podczas gdy równoległy wszechświat klas usług trzyma całą logikę. To nie projektowanie obiektowe; to kod proceduralny w kostiumie Ruby. Zachowanie, które naprawdę należy do pojedynczego rekordu (metoda nad jego własnymi atrybutami), powinno zostać na modelu.
  • Usługi-bogowie. PlaceOrder, który rośnie do 400 linii, właśnie znów stał się grubym modelem, przeniesionym. Gdy usługa się rozrasta, rozłóż ją na mniejsze usługi, które woła, a nie na więcej prywatnych metod.
  • Współdzielony zmienny stan i niejasne wejścia. Trzymaj usługi bezstanowymi poza argumentami konstruktora, z jawnymi wejściami i jednym jasnym wyjściem. Usługa sięgająca do globali albo mutująca swoje wejścia jest trudna do ogarnięcia jako rzecz, którą obiecała być: funkcja z nazwą.

Werdykt

Obiekty usługowe to właściwe narzędzie do realnego i konkretnego problemu: procesów biznesowych obejmujących wiele modeli i niemających naturalnego miejsca w czystym Railsie. Użyte do tego — nazwana klasa, jedno call, granica transakcji, jasny wynik — czynią aplikację dramatycznie łatwiejszą do zrozumienia, testowania i zmiany, i trzymają twoje modele skupione na byciu modelami. Dyscypliną jest znajomość granicy: obiekt usługowy jest do procesu, nie do każdej akcji. Sięgaj po niego, gdy logika obejmuje obiekty i kroki; zostaw modelowi to, co tylko on potrafi. Złap ten osąd dobrze, a problem grubego modelu po prostu przestaje się zdarzać.