La semaine dernière, Emmanuelle et Aurélien ont publié leur retour sur l’app iOS de Simone.paris. Côté backend, c’est un projet Rails classique — mais qui illustre bien pourquoi ce framework reste un choix redoutable pour servir une app mobile.

L’app Simone permet de réserver une coiffure à domicile à Paris. Le parcours client est linéaire : choisir un service, un créneau, renseigner une adresse, payer. Derrière, le backend gère la logique métier — recherche de disponibilités, machine à états pour les réservations, paiement Adyen, notifications push, SMS, facturation PDF. Et un dashboard ActiveAdmin pour que la fondatrice puisse suivre son activité au quotidien.

Pourquoi Rails

On est fin 2014. Le choix du backend pour une app mobile, c’est souvent Rails, Django, Express ou du PHP. On part sur Rails pour des raisons pragmatiques :

  • Convention over configuration — la structure est imposée, chaque fichier a sa place. On ne perd pas de temps à débattre de l’architecture de dossiers.
  • ActiveRecord — le mapping objet-relationnel est prêt. Relations, validations, scopes, callbacks — tout est déclaratif.
  • L’écosystème de gems — pour chaque besoin transversal (auth, paiement, state machine, admin), il existe une gem éprouvée. On assemble plus qu’on ne code.
  • ActiveAdmin — un back-office complet généré depuis les modèles. C’est souvent ce qui fait la différence pour un client non-technique.

Le Gemfile résume bien l’approche — chaque gem couvre un besoin précis :

gem 'rails', '~> 4.2.1'
gem 'pg'                          # PostgreSQL
gem 'devise', '3.4.0'            # Authentification
gem 'grape'                       # API REST
gem 'grape-entity'                # Sérialisation JSON
gem 'aasm'                        # Machine à états
gem 'money-rails', '~>1'         # Gestion des prix
gem 'sidekiq'                     # Jobs asynchrones
gem 'rpush'                       # Notifications push iOS
gem 'activeadmin', '~> 1.2.0'    # Dashboard admin
gem 'chartkick'                   # Graphiques
gem 'audited-activerecord'        # Audit trail
gem 'paperclip'                   # Upload S3 (factures PDF)

L’API : Grape plutôt que Rails controllers

Pour servir l’app iOS, on utilise Grape plutôt que les controllers Rails classiques. Grape est un micro-framework dédié aux API REST — il impose une structure déclarative qui convient mieux à une API pure qu’un controller Rails avec respond_to :json.

ARCHITECTURE API — GRAPE + ENTITIESL’app iOS ne parle qu’à l’API Grape — les controllers Rails servent le site web et l’adminApp iOSAFNetworkingBearer tokenJSONApi::Root (Grape)POST /sessionGET /servicesGET /availabilitiesPOST /reservationsPUT /reservations/:idGET /reservations/:id/validatePOST /paymentsversioning: Accept header v1.1Swagger auto-generatedpresentGrape::EntityReservationTreatmentUserContactexpose conditionnel via lambdasActiveRecordvalidations, scopes, callbacksAASM state machinePostgreSQL23 tablesindexes stratégiquesSidekiqpaiement AdyenSMS, push, factures PDF

Chaque endpoint est déclaratif — paramètres typés, codes HTTP documentés, sérialisation par Entity :

module Api
  class Reservations < Grape::API
    resource :reservations do
      desc 'Create a new reservation', entity: Api::Entities::Reservation
      before { authenticate! }
      params do
        requires :user_id,      type: String
        requires :treatment_id, type: String
        optional :from,         type: DateTime
      end
      post '/' do
        reservation = Reservation.create(parameters.to_h.merge(source: simone_source))
        present reservation, with: Api::Entities::Reservation
      end
    end
  end
end

Le present délègue la sérialisation à un Entity. C’est le même principe que le Decorator côté iOS — le controller ne sait pas comment le JSON est construit :

class Reservation < Grape::Entity
  expose :id, :state, :user_id
  expose :treatment, using: Api::Entities::Treatment
  expose :contact,   using: Api::Entities::Contact,
         if: -> (r, _) { r.contact? }
  expose :suggested_contact, using: Api::Entities::Contact,
         unless: -> (r, _) { r.contact? } do |r|
           r.user.last_delivery_address
         end
end

Les lambdas dans if: / unless: permettent de conditionner l’exposition — ici, on renvoie soit le contact de la réservation, soit la dernière adresse connue de l’utilisateur. L’app iOS n’a pas à gérer cette logique.

La machine à états : AASM

