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.
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 :
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.