Snellire models e controllers di Rails con i command object

In ambito Ruby e Rails si discute spesso di best practices, metodologie di testing e strumenti di sviluppo. Un po’ più raro è, invece, sentir parlare di design patterns che riguardino il come strutturare il codice su Rails. In questo articolo, introdurremo i commands spiegando come e perchè usarli.
Tempo di lettura: 5 minuti

Per diversi anni il mantra imperante è stato "skinny controller, fat model": spostare la logica applicativa dal controller al modello, per facilitarne un possibile riutilizzo; è una pratica tuttora valida, anche se a volte può risultare un po’ stretta.

Prendiamo un esempio concreto: la nostra applicazione richiede l’esecuzione di una serie di azioni in concomitanza con la registrazione di un nuovo utente:

  • generazione di una chiave da usare per permettere l’accesso alle API;
  • invio di una email di benvenuto.

Sfruttando gli hooks ActiveRecord, questa potrebbe essere una possibile implementazione:

# app/models/user.rb
class User < ActiveRecord::Base
  has_many :things

  validates :email,
    presence: true,
    uniqueness: { case_sensitive: false }

  before_validation :generate_api_token, on: :create
  after_save :send_welcome_email, on: :create

  private

  def send_welcome_email
    # ...
  end

  def generate_api_token
    # ...
  end
end


# app/controllers/users_controller.rb
class UsersController < ApplicationController
  # ...

  def create
    @user = User.new(params[:email])

    if @user.save
      redirect_to users_path, notice: "Welcome!"
    else
      render :new
    end
  end

  # ...
end

A prima vista non c’è nulla di sbagliato, tutti abbiamo letto e scritto codice simile decine di volte, senza battere ciglio. Ci sono però un paio di osservazioni che possiamo fare:

  • Costringendoci ad implementare tutta la logica applicativa attraverso hooks che agiscono prima, durante o dopo il salvataggio di dati, il flusso tende ad essere “saltellante”. Se ci trovassimo a rivedere il codice tra qualche settimana, probabilmente sarebbe difficile ricostruire l’insieme complessivo delle azioni “automatiche” al contorno;
  • Immaginiamo un possibile task di import in batch di utenti: potremmo non avere bisogno di inviare una mail di benvenuto in questo caso! Ovviamente Rails ci permette di disabilitare un particolare hook in determinate condizioni, ma essendo il comportamento “nascosto”, spesso il rischio è quello di dimenticarsene;
  • Almeno in linea teorica, le uniche responsabilità di un modello dovrebbero essere relative alla persistenza e alla consistenza dei dati: un modello non dovrebbe saperne nulla di ciò che avviene a livello di business-logic;
  • Inserendo tutte la logica applicativa all’interno dei modelli, il rischio è quello di ritrovarsi in breve tempo di fronte a God objects: file di migliaia di righe di codice difficili da gestire e che controllano l’intero applicativo.

Proviamo quindi a immaginarci un modello ActiveRecord le cui uniche responsabilità siano quelle di specificare eventuali associazioni con altri models e validare i dati. Dove potremmo andare a collocare la logica applicativa, senza venir meno all’assunto di non sporcare i controller?

Introduciamo i Commands

Un command, o service object, è una classe dedicata all’esecuzione di una determinata azione, semplice o complessa che sia. Riprendendo l’esempio citato sopra, potremmo implementare un command che si occupi di creare l’utente, generando la chiave per l’API ed inviando una email di benvenuto:

# /app/commands/create_user.rb
class RegisterUser < Struct.new(:email)
  def execute
    user.email = email

    if user.valid?
      user.api_token = generate_api_token
      user.save!
      send_welcome_email
    end
    user
  end

  private

  def user
    @user ||= User.new
  end

  def send_welcome_email
    # ...
  end

  def generate_api_token
    # ...
  end
end


# /app/controllers/users_controller.rb
class UsersController < ApplicationController
  # ...

  def create
    @user = RegisterUser.new(params[:email]).execute

    if @user.persisted?
      redirect_to users_path, notice: "Welcome!"
    else
      render :new
    end
  end
  # ...
end

A prescindere dal fatto che sia stato volutamente preso in considerazione un esempio molto semplice, l’uso del command ci ha permesso di isolare la logica applicativa, liberando il model e il controller da responsabilità che non gli appartenevano.

È importante notare alcune caratteristiche del command:

  • un solo metodo, execute, che esegue l’azione: sebbene nessuno vieti di esporre altri metodi pubblici, il fatto di avere un solo modo per utilizzarlo semplifica notevolmente il nostro codice;
  • è un semplice oggetto Ruby (anche conosciuto come PORO, Plain Old Ruby Object): ci rende agnostici dalle altre librerie e, tra le varie conseguenze, permette facilmente di effettuare cambiamenti, anche radicali, senza traumi; 
  • facile da testare: avendo un solo metodo e trattandosi di un semplice oggetto Ruby, scrivere unit tests o eseguire integration tests diventano operazioni molto più semplici;
  • semplice da riutilizzare: supponendo di dover predisporre una API, è possibile riutilizzare esattamente lo stesso command, senza duplicazione di codice. Lo stesso dicasi per rake tasks, o addirittura operazioni da console.

Conclusioni

Questo articolo vuole mostrare in modo semplice le potenzialità dell’uso dei commands. Sebbene a prima vista possa sembrare uno spreco di codice o, peggio, un’inutile complicazione, i vantaggi di questo approccio si cominciano ad apprezzare nel tempo, quando c’è bisogno di aggiungere nuove features o modificare quelle esistenti, e ti accorgi che ogni pezzo si integra senza difficoltà o stravolgimenti. Organizzare la logica applicativa attraverso l’uso di commands permette inoltre di avere una visione generale di cosa la nostra applicazione faccia e quali siano i suoi principali casi d’uso semplicemente aprendo una cartella, app/commands, favorendo l’introduzione di nuovi elementi nel team.

Hai trovato questo post interessante?Scopri chi siamo

Made with Middleman and DatoCMS, our CMS for static websites