Rails 5 est sorti en juin — et on n’a pas attendu pour migrer. Quatre mois plus tard, on boucle le premier gros projet qu’on a construit dessus : Hively, une marketplace de services à domicile. L’app mobile (Ionic) communique avec une API Rails qui gère les prestataires, les disponibilités, les réservations, le chat en temps réel via Action Cable et le back-office.
C’est un projet qu’on connaît bien chez imagine — on avait déjà construit le backend Rails de Simone.paris l’an dernier, une app de réservation de coiffure à domicile, et plusieurs autres projets de ce type depuis. Hively va nettement plus loin : multi-services (ménage, fitness, garde d’enfants, pet-care, beauté), réservations récurrentes, communautés, et un moteur de recherche de prestataires avec calcul de disponibilités en temps réel. On a construit une architecture polymorphique data-driven qui permet d’ajouter un nouveau service sans toucher une ligne de code applicatif — uniquement de la configuration.
Cet article détaille trois aspects de l’architecture backend qui illustrent pourquoi Rails reste, à notre avis, le framework le plus productif pour ce type de projet.
Rails 5 en mode API : le bon timing
Ce qu’on faisait avant — et pourquoi c’était pénible
On utilisait déjà Rails comme backend d’apps mobiles — c’est ce qu’on fait depuis Simone. Mais jusqu’à Rails 4.2, construire une API propre demandait du travail supplémentaire.
Option 1 : Grape. On a testé Grape, le framework Ruby spécialisé API, sur un projet précédent. L’idée est séduisante — un DSL dédié, une documentation Swagger auto-générée, un routage indépendant de Rails. En pratique, on se retrouve avec deux frameworks dans la même app (Grape pour l’API, Rails pour le back-office), deux systèmes de routing, deux façons de gérer les erreurs, et des modèles qui doivent servir les deux. La maintenance devient un casse-tête.
Option 2 : Rails allégé à la main. C’est ce qu’on faisait sur Simone. On partait d’un rails new classique, puis on passait une demi-journée à déshabiller le framework :
# Ce qu'on devait faire en Rails 4.2 — à la main, à chaque projet
config.middleware.delete ActionDispatch::Cookies
config.middleware.delete ActionDispatch::Session::CookieStore
config.middleware.delete ActionDispatch::Flash
config.middleware.delete Rack::MethodOverride
# Supprimer les générateurs de vues
config.generators do |g|
g.template_engine nil
g.assets false
g.helper false
end
# Ignorer l'asset pipeline dans les contrôleurs
# Et prier pour que le prochain gem qu'on installe
# n'assume pas que les vues existent...
Fragile, non documenté, et chaque gem tierce pouvait casser en assumant la présence des middlewares qu’on avait supprimés. ActiveAdmin, par exemple, avait besoin des sessions — il fallait les réactiver chirurgicalement pour le namespace /admin.
Option 3 : rails-api. La gem rails-api de Santiago Pastorino — un précurseur du mode API officiel. On l’a utilisée brièvement, mais la gem restait un patch non officiel sur Rails, avec un risque de compatibilité à chaque montée de version.
Rails 5 : enfin officiel
Rails 5 met fin à ce bricolage. rails new hively --api génère un projet allégé nativement :
# application.rb — Rails 5 API mode
module Hively
class Application < Rails::Application
config.api_only = true
end
end
Ce flag supprime les middlewares de session, de cookies, de CSRF, de flash — tout ce qui concerne le rendu HTML. Le contrôleur de base hérite de ActionController::API au lieu de ActionController::Base. Le projet démarre léger, et on n’ajoute que ce dont on a besoin.
En pratique, on a quand même besoin de sessions pour le back-office ActiveAdmin — on réactive les middlewares cookie/session pour le namespace /admin uniquement. Le reste de l’app est purement stateless, avec une authentification par token Bearer.
L’authentification par token
On a construit un système de tokens custom plutôt que d’utiliser Devise pour l’API. Devise est génial pour l’authentification web (on l’utilise pour les prestataires et le back-office), mais pour une API mobile, un token simple et robuste suffit :
# app/models/access_token.rb
class AccessToken < ApplicationRecord
ACCESS_TOKEN_EXPIRATION_DELAY = 3.months
belongs_to :authenticatable, polymorphic: true
scope :active, -> {
where(canceled_at: nil)
.where('expires_at > ?', Time.current)
}
before_create :generate_token
def self.find_by_access_token(clear_token)
token = active.find_by(
token_digest: Digest::SHA256.hexdigest(clear_token)
)
token&.touch(:last_accessed_at)
token
end
private
def generate_token
clear_token = SecureRandom.urlsafe_base64(96)
self.token_digest = Digest::SHA256.hexdigest(clear_token)
self.clear_token = clear_token # attribut virtuel, renvoyé une seule fois
self.expires_at = ACCESS_TOKEN_EXPIRATION_DELAY.from_now
end
end
Le token clair (128 caractères) est renvoyé au client une seule et unique fois, à la création. Côté serveur, on ne stocke que le digest SHA-256. Si la base de données est compromise, les tokens sont inutilisables. Le concern TokenAuthenticatable est inclus dans les modèles User et Provider — polymorphisme classique Rails.
Le concern côté contrôleur intercepte chaque requête et charge l’utilisateur courant :
# app/controllers/concerns/token_authentication.rb
module TokenAuthentication
extend ActiveSupport::Concern
included do
before_action :authenticate_with_token!
end
def current_user
@current_user ||= lookup_user_from_token
end
private
def lookup_user_from_token
token = request.headers['Authorization']&.gsub(/^Bearer /, '')
return nil unless token.present?
AccessToken.find_by_access_token(token)&.authenticatable
end
end
C’est volontairement simple. Pas de JWT (on n’a pas besoin de claims embarqués dans le token), pas de refresh token (l’expiration à trois mois suffit pour une app mobile), pas d’OAuth2 complet (surdimensionné pour un projet mono-client). Le token est un identifiant opaque — il identifie, il n’autorise pas.
Les concerns : notre arme secrète
Rails 4 avait introduit les concerns — des modules réutilisables inclus dans les modèles. Sur Hively, on a poussé ce pattern plus loin que sur aucun projet précédent. Chaque aspect transversal du domaine est encapsulé dans un concern :
# Le TokenAuthenticatable fonctionne pour User ET Provider
module TokenAuthenticatable
extend ActiveSupport::Concern
included do
has_many :access_tokens, as: :authenticatable, dependent: :destroy
end
def generate_access_token!
access_tokens.create!
end
def invalidate_tokens!
access_tokens.active.update_all(canceled_at: Time.current)
end
end
# Le SocialAuthenticatable gère Facebook pour les deux modèles
module SocialAuthenticatable
extend ActiveSupport::Concern
class_methods do
def find_or_create_from_social_profile!(provider, profile)
social = SocialId.find_by(provider: provider, social_id: profile[:id])
return social.social_authenticatable if social
ActiveRecord::Base.transaction do
user = create!(
email: profile[:email],
first_name: profile[:first_name],
last_name: profile[:last_name]
)
user.social_ids.create!(provider: provider, social_id: profile[:id])
user
end
end
end
end
Le résultat : User et Provider partagent exactement le même code d’authentification. Quand on a ajouté la connexion Facebook pour les prestataires (deux mois après la livraison initiale pour les utilisateurs), il a suffi d’ajouter include SocialAuthenticatable dans le modèle Provider. Zéro duplication.
On a huit concerns sur ce projet : TokenAuthenticatable, SocialAuthenticatable, Profilable, Postcodes, Password, Username, Referrable, UserFavorite. Chacun encapsule une responsabilité unique. Le modèle User fait 50 lignes — pas 500.
ApplicationRecord : le nouveau standard
Un détail de Rails 5 qui simplifie les concerns : ApplicationRecord. Avant, tous les modèles héritaient de ActiveRecord::Base directement — il n’y avait pas de classe intermédiaire pour mutualiser du comportement. Si on voulait ajouter une méthode à tous les modèles, on devait monkey-patcher ActiveRecord::Base (dangereux) ou créer un concern inclus partout (verbeux).
Rails 5 introduit ApplicationRecord — une classe abstraite qui s’intercale entre les modèles et ActiveRecord::Base. On y met les comportements communs à tous nos modèles : les conventions de sérialisation JSON, les scopes partagés, les callbacks de logging. C’est propre, explicite, et ça ne pollue pas le framework.
Le safe navigation operator &.
Ruby 2.3 (sorti en décembre dernier) nous a donné &. — l’opérateur de navigation sûre. On l’utilise partout dans les contrôleurs API :
# Avant — verbeux et défensif
def current_user
token = AccessToken.find_by_access_token(raw_token)
if token
token.authenticatable
else
nil
end
end
# Après — une ligne, idiomatique
def current_user
AccessToken.find_by_access_token(raw_token)&.authenticatable
end
Ce n’est pas spécifique à Rails, mais Ruby 2.3 + Rails 5 forment un combo où chaque ligne de code est plus concise et plus sûre. Le &. élimine les nil checks défensifs qui polluaient nos contrôleurs — et chaque ligne gagnée est une ligne de moins à maintenir.
Le moteur de disponibilités
C’est le morceau le plus intéressant de l’architecture — et le plus complexe.
Le problème
Un utilisateur cherche un prestataire de ménage disponible mardi prochain entre 9h et 12h dans le quartier de Shoreditch. L’API doit :
- Trouver les prestataires qui couvrent ce code postal
- Qui proposent le service “ménage”
- Qui sont disponibles sur ce créneau — c’est-à-dire qui ont déclaré être disponibles le mardi, qui n’ont pas posé de congé ce jour-là, et qui n’ont pas déjà une réservation confirmée sur cette plage
Les deux premiers filtres sont triviaux (des WHERE sur la base). Le troisième est un calcul.
La composition de plages horaires
On a développé une classe RangeComposition qui manipule des ensembles de plages numériques. Le principe est simple : on part des disponibilités du prestataire, on soustrait les congés et les réservations, on obtient les créneaux libres.
class RangeComposition
attr_accessor :ranges
def initialize(exclude_border: false)
@ranges = []
@exclude_border = exclude_border
end
def add(range_or_ranges)
Array(range_or_ranges).each { |r| add_range(r) }
self
end
def remove(range_or_ranges)
Array(range_or_ranges).each { |r| remove_range(r) }
self
end
private
def add_range(new_range)
before, overlapping, after = @ranges.partition_three { |r| overlaps?(r, new_range) }
merged_begin = [new_range.begin, *overlapping.map(&:begin)].min
merged_end = [new_range.end, *overlapping.map(&:end)].max
@ranges = before + [merged_begin..merged_end] + after
end
def remove_range(other)
@ranges = @ranges.flat_map do |range|
if overlaps?(range, other)
remaining = []
remaining << (range.begin..other.begin) if range.begin < other.begin
remaining << (other.end..range.end) if other.end < range.end
remaining
else
[range]
end
end
end
def overlaps?(a, b)
a.begin <= b.end && b.begin <= a.end
end
end
L’algorithme de add_range est le point délicat. Quand on ajoute une plage, il faut fusionner toutes les plages existantes qui la chevauchent. On partitionne les plages en trois groupes : celles qui sont avant (pas de chevauchement), celles qui chevauchent, et celles qui sont après. Les plages qui chevauchent sont fusionnées en prenant le min des débuts et le max des fins.
remove_range fait l’inverse : pour chaque plage existante qui chevauche la plage à retirer, on coupe et on garde les morceaux restants.
L’assemblage dans le modèle Provider
Le modèle Provider utilise RangeComposition pour calculer ses créneaux disponibles :
class Provider < ApplicationRecord
def slots(starting_day: 1, ending_day: 14)
composition = RangeComposition.new(exclude_border: true)
# 1. Ajouter les disponibilités hebdomadaires
composition.add(available_ranges(
starting_day: starting_day,
ending_day: ending_day
))
# 2. Soustraire les congés
composition.remove(holidays_ranges)
# 3. Soustraire les réservations confirmées
composition.remove(booked_availability_ranges)
# 4. Soustraire le passé (+ buffer de 12h)
composition.remove([0..12.hours.from_now.to_i])
# 5. Convertir en créneaux exploitables
composition.ranges.map do |range|
{
beginning_at: Time.at(range.begin).utc,
ending_at: Time.at(range.end).utc
}
end
end
private
def available_ranges(starting_day:, ending_day:)
working_days(starting_day: starting_day, ending_day: ending_day)
.flat_map do |date|
availabilities.active.where(weekday: date.wday).map do |a|
day_start = date.beginning_of_day.to_i
(day_start + a.beginning)..(day_start + a.ending)
end
end
end
end
Le exclude_border: true est subtil. Quand un prestataire a un créneau de 9h à 12h et un autre de 12h à 15h, on ne veut pas les fusionner en un seul créneau de 9h à 15h — 12h est une frontière intentionnelle (pause déjeuner, par exemple). L’option exclude_border considère que deux plages qui se touchent exactement ne se chevauchent pas.
La validation de créneau
Quand l’utilisateur réserve un créneau, on ne se contente pas de vérifier que le créneau est dans les disponibilités — on vérifie qu’il respecte les contraintes métier :
class AbstractBooking < ApplicationRecord
validate :beginning_at_is_a_valid_multiple_of_30_minutes
validate :ending_at_is_a_valid_multiple_of_15_minutes
validate :datetime_in_slots
private
def beginning_at_is_a_valid_multiple_of_30_minutes
return unless beginning_at
unless beginning_at.min % 30 == 0
errors.add(:beginning_at, "must start on a 30-minute slot")
end
end
def datetime_in_slots
return unless provider && beginning_at
unless provider.datetime_in_slots?(beginning_at, booking_id: id)
errors.add(:beginning_at, "is not in provider's available slots")
end
end
end
Les créneaux commencent toutes les 30 minutes (9:00, 9:30, 10:00…) — c’est un compromis entre granularité et complexité d’interface. La validation datetime_in_slots rappelle le calcul de disponibilité du provider en excluant la réservation courante (pour permettre la modification).
La machine à états des réservations
AASM pour les transitions
Une réservation n’est pas un simple enregistrement en base — c’est un objet métier avec un cycle de vie. On utilise AASM (Acts As State Machine) pour modéliser les transitions et les effets de bord :
class AbstractBooking < ApplicationRecord
include AASM
aasm column: :state do
state :pending, initial: true
state :confirmed, :declined, :cancelled, :completed
event :confirm do
transitions from: :pending, to: :confirmed
after do
FeedService.booking_request_accepted(self)
ReminderBookingJob.set(wait_until: beginning_at - 2.hours)
.perform_later(id)
end
end
event :decline do
transitions from: :pending, to: :declined
after { FeedService.booking_declined(self) }
end
event :cancel do
transitions from: [:pending, :confirmed], to: :cancelled
after { CancelBookingJob.perform_later(id) }
end
event :complete do
transitions from: :confirmed, to: :completed
after { FeedService.booking_completed(self) }
end
end
end
AASM apporte trois choses qu’un simple champ status ne donne pas :
- Les transitions sont déclaratives. On ne peut pas passer de
declinedàconfirmed— AASM lève une exception. Pas besoin deif/elsedéfensif dans les contrôleurs. - Les callbacks sont liés aux transitions. L’email de confirmation part quand on passe de
pendingàconfirmed, pas quand le champstatecontientconfirmed. La nuance est importante : un état restauré manuellement en base ne déclenche pas les effets de bord. - Les scopes sont générés automatiquement.
Booking.confirmed,Booking.pending— pas besoin de les écrire.
Les réservations récurrentes
Un cas métier qui a demandé du travail : les plans de réservation. Un utilisateur peut réserver un ménage hebdomadaire — tous les mardis de 9h à 12h. Le BookingPlan génère les Booking enfants :
class BookingPlan < AbstractBooking
has_many :bookings
def create_bookings(start_date = beginning_at)
book_datetimes(start_date).each do |datetime|
bookings.create!(
user: user,
provider: provider,
service: service,
beginning_at: datetime,
ending_at: datetime + duration.minutes,
duration: duration,
address: address,
options: options,
state: state # les enfants héritent de l'état du plan
)
end
end
end
Le job BookingPlanJob est déclenché quand le plan est confirmé. Il génère les réservations individuelles pour les prochaines semaines, en vérifiant la disponibilité du prestataire pour chaque créneau. Si un créneau est déjà pris, il est sauté — pas de conflit silencieux.
Les profils polymorphiques
Le défi du multi-services
Un prestataire de ménage a un tarif horaire et des options (repassage, linge, matériel fourni). Un coach fitness propose du yoga, du pilates ou du personal training en sessions de 30 ou 60 minutes avec des tarifs différents. Un dog-walker distingue les balades solo et en groupe, par taille de chien.
On ne peut pas mettre tout ça dans un seul modèle Provider. La solution : des profils polymorphiques — un modèle par vertical, liés au provider par une association belongs_to :profile, polymorphic: true.
Le concern Profilable et la configuration YAML
La vraie astuce, c’est le concern Profilable combiné avec un fichier profiles.yml. Plutôt que de coder en dur les attributs de chaque profil, on les déclare dans un YAML :
# config/profiles.yml
cleaning:
attributes:
hourly_rate:
type: MinMax
min: 8
max: 50
step: 1
ironing:
type: Boolean
laundry:
type: Boolean
brings_equipment:
type: Boolean
booking_options:
- ironing
- extra_clean
dog_walking:
attributes:
solo_30_rate:
type: MinMax
min: 5
max: 30
group_30_rate:
type: MinMax
min: 3
max: 20
large_dogs:
type: Boolean
max_dogs:
type: Direct
booking_options:
- walk_type
- dog_size
Le concern Profilable charge cette configuration au boot et génère dynamiquement les validations, les scopes de filtrage et les attributs exposés par l’API :
module Profilable
extend ActiveSupport::Concern
included do
has_one :provider, as: :profile
def self.profile_config
Rails.application.config_for(:profiles)[service_key]
end
def self.filtrable_attributes
profile_config['attributes'].select { |_, v| v['filtrable'] }
end
def self.displayable_attributes
profile_config['attributes'].select { |_, v| v['displayable'] }
end
end
def short_description
# Génère une description lisible à partir des attributs actifs
active_attrs = self.class.displayable_attributes.keys
.select { |attr| send(attr) }
active_attrs.map(&:humanize).join(', ')
end
end
L’avantage est considérable : quand on a ajouté la verticale “beauté” en cours de projet, on a créé une migration pour la table profile_blow_dries, ajouté une entrée dans profiles.yml, et c’était en ligne. Pas de changement dans le contrôleur API, pas de changement dans le FilterService, pas de changement dans le back-office — tout est piloté par la configuration.
Le FilterService
Le FilterService lit la configuration YAML pour savoir quels filtres appliquer à chaque service. Un filtre MinMax sur le tarif devient un WHERE hourly_rate_cents BETWEEN ? AND ?. Un filtre Boolean sur “repassage” devient un WHERE ironing = true. Le service encapsule cette logique :
class FilterService
def initialize(providers, params, service)
@providers = providers
@params = params
@config = service.profile_class.constantize.profile_config
end
def filter
@config['attributes'].each do |attr_name, attr_config|
value = @params[attr_name]
next unless value.present?
@providers = case attr_config['type']
when 'Boolean'
@providers.where(attr_name => cast_boolean(value))
when 'MinMax'
@providers.where("#{attr_name}_cents >= ? AND #{attr_name}_cents <= ?",
value['min'].to_i * 100, value['max'].to_i * 100)
when 'ChoiceSet'
@providers.where("#{attr_name} = ANY(?)", value)
end
end
@providers
end
end
C’est du Rails idiomatique — on compose des scopes ActiveRecord. La requête SQL finale est construite par enchaînement de where, et ActiveRecord s’occupe de l’optimisation. Sur un dataset de quelques centaines de prestataires par zone géographique, c’est instantané.
Sidekiq : les jobs en arrière-plan
Le projet utilise cinq jobs Sidekiq, chacun avec une responsabilité claire :
# Génère les réservations d'un plan récurrent
class BookingPlanJob < ApplicationJob
def perform(booking_plan_id)
plan = BookingPlan.find(booking_plan_id)
plan.create_bookings
end
end
# Annule les réservations pending après délai
class CancelBookingJob < ApplicationJob
def perform(booking_id)
booking = AbstractBooking.find(booking_id)
return unless booking.pending?
booking.cancel!
end
end
# Rappel 2h avant la prestation
class ReminderBookingJob < ApplicationJob
def perform(booking_id)
booking = AbstractBooking.find(booking_id)
return unless booking.confirmed?
FeedService.reminder_booking_upcoming(booking)
end
end
Le pattern est toujours le même : le job reçoit un ID, recharge l’objet, vérifie l’état, agit. Le return unless booking.pending? est important — entre le moment où le job est planifié et le moment où il s’exécute, l’état peut avoir changé. Un job doit être idempotent et résistant aux changements d’état.
Sidekiq tourne sur Redis avec six workers en concurrence. Les mailers ont leur propre queue avec une priorité inférieure — un pic d’envoi d’emails ne bloque pas les jobs métier.
Ce qu’on en retient
Après Simone.paris, Hively confirme que Rails 5 est le bon choix pour les API mobiles — et qu’on avait raison de migrer dès la sortie. Le mode API officialise une pratique qu’on défend depuis deux ans. Action Cable intègre le temps réel sans dépendance externe. La commande unifiée rails élimine une source de confusion quotidienne.
Là où d’autres équipes hésitent encore entre Express.js, Sails ou Laravel pour leurs backends d’apps mobiles, on livre des projets complets en Rails avec un écosystème qui n’a pas d’équivalent : Devise pour l’auth, AASM pour les machines à états, Sidekiq pour les jobs asynchrones, ActiveAdmin pour le back-office, CarrierWave pour les uploads, Mandrill et Nexmo pour les notifications. Tout s’emboîte. On ne passe pas trois jours à choisir et câbler des briques — on les branche et on se concentre sur le métier.
Les patterns qu’on recommande :
- Tokens plutôt que sessions pour les APIs mobiles. Pas besoin de JWT — un token opaque avec digest SHA-256 couvre 95% des cas. Plus simple, plus sûr, plus facile à révoquer.
- AASM pour tout ce qui a un cycle de vie. Les réservations, les inscriptions de prestataires, les candidatures — dès qu’un objet passe par des états, AASM rend les transitions explicites et les effets de bord prévisibles. On l’utilise systématiquement sur tous nos projets depuis Simone.
- Le polymorphisme Rails, avec modération. Les profils polymorphiques nous ont sauvé la mise sur un projet multi-services. Mais on ne polymorphise que ce qui est vraiment hétérogène — les adresses, les tokens, les participants de conversation sont polymorphiques aussi, et ça ajoute de la complexité au schéma.
- La configuration YAML plutôt que le code pour les attributs métier qui varient par verticale. C’est l’approche la plus ambitieuse du projet — et la plus payante. Ajouter un service sans toucher au code applicatif, c’est la différence entre une demi-journée et une semaine de développement.
Rails 5.1 est déjà annoncé avec l’intégration native de Webpack via Webpacker et le support de Yarn. La direction est claire : Rails embrasse l’écosystème JavaScript frontend au lieu de le combattre. C’est exactement ce qu’on fait déjà — on a juste un peu d’avance.
Le prochain article portera sur le front Ionic — le pipeline Webpack et ES6 qu’on a mis en place.