C’est le coeur du backend. Une réservation passe par 13 états, de waiting_for_client à paid, avec des chemins alternatifs (annulation, absence du client, prestataire introuvable). Exprimer ça en if/else, c’est ingérable. AASM transforme le workflow en déclaration :

MACHINE À ÉTATS — PARCOURS PRINCIPAL D’UNE RÉSERVATIONChaque transition déclenche des callbacks : emails, SMS, paiement, facturationwaiting_for_clientinitialvalidatebroadcastedSMS envoyéacceptaccepted_by_contractorconfirmation clientperformtreatment_donepaiement lancérequest_paymentpayment_requestedAdyen asyncpaypaidfinalignored_by_contractorstimeout SMSno_contractor_foundcanceled_without_feesannulation clienterror_on_paymentabortedtimeout clientChemin principal (happy path)Chemins alternatifs (erreur, annulation, timeout)

Le code correspondant est déclaratif — on lit le workflow comme une spécification :

aasm column: :state do
  state :waiting_for_client, initial: true
  state :broadcasted
  state :accepted_by_contractor
  state :treatment_done
  state :payment_requested
  state :paid, final: true

  event :validate_by_client,
        after: %i(use_promotions copy_contact_on_user
                  send_client_previous_confirmation
                  schedule_sms_to_contractor) do
    transitions from: :waiting_for_client, to: :broadcasted,
                guard: :contact_id?
  end

  event :perform_treatment,
        after: %i(request_payment! execute!) do
    transitions from: :accepted_by_contractor, to: :treatment_done,
                guard: :reservation_can_be_completed?
  end

  event :pay,
        before: :referral_promotions,
        after: %i(create_invoice send_invoice) do
    transitions from: :payment_requested, to: :paid
  end
end

Chaque transition a ses gardes (guard:) et ses callbacks (after:, before:). Quand le client valide sa réservation, cinq actions s’enchaînent automatiquement : application des promotions, copie du contact, email de pré-confirmation, SMS au prestataire, planification du timeout. On n’a rien à orchestrer manuellement.

Les Service Objects

Pour la logique métier complexe, on extrait des Service Objects. Le plus représentatif est AvailabilitiesFinder — trouver les créneaux disponibles pour un soin donné :

class AvailabilitiesFinder
  SCHEDULE_STEPS = 30.minutes
  BETWEEN_RESERVATION_TIME = 45.minutes

  def initialize(treatment: nil, postcode: nil)
    @treatment = treatment
    @postcode  = postcode
  end

  def find
    range_to_include = contractors.flat_map { |c| range_for_contractor(c) }
    @ranges = StepFunction.build(
      to_include: range_to_include,
      to_exclude: time_ranges_to_exclude
    ).to_time_ranges
    @result = @ranges.inject(Set[]) do |set, tr|
      set + tr.split_in_time_slots(treatment_duration, SCHEDULE_STEPS)
    end
    sort_result!
  end
end

Le service agrège les disponibilités de tous les prestataires, exclut les réservations existantes (avec un tampon de 45 minutes pour le déplacement), retire les jours fériés et les vacances, puis découpe en créneaux de 30 minutes. Le tout repose sur StepFunction, un objet mathématique maison qui fusionne des intervalles temporels.

Le controller ne voit qu’un appel :

service = AvailabilitiesFinder.new(treatment: treatment, postcode: postcode)
service.find
present service.result

ActiveAdmin : le dashboard pour la cliente

C’est peut-être la partie la plus sous-estimée du projet. La fondatrice de Simone avait besoin de suivre son activité — nombre de réservations, chiffre d’affaires, répartition par arrondissement, par tranche horaire, par service. Sans budget pour un outil BI.

ActiveAdmin a tout débloqué. En une journée, on a monté un dashboard KPI complet :

ActiveAdmin.register_page 'Dashboard' do
  content title: 'Tableau de bord' do
    panel 'Données Globales' do
      display_p_with_strong("Nombre d'inscrits :", users.count)
      display_p_with_strong('Nombre de clients :', users.with_reservations.count)
    end

    panel 'Prestations exécutées' do
      sum = Money.new(reservations.sum(:discounted_price_cents))
      display_p_with_strong('CA facturé :', humanized_money_with_symbol(sum))
      display_p_with_strong('Panier moyen :',
        humanized_money_with_symbol(sum / reservations.count))
    end
  end
end

Avec chartkick et groupdate, les graphiques se construisent en une ligne :

price_by_day = reservations.group_by_day(:created_at).sum(:discounted_price_cents)
price_by_day = price_by_day.map { |day, cents| [day, Money.new(cents).to_f] }
line_chart [{ name: 'CA TTC par jour (€)', data: price_by_day }]

Le dashboard affiche le CA par jour, le nombre de prestations par arrondissement, la répartition par tranche horaire — le tout avec un filtre de dates. La cliente peut isoler un mois, un trimestre, comparer les tendances.

La gestion des réservations

ActiveAdmin génère le CRUD automatiquement depuis le modèle. On a customisé l’index pour afficher 17 colonnes (client, prestataire, état, prix, promotions…) avec des filtres Ransack :

ActiveAdmin.register Reservation do
  scope :priority, default: true
  scope "Accepted", :accepted_by_contractor

  filter :state, as: :select,
    collection: proc { Reservation.other_states.map { |c| [t("reservations.states.#{c}"), c] } }
  filter :by_contact_postcode_in, label: "Code Postal", as: :string
  filter :by_promotion_in, label: 'Code Promotion', as: :string

  index do
    column :user do |r|
      link_to r.user.email, admin_user_path(r.user)
    end
    column :state do |r|
      display_order_state(r)
    end
    column :discounted_price do |r|
      humanized_money_with_symbol(r.discounted_price)
    end
    actions
  end
end

Le scope :priority filtre par défaut les réservations qui nécessitent une action — broadcasted sans prestataire, ignorées par les prestataires. La fondatrice arrive le matin, elle voit immédiatement ce qui demande son attention.

Les transitions d’état sont aussi exposées dans l’admin :

action_item(:event, only: :show) do
  resource.aasm.events.map do |e|
    link_to t("reservations.events.#{e.name}"),
      make_event_admin_reservation_path(event: e.name)
  end
end

Un bouton par transition possible. L’admin clique sur “Accepter par prestataire”, la machine à états fait le reste — envoi des emails, SMS, mise à jour du statut. Le code métier est le même que celui de l’API.

Le modèle Reservation : tout est déclaratif

Le modèle Reservation est le fichier le plus dense du projet, mais il reste lisible grâce aux conventions Rails :

class Reservation < ActiveRecord::Base
  include ReservationStates     # machine à états (concern)
  include Contactable           # association polymorphe contact
  include DiscountableReservation # calcul de prix avec promotions

  belongs_to :user
  belongs_to :treatment
  belongs_to :contractor

  has_many :payments, dependent: :destroy
  has_many :user_promotions, -> { not_canceled }

  monetize :discounted_price_cents
  monetize :buying_price_cents, allow_nil: true

  validates :contact, presence: true, if: :state_with_information_completed?

  scope :executed, -> { where(state: %i[treatment_done paid payment_requested]) }
  scope :from_date_between, -> (from, to) { where(from: from..to) }
  scope :with_promotion, -> { joins(:user_promotions) }

  delegate :name, to: :service, prefix: true, allow_nil: true
  audited
end

Vingt lignes qui décrivent les associations, les validations conditionnelles, les scopes pour les requêtes courantes, la délégation pour éviter la loi de Demeter, et l’audit automatique. C’est la force de Rails — on déclare le comportement, on ne l’implémente pas.

Ce qu’on en retient

Rails nous a permis de livrer un backend complet en quelques semaines — API, machine à états, paiement, notifications, dashboard admin. Les points-clés :

  • Grape pour l’API est un meilleur choix que les controllers Rails quand on sert uniquement une app mobile. La déclaration des paramètres, le versioning, les Entities pour la sérialisation — tout est plus explicite.
  • AASM transforme un workflow complexe en code lisible. Les 13 états et leurs transitions sont documentés par le code lui-même. Ajouter un état, c’est ajouter trois lignes.
  • Les Service Objects (AvailabilitiesFinder, AddPromotionService) gardent les modèles propres. La logique métier complexe a sa place — ni dans le controller, ni dans le modèle.
  • ActiveAdmin + chartkick = un dashboard en une journée. Pour une startup early-stage, c’est souvent la fonctionnalité la plus valorisée par le client. Pas besoin de Metabase, pas besoin de Tableau.
  • Les conventions Rails libèrent la bande passante mentale. On ne débat pas de l’arborescence, du nommage des fichiers, de l’endroit où mettre les validations. Tout est décidé d’avance. On code.

L’app Simone a tourné en production sans accroc. Le backend a absorbé la charge, le dashboard a servi au quotidien, et la base de code est restée lisible un an plus tard quand on a dû faire des ajustements. C’est ça, le vrai bénéfice de Rails : pas la productivité du premier jour, mais la maintenabilité du jour 365.