Questions d'entretien Ruby on Rails : Top 25 en 2026

Les 25 questions d'entretien Ruby on Rails les plus posées. Architecture MVC, Active Record, migrations, tests RSpec, API REST avec réponses détaillées et exemples de code.

Questions d'entretien Ruby on Rails - Guide complet

Les entretiens Ruby on Rails évaluent la maîtrise du framework Ruby le plus populaire, la compréhension de l'architecture MVC, l'ORM Active Record, et la capacité à construire des applications web robustes suivant la philosophie "Convention over Configuration". Ce guide couvre les 25 questions les plus posées, des fondamentaux Rails jusqu'aux patterns avancés de production.

Conseil pour l'entretien

Les recruteurs apprécient les candidats qui comprennent la philosophie Rails : "Convention over Configuration", DRY (Don't Repeat Yourself), et les Rails Way patterns. Expliquer pourquoi Rails fait certains choix architecturaux fait la différence.

Fondamentaux Ruby on Rails

Question 1 : Expliquez le pattern MVC dans Ruby on Rails

Le pattern Model-View-Controller (MVC) est le cœur architectural de Rails. Il sépare les responsabilités en trois couches distinctes pour une meilleure maintenabilité et testabilité du code.

ruby
# app/models/article.rb
# Le Model gère les données et la logique métier
class Article < ApplicationRecord
  # Validations des données
  validates :title, presence: true, length: { minimum: 5 }
  validates :body, presence: true

  # Associations avec d'autres modèles
  belongs_to :author, class_name: 'User'
  has_many :comments, dependent: :destroy
  has_many :tags, through: :article_tags

  # Scopes pour les requêtes réutilisables
  scope :published, -> { where(published: true) }
  scope :recent, -> { order(created_at: :desc).limit(10) }

  # Callbacks du cycle de vie
  before_save :generate_slug

  private

  def generate_slug
    self.slug = title.parameterize if title_changed?
  end
end
ruby
# app/controllers/articles_controller.rb
# Le Controller reçoit les requêtes et orchestre la réponse
class ArticlesController < ApplicationController
  before_action :authenticate_user!, except: [:index, :show]
  before_action :set_article, only: [:show, :edit, :update, :destroy]

  def index
    @articles = Article.published.recent.includes(:author)
  end

  def show
    @comments = @article.comments.includes(:user)
  end

  def create
    @article = current_user.articles.build(article_params)

    if @article.save
      redirect_to @article, notice: 'Article créé avec succès.'
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def set_article
    @article = Article.find(params[:id])
  end

  def article_params
    params.require(:article).permit(:title, :body, :published, tag_ids: [])
  end
end
erb
<%# app/views/articles/show.html.erb %>
<%# La View affiche les données au format HTML %>
<article class="article-detail">
  <header>
    <h1><%= @article.title %></h1>
    <p class="meta">
      Par <%= @article.author.name %>      <%= l @article.created_at, format: :long %>
    </p>
  </header>

  <div class="content">
    <%= simple_format @article.body %>
  </div>

  <%# Partial pour les commentaires %>
  <%= render @comments %>
</article>

Le flux typique : la requête arrive au Router, qui dispatche vers le Controller approprié. Le Controller interagit avec le Model pour récupérer ou modifier les données, puis passe ces données à la View pour le rendu HTML.

Question 2 : Qu'est-ce qu'Active Record et comment fonctionne l'ORM Rails ?

Active Record est l'ORM (Object-Relational Mapping) de Rails qui implémente le pattern Active Record. Chaque classe Model représente une table de base de données, et chaque instance représente une ligne.

ruby
# app/models/user.rb
# Active Record mappe automatiquement les colonnes aux attributs
class User < ApplicationRecord
  # La table 'users' est automatiquement associée
  # Colonnes: id, email, name, created_at, updated_at

  has_secure_password # BCrypt pour le mot de passe

  has_many :articles, foreign_key: :author_id
  has_one :profile, dependent: :destroy
  has_and_belongs_to_many :roles

  # Validations
  validates :email, presence: true,
                    uniqueness: { case_sensitive: false },
                    format: { with: URI::MailTo::EMAIL_REGEXP }

  # Callbacks
  before_save :normalize_email

  # Méthodes de classe pour les requêtes
  def self.admins
    joins(:roles).where(roles: { name: 'admin' })
  end

  private

  def normalize_email
    self.email = email.downcase.strip
  end
end
ruby
# Exemples de requêtes Active Record
# Console Rails ou dans un service

# Création
user = User.create!(email: 'dev@example.com', name: 'Alice', password: 'secret123')

# Lecture avec conditions
active_users = User.where(active: true).order(:name)
user = User.find_by(email: 'dev@example.com')

# Requêtes chaînées (lazy evaluation)
recent_admins = User.admins
                    .where('created_at > ?', 1.month.ago)
                    .includes(:profile)
                    .limit(10)

# N+1 prevention avec eager loading
articles = Article.includes(:author, :comments).published

# Mise à jour
user.update!(name: 'Alice Martin')

# Transactions
User.transaction do
  user.debit_balance!(100)
  recipient.credit_balance!(100)
  Payment.create!(from: user, to: recipient, amount: 100)
end

Active Record convertit les méthodes Ruby en requêtes SQL optimisées. Les méthodes comme where, joins, includes sont paresseuses (lazy) - la requête n'est exécutée qu'au moment de l'itération ou de l'appel à to_a.

Question 3 : Expliquez le système de migrations Rails

Les migrations permettent de versionner le schéma de base de données avec Ruby. Elles sont réversibles et permettent une évolution contrôlée de la structure de données.

ruby
# db/migrate/20260203100000_create_products.rb
# Migration pour créer une table
class CreateProducts < ActiveRecord::Migration[7.1]
  def change
    create_table :products do |t|
      t.string :name, null: false
      t.text :description
      t.decimal :price, precision: 10, scale: 2, null: false
      t.integer :stock_quantity, default: 0
      t.references :category, null: false, foreign_key: true
      t.boolean :active, default: true

      t.timestamps # created_at et updated_at automatiques
    end

    # Index pour les performances
    add_index :products, :name
    add_index :products, [:category_id, :active]
  end
end
ruby
# db/migrate/20260203110000_add_slug_to_products.rb
# Migration pour modifier une table existante
class AddSlugToProducts < ActiveRecord::Migration[7.1]
  def change
    add_column :products, :slug, :string
    add_index :products, :slug, unique: true

    # Remplir les slugs existants
    reversible do |dir|
      dir.up do
        Product.find_each do |product|
          product.update_column(:slug, product.name.parameterize)
        end
      end
    end

    # Rendre non-nullable après remplissage
    change_column_null :products, :slug, false
  end
end
bash
# Commandes de migration essentielles
rails db:migrate              # Exécuter les migrations pending
rails db:rollback             # Annuler la dernière migration
rails db:rollback STEP=3      # Annuler les 3 dernières migrations
rails db:migrate:status       # Voir le statut des migrations
rails db:seed                 # Exécuter db/seeds.rb
rails db:reset                # Drop, create, migrate, seed

Les migrations doivent être réversibles. La méthode change est intelligente et peut inverser automatiquement les opérations courantes. Pour les cas complexes, utiliser up et down séparément.

Active Record Avancé

Question 4 : Comment optimiser les requêtes N+1 dans Rails ?

Le problème N+1 survient quand une requête initiale est suivie de N requêtes additionnelles pour charger les associations. Rails propose plusieurs méthodes d'eager loading pour résoudre ce problème.

ruby
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def index
    # ❌ PROBLÈME N+1 : 1 requête + N requêtes par order
    # @orders = Order.all
    # Dans la view : order.user.name génère une requête par order

    # ✅ SOLUTION avec includes (eager loading)
    @orders = Order.includes(:user, :items)
                   .where(status: 'completed')
                   .order(created_at: :desc)
    # Génère seulement 3 requêtes total
  end

  def show
    # includes : charge les associations séparément (2-3 requêtes)
    @order = Order.includes(items: :product).find(params[:id])

    # preload : force le chargement séparé
    @order = Order.preload(:items, :user).find(params[:id])

    # eager_load : force un LEFT OUTER JOIN (1 requête)
    @order = Order.eager_load(:items).find(params[:id])
  end
end
ruby
# app/models/order.rb
class Order < ApplicationRecord
  belongs_to :user
  has_many :items, class_name: 'OrderItem'
  has_many :products, through: :items

  # Scope avec includes par défaut
  scope :with_details, -> { includes(:user, items: :product) }

  # Counter cache pour éviter COUNT queries
  # Nécessite: add_column :users, :orders_count, :integer, default: 0
  belongs_to :user, counter_cache: true
end
ruby
# Détection N+1 avec Bullet gem (development)
# config/environments/development.rb
config.after_initialize do
  Bullet.enable = true
  Bullet.alert = true
  Bullet.bullet_logger = true
  Bullet.rails_logger = true
end

# Bullet affichera des alertes quand :
# - Une requête N+1 est détectée
# - Un eager loading inutile est présent
# - Un counter cache devrait être utilisé

La règle : utiliser includes par défaut (Rails choisit la stratégie optimale), preload quand on veut forcer des requêtes séparées, eager_load quand on filtre sur les associations.

Question 5 : Expliquez les scopes et les query objects dans Rails

Les scopes encapsulent des conditions de requête réutilisables. Pour des requêtes complexes, les Query Objects offrent une meilleure organisation et testabilité.

ruby
# app/models/product.rb
class Product < ApplicationRecord
  # Scopes simples
  scope :active, -> { where(active: true) }
  scope :in_stock, -> { where('stock_quantity > 0') }
  scope :featured, -> { where(featured: true) }

  # Scopes avec paramètres
  scope :cheaper_than, ->(price) { where('price < ?', price) }
  scope :in_category, ->(category) { where(category: category) }

  # Scopes chaînables
  scope :available, -> { active.in_stock }

  # Scope avec jointures
  scope :with_recent_orders, -> {
    joins(:order_items)
      .where('order_items.created_at > ?', 30.days.ago)
      .distinct
  }

  # Scope avec sous-requête
  scope :bestsellers, -> {
    where(id: OrderItem.group(:product_id)
                       .order('COUNT(*) DESC')
                       .limit(10)
                       .select(:product_id))
  }
end
ruby
# app/queries/products_search_query.rb
# Query Object pour recherches complexes
class ProductsSearchQuery
  def initialize(relation = Product.all)
    @relation = relation
  end

  def call(params)
    @relation = filter_by_category(params[:category])
    @relation = filter_by_price_range(params[:min_price], params[:max_price])
    @relation = filter_by_search(params[:q])
    @relation = apply_sorting(params[:sort])
    @relation
  end

  private

  def filter_by_category(category)
    return @relation if category.blank?
    @relation.where(category_id: category)
  end

  def filter_by_price_range(min, max)
    @relation = @relation.where('price >= ?', min) if min.present?
    @relation = @relation.where('price <= ?', max) if max.present?
    @relation
  end

  def filter_by_search(query)
    return @relation if query.blank?
    @relation.where('name ILIKE ? OR description ILIKE ?',
                    "%#{query}%", "%#{query}%")
  end

  def apply_sorting(sort)
    case sort
    when 'price_asc' then @relation.order(price: :asc)
    when 'price_desc' then @relation.order(price: :desc)
    when 'newest' then @relation.order(created_at: :desc)
    else @relation.order(:name)
    end
  end
end

# Utilisation dans le controller
@products = ProductsSearchQuery.new(Product.active).call(params)

Les scopes sont parfaits pour des conditions simples et réutilisables. Les Query Objects conviennent aux recherches complexes avec plusieurs filtres optionnels et une logique de composition.

Prêt à réussir tes entretiens Ruby on Rails ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Routing et Controllers

Question 6 : Comment fonctionne le routing RESTful dans Rails ?

Rails encourage les routes RESTful qui mappent les verbes HTTP aux actions CRUD. Le router traduit les URLs en appels de controller spécifiques.

ruby
# config/routes.rb
Rails.application.routes.draw do
  # Routes RESTful standard (7 actions)
  resources :articles do
    # Routes imbriquées
    resources :comments, only: [:create, :destroy]

    # Routes membres (agissent sur une instance)
    member do
      post :publish
      delete :archive
    end

    # Routes collection (agissent sur la collection)
    collection do
      get :drafts
      get :search
    end
  end

  # Routes API avec namespace
  namespace :api do
    namespace :v1 do
      resources :products, only: [:index, :show, :create, :update] do
        resources :reviews, shallow: true
      end
    end
  end

  # Route personnalisée
  get 'dashboard', to: 'dashboard#index'

  # Contraintes sur les routes
  constraints(SubdomainConstraint.new) do
    resources :admin_settings
  end

  # Route racine
  root 'home#index'
end
bash
# rails routes - Affiche toutes les routes générées
#
# Verb   URI Pattern                    Controller#Action
# GET    /articles                      articles#index
# POST   /articles                      articles#create
# GET    /articles/new                  articles#new
# GET    /articles/:id/edit             articles#edit
# GET    /articles/:id                  articles#show
# PATCH  /articles/:id                  articles#update
# DELETE /articles/:id                  articles#destroy
# POST   /articles/:id/publish          articles#publish
# GET    /articles/drafts               articles#drafts

Les helpers de route générés (article_path(@article), new_article_path) permettent de référencer les URLs de manière dynamique et maintenable.

Question 7 : Expliquez les callbacks et filters dans les controllers

Les callbacks (before_action, after_action, around_action) permettent d'exécuter du code avant, après ou autour des actions du controller.

ruby
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # Protection CSRF activée par défaut
  protect_from_forgery with: :exception

  # Callback global pour l'authentification
  before_action :authenticate_user!

  # Gestion des erreurs globale
  rescue_from ActiveRecord::RecordNotFound, with: :not_found
  rescue_from ActionController::ParameterMissing, with: :bad_request

  private

  def not_found
    render json: { error: 'Resource not found' }, status: :not_found
  end

  def bad_request(exception)
    render json: { error: exception.message }, status: :bad_request
  end
end
ruby
# app/controllers/admin/products_controller.rb
class Admin::ProductsController < ApplicationController
  # Callbacks avec options
  before_action :require_admin
  before_action :set_product, only: [:show, :edit, :update, :destroy]
  after_action :log_activity, only: [:create, :update, :destroy]

  # Callback conditionnel
  before_action :check_stock, only: [:update], if: :stock_changed?

  def create
    @product = Product.new(product_params)

    if @product.save
      redirect_to [:admin, @product], notice: 'Produit créé.'
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    if @product.update(product_params)
      redirect_to [:admin, @product], notice: 'Produit mis à jour.'
    else
      render :edit, status: :unprocessable_entity
    end
  end

  private

  def require_admin
    redirect_to root_path unless current_user&.admin?
  end

  def set_product
    @product = Product.find(params[:id])
  end

  def stock_changed?
    params[:product][:stock_quantity].present?
  end

  def log_activity
    ActivityLog.create!(
      user: current_user,
      action: action_name,
      resource: @product
    )
  end

  def product_params
    params.require(:product).permit(:name, :price, :description, :stock_quantity)
  end
end

Les callbacks s'exécutent dans l'ordre de déclaration. Utiliser skip_before_action dans les sous-classes pour désactiver un callback hérité. Éviter les callbacks avec trop de logique métier - préférer les Service Objects.

Services et Architecture

Question 8 : Comment implémenter des Service Objects dans Rails ?

Les Service Objects encapsulent la logique métier complexe qui ne appartient ni au Model ni au Controller. Ils améliorent la testabilité et respectent le principe de responsabilité unique.

ruby
# app/services/order_processor.rb
# Service Object avec interface standardisée
class OrderProcessor
  def initialize(order, payment_method:)
    @order = order
    @payment_method = payment_method
  end

  def call
    return failure('Order already processed') if @order.processed?

    ActiveRecord::Base.transaction do
      validate_stock!
      process_payment!
      update_inventory!
      send_confirmation!

      @order.update!(status: 'completed', processed_at: Time.current)
    end

    success(@order)
  rescue PaymentError => e
    failure("Payment failed: #{e.message}")
  rescue InsufficientStockError => e
    failure("Stock insufficient: #{e.message}")
  end

  private

  def validate_stock!
    @order.items.each do |item|
      unless item.product.stock_quantity >= item.quantity
        raise InsufficientStockError, item.product.name
      end
    end
  end

  def process_payment!
    result = PaymentGateway.charge(
      amount: @order.total,
      method: @payment_method,
      description: "Order ##{@order.id}"
    )

    raise PaymentError, result.error unless result.success?
    @order.update!(payment_reference: result.transaction_id)
  end

  def update_inventory!
    @order.items.each do |item|
      item.product.decrement!(:stock_quantity, item.quantity)
    end
  end

  def send_confirmation!
    OrderMailer.confirmation(@order).deliver_later
  end

  def success(data)
    Result.new(success: true, data: data)
  end

  def failure(error)
    Result.new(success: false, error: error)
  end

  Result = Struct.new(:success, :data, :error, keyword_init: true) do
    def success? = success
    def failure? = !success
  end
end
ruby
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def create
    @order = current_user.orders.build(order_params)

    if @order.save
      result = OrderProcessor.new(@order, payment_method: params[:payment_method]).call

      if result.success?
        redirect_to @order, notice: 'Commande confirmée!'
      else
        @order.update!(status: 'payment_failed')
        flash.now[:alert] = result.error
        render :new, status: :unprocessable_entity
      end
    else
      render :new, status: :unprocessable_entity
    end
  end
end

Le pattern Service Object suit une convention simple : une classe, une responsabilité, une méthode publique call. Le retour d'un objet Result permet une gestion propre des succès et échecs.

Question 9 : Expliquez les Concerns dans Rails

Les Concerns permettent d'extraire et partager du code entre Models ou Controllers. Ils utilisent ActiveSupport::Concern pour une syntaxe propre d'inclusion.

ruby
# app/models/concerns/sluggable.rb
# Concern réutilisable pour générer des slugs
module Sluggable
  extend ActiveSupport::Concern

  included do
    # Code exécuté à l'inclusion
    before_validation :generate_slug, if: :should_generate_slug?
    validates :slug, presence: true, uniqueness: true
  end

  # Méthodes de classe
  class_methods do
    def find_by_slug!(slug)
      find_by!(slug: slug)
    end

    def sluggable_source(column = :title)
      @sluggable_source = column
    end

    def sluggable_source_column
      @sluggable_source || :title
    end
  end

  # Méthodes d'instance
  def to_param
    slug
  end

  private

  def should_generate_slug?
    slug.blank? || send("#{self.class.sluggable_source_column}_changed?")
  end

  def generate_slug
    source = send(self.class.sluggable_source_column)
    return if source.blank?

    base_slug = source.parameterize
    self.slug = unique_slug(base_slug)
  end

  def unique_slug(base)
    slug = base
    counter = 1

    while self.class.where(slug: slug).where.not(id: id).exists?
      slug = "#{base}-#{counter}"
      counter += 1
    end

    slug
  end
end
ruby
# app/models/article.rb
class Article < ApplicationRecord
  include Sluggable

  sluggable_source :title # Optionnel, :title par défaut
end

# app/models/product.rb
class Product < ApplicationRecord
  include Sluggable

  sluggable_source :name
end
ruby
# app/controllers/concerns/pagination.rb
# Concern pour les controllers
module Pagination
  extend ActiveSupport::Concern

  included do
    helper_method :page_param, :per_page_param
  end

  private

  def paginate(relation)
    relation.page(page_param).per(per_page_param)
  end

  def page_param
    params[:page]&.to_i || 1
  end

  def per_page_param
    [params[:per_page]&.to_i || 25, 100].min
  end
end

Les Concerns sont utiles pour le code véritablement partagé. Éviter de créer des Concerns pour simplement "raccourcir" un Model - cela masque la complexité sans la réduire.

Tests avec RSpec

Question 10 : Comment structurer les tests RSpec dans Rails ?

RSpec est le framework de test standard pour Rails. Une bonne structure de tests inclut les specs de Models, Controllers, Services, et les tests d'intégration.

ruby
# spec/models/user_spec.rb
require 'rails_helper'

RSpec.describe User, type: :model do
  # Factories avec FactoryBot
  let(:user) { build(:user) }
  let(:admin) { build(:user, :admin) }

  describe 'validations' do
    it { is_expected.to validate_presence_of(:email) }
    it { is_expected.to validate_uniqueness_of(:email).case_insensitive }

    it 'validates email format' do
      user.email = 'invalid'
      expect(user).not_to be_valid
      expect(user.errors[:email]).to include('is invalid')
    end
  end

  describe 'associations' do
    it { is_expected.to have_many(:articles).dependent(:destroy) }
    it { is_expected.to have_one(:profile) }
    it { is_expected.to belong_to(:organization).optional }
  end

  describe '#full_name' do
    it 'returns first and last name combined' do
      user = build(:user, first_name: 'John', last_name: 'Doe')
      expect(user.full_name).to eq('John Doe')
    end

    it 'handles missing last name' do
      user = build(:user, first_name: 'John', last_name: nil)
      expect(user.full_name).to eq('John')
    end
  end

  describe '.active' do
    it 'returns only active users' do
      active = create(:user, active: true)
      inactive = create(:user, active: false)

      expect(User.active).to include(active)
      expect(User.active).not_to include(inactive)
    end
  end
end
ruby
# spec/services/order_processor_spec.rb
require 'rails_helper'

RSpec.describe OrderProcessor do
  let(:user) { create(:user) }
  let(:product) { create(:product, stock_quantity: 10, price: 100) }
  let(:order) { create(:order, user: user, items: [build(:order_item, product: product, quantity: 2)]) }

  subject { described_class.new(order, payment_method: 'card') }

  describe '#call' do
    context 'when order is valid' do
      before do
        allow(PaymentGateway).to receive(:charge).and_return(
          OpenStruct.new(success?: true, transaction_id: 'txn_123')
        )
      end

      it 'processes the order successfully' do
        result = subject.call

        expect(result).to be_success
        expect(order.reload.status).to eq('completed')
      end

      it 'decrements product stock' do
        expect { subject.call }.to change { product.reload.stock_quantity }.by(-2)
      end

      it 'sends confirmation email' do
        expect { subject.call }
          .to have_enqueued_mail(OrderMailer, :confirmation)
          .with(order)
      end
    end

    context 'when payment fails' do
      before do
        allow(PaymentGateway).to receive(:charge).and_return(
          OpenStruct.new(success?: false, error: 'Card declined')
        )
      end

      it 'returns failure result' do
        result = subject.call

        expect(result).to be_failure
        expect(result.error).to include('Card declined')
      end

      it 'does not update order status' do
        expect { subject.call }.not_to change { order.reload.status }
      end
    end
  end
end
ruby
# spec/requests/api/v1/products_spec.rb
require 'rails_helper'

RSpec.describe 'API V1 Products', type: :request do
  let(:user) { create(:user) }
  let(:headers) { { 'Authorization' => "Bearer #{user.api_token}" } }

  describe 'GET /api/v1/products' do
    let!(:products) { create_list(:product, 3, :active) }

    it 'returns list of products' do
      get '/api/v1/products', headers: headers

      expect(response).to have_http_status(:ok)
      expect(json_response['data'].size).to eq(3)
    end

    it 'filters by category' do
      category = create(:category)
      categorized = create(:product, category: category)

      get '/api/v1/products', params: { category_id: category.id }, headers: headers

      expect(json_response['data'].map { |p| p['id'] }).to eq([categorized.id])
    end
  end

  describe 'POST /api/v1/products' do
    let(:valid_params) do
      { product: { name: 'New Product', price: 99.99, category_id: create(:category).id } }
    end

    it 'creates a new product' do
      expect {
        post '/api/v1/products', params: valid_params, headers: headers
      }.to change(Product, :count).by(1)

      expect(response).to have_http_status(:created)
    end
  end
end

Les bonnes pratiques : utiliser let pour les données, describe pour les méthodes/contextes, context pour les conditions, et it pour les assertions spécifiques. Un test doit tester une seule chose.

Question 11 : Comment utiliser les Factories avec FactoryBot ?

FactoryBot permet de créer des données de test de manière déclarative et maintenable. Les factories remplacent les fixtures statiques.

ruby
# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    # Séquences pour l'unicité
    sequence(:email) { |n| "user#{n}@example.com" }
    first_name { Faker::Name.first_name }
    last_name { Faker::Name.last_name }
    password { 'password123' }
    confirmed_at { Time.current }

    # Traits pour les variations
    trait :admin do
      role { 'admin' }
      after(:create) do |user|
        user.permissions.create!(name: 'admin_access')
      end
    end

    trait :unconfirmed do
      confirmed_at { nil }
    end

    trait :with_profile do
      after(:create) do |user|
        create(:profile, user: user)
      end
    end

    trait :with_articles do
      transient do
        articles_count { 3 }
      end

      after(:create) do |user, evaluator|
        create_list(:article, evaluator.articles_count, author: user)
      end
    end

    # Factory héritée
    factory :admin_user do
      admin
      with_profile
    end
  end
end
ruby
# spec/factories/orders.rb
FactoryBot.define do
  factory :order do
    user
    status { 'pending' }

    trait :with_items do
      transient do
        items_count { 2 }
      end

      after(:create) do |order, evaluator|
        create_list(:order_item, evaluator.items_count, order: order)
        order.recalculate_total!
      end
    end

    trait :completed do
      status { 'completed' }
      processed_at { Time.current }
      with_items
    end

    trait :high_value do
      after(:create) do |order|
        create(:order_item, order: order, quantity: 10, unit_price: 500)
        order.recalculate_total!
      end
    end
  end
end
ruby
# Utilisation dans les tests
RSpec.describe OrderProcessor do
  # build : instance non sauvegardée
  let(:user) { build(:user) }

  # create : instance sauvegardée en DB
  let(:order) { create(:order, :with_items, user: user) }

  # create_list : plusieurs instances
  let(:products) { create_list(:product, 5) }

  # Combinaison de traits
  let(:admin) { create(:user, :admin, :with_profile) }

  # Override d'attributs
  let(:expensive_order) { create(:order, :with_items, items_count: 10) }

  # build_stubbed : plus rapide, pour les tests unitaires
  let(:stubbed_user) { build_stubbed(:user) }
end

Préférer build ou build_stubbed à create quand la persistence n'est pas nécessaire - cela accélère significativement les tests.

Background Jobs

Question 12 : Comment utiliser Active Job et Sidekiq dans Rails ?

Active Job fournit une interface unifiée pour les background jobs, indépendamment du backend (Sidekiq, Resque, etc.). Sidekiq est le choix populaire pour sa performance avec Redis.

ruby
# app/jobs/process_order_job.rb
class ProcessOrderJob < ApplicationJob
  queue_as :default

  # Retry configuration
  retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3
  retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 10
  discard_on ActiveJob::DeserializationError

  # Sidekiq options (si Sidekiq backend)
  sidekiq_options retry: 5, backtrace: true

  def perform(order_id)
    order = Order.find(order_id)

    OrderProcessor.new(order).call
  rescue ActiveRecord::RecordNotFound
    # Order supprimée entre l'enqueue et l'exécution
    Rails.logger.warn("Order #{order_id} not found, skipping job")
  end
end
ruby
# app/jobs/batch_email_job.rb
class BatchEmailJob < ApplicationJob
  queue_as :mailers

  # Rate limiting avec Sidekiq Enterprise ou gem throttle
  sidekiq_options throttle: { threshold: 100, period: 1.minute }

  def perform(user_ids, template_id)
    template = EmailTemplate.find(template_id)

    User.where(id: user_ids).find_each do |user|
      UserMailer.custom_email(user, template).deliver_later
    end
  end
end
ruby
# Enqueuing jobs
# Immédiat
ProcessOrderJob.perform_later(order.id)

# Différé
ProcessOrderJob.set(wait: 5.minutes).perform_later(order.id)

# À une heure spécifique
ProcessOrderJob.set(wait_until: Date.tomorrow.noon).perform_later(order.id)

# Queue spécifique
ProcessOrderJob.set(queue: :critical).perform_later(order.id)

# Synchrone (pour les tests ou debugging)
ProcessOrderJob.perform_now(order.id)
ruby
# config/sidekiq.yml
:concurrency: 10
:queues:
  - [critical, 3]    # Priorité haute, poids 3
  - [default, 2]     # Priorité moyenne, poids 2
  - [mailers, 1]     # Priorité basse, poids 1
  - [low, 1]

:schedule:
  cleanup_job:
    cron: '0 3 * * *'  # Tous les jours à 3h
    class: CleanupJob

Active Job abstrait le backend, mais accéder aux fonctionnalités spécifiques (batches, rate limiting) nécessite souvent de coupler au backend choisi.

Prêt à réussir tes entretiens Ruby on Rails ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

API Development

Question 13 : Comment construire une API RESTful avec Rails ?

Rails facilite la construction d'APIs JSON avec les Controllers API-only et les serializers. Une bonne API est versionnée, documentée et sécurisée.

ruby
# app/controllers/api/v1/base_controller.rb
module Api
  module V1
    class BaseController < ActionController::API
      include ActionController::HttpAuthentication::Token::ControllerMethods

      before_action :authenticate_token!

      rescue_from ActiveRecord::RecordNotFound, with: :not_found
      rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
      rescue_from ActionController::ParameterMissing, with: :bad_request

      private

      def authenticate_token!
        authenticate_or_request_with_http_token do |token, options|
          @current_user = User.find_by(api_token: token)
        end
      end

      def current_user
        @current_user
      end

      def not_found(exception)
        render json: { error: 'Resource not found', details: exception.message },
               status: :not_found
      end

      def unprocessable_entity(exception)
        render json: { error: 'Validation failed', details: exception.record.errors },
               status: :unprocessable_entity
      end

      def bad_request(exception)
        render json: { error: 'Bad request', details: exception.message },
               status: :bad_request
      end
    end
  end
end
ruby
# app/controllers/api/v1/products_controller.rb
module Api
  module V1
    class ProductsController < BaseController
      before_action :set_product, only: [:show, :update, :destroy]

      def index
        @products = Product.active
                           .includes(:category)
                           .page(params[:page])
                           .per(params[:per_page] || 20)

        render json: {
          data: ProductSerializer.new(@products).serializable_hash,
          meta: pagination_meta(@products)
        }
      end

      def show
        render json: ProductSerializer.new(@product, include: [:category, :reviews])
      end

      def create
        @product = Product.new(product_params)
        @product.save!

        render json: ProductSerializer.new(@product), status: :created
      end

      def update
        @product.update!(product_params)
        render json: ProductSerializer.new(@product)
      end

      def destroy
        @product.destroy!
        head :no_content
      end

      private

      def set_product
        @product = Product.find(params[:id])
      end

      def product_params
        params.require(:product).permit(:name, :description, :price, :category_id)
      end

      def pagination_meta(collection)
        {
          current_page: collection.current_page,
          total_pages: collection.total_pages,
          total_count: collection.total_count
        }
      end
    end
  end
end
ruby
# app/serializers/product_serializer.rb
# Avec jsonapi-serializer gem
class ProductSerializer
  include JSONAPI::Serializer

  attributes :id, :name, :description, :price, :created_at

  attribute :formatted_price do |product|
    "$#{product.price.to_f.round(2)}"
  end

  belongs_to :category
  has_many :reviews

  link :self do |product|
    Rails.application.routes.url_helpers.api_v1_product_url(product)
  end
end

Les bonnes pratiques API : versionner via namespace, utiliser les codes HTTP appropriés, paginer les collections, et fournir des messages d'erreur clairs.

Question 14 : Comment implémenter l'authentification JWT dans Rails ?

JWT (JSON Web Tokens) est une méthode stateless d'authentification populaire pour les APIs. Le token encode l'identité de l'utilisateur et sa validité.

ruby
# app/services/jwt_service.rb
class JwtService
  SECRET_KEY = Rails.application.credentials.secret_key_base
  ALGORITHM = 'HS256'.freeze

  class << self
    def encode(payload, exp = 24.hours.from_now)
      payload[:exp] = exp.to_i
      payload[:iat] = Time.current.to_i
      JWT.encode(payload, SECRET_KEY, ALGORITHM)
    end

    def decode(token)
      decoded = JWT.decode(token, SECRET_KEY, true, algorithm: ALGORITHM)
      HashWithIndifferentAccess.new(decoded.first)
    rescue JWT::ExpiredSignature
      raise AuthenticationError, 'Token has expired'
    rescue JWT::DecodeError
      raise AuthenticationError, 'Invalid token'
    end
  end
end
ruby
# app/controllers/api/v1/auth_controller.rb
module Api
  module V1
    class AuthController < ActionController::API
      def login
        user = User.find_by(email: params[:email])

        if user&.authenticate(params[:password])
          token = JwtService.encode(user_id: user.id)
          render json: {
            token: token,
            user: UserSerializer.new(user),
            expires_at: 24.hours.from_now
          }
        else
          render json: { error: 'Invalid credentials' }, status: :unauthorized
        end
      end

      def refresh
        token = JwtService.encode(user_id: current_user.id)
        render json: { token: token, expires_at: 24.hours.from_now }
      end
    end
  end
end
ruby
# app/controllers/concerns/jwt_authenticatable.rb
module JwtAuthenticatable
  extend ActiveSupport::Concern

  included do
    before_action :authenticate_jwt!
  end

  private

  def authenticate_jwt!
    header = request.headers['Authorization']
    token = header&.split(' ')&.last

    raise AuthenticationError, 'Missing token' unless token

    decoded = JwtService.decode(token)
    @current_user = User.find(decoded[:user_id])
  rescue AuthenticationError => e
    render json: { error: e.message }, status: :unauthorized
  rescue ActiveRecord::RecordNotFound
    render json: { error: 'User not found' }, status: :unauthorized
  end

  def current_user
    @current_user
  end
end

Pour la production, considérer : refresh tokens, token blacklisting pour logout, et des durées de vie courtes. Des gems comme devise-jwt simplifient l'implémentation.

Caching et Performance

Question 15 : Comment implémenter le caching dans Rails ?

Rails offre plusieurs niveaux de caching : fragment caching, Russian Doll caching, low-level caching. Le choix dépend du cas d'usage.

ruby
# config/environments/production.rb
config.action_controller.perform_caching = true
config.cache_store = :redis_cache_store, {
  url: ENV['REDIS_URL'],
  namespace: 'myapp:cache',
  expires_in: 1.day,
  race_condition_ttl: 10.seconds
}
erb
<%# app/views/products/index.html.erb %>
<%# Fragment caching avec cache key automatique %>
<% @products.each do |product| %>
  <%# Cache basé sur updated_at du produit %>
  <% cache product do %>
    <%= render product %>
  <% end %>
<% end %>

<%# Russian Doll caching - cache imbriqué %>
<% cache ['v1', @category] do %>
  <h2><%= @category.name %></h2>

  <% @category.products.each do |product| %>
    <% cache ['v1', product] do %>
      <%= render product %>
    <% end %>
  <% end %>
<% end %>

<%# Cache conditionnel %>
<% cache_if current_user.nil?, @product do %>
  <%= render @product %>
<% end %>
ruby
# app/models/product.rb
class Product < ApplicationRecord
  # Touch parent pour invalider le cache Russian Doll
  belongs_to :category, touch: true

  # Cache key personnalisé
  def cache_key_with_version
    "#{super}/#{reviews.maximum(:updated_at)&.to_i}"
  end
end
ruby
# Low-level caching dans les services
class DashboardStatsService
  def call
    Rails.cache.fetch('dashboard:stats', expires_in: 15.minutes) do
      {
        total_users: User.count,
        active_users: User.where('last_sign_in_at > ?', 30.days.ago).count,
        total_orders: Order.completed.count,
        revenue_mtd: Order.completed.where(created_at: Time.current.beginning_of_month..).sum(:total)
      }
    end
  end
end

# Cache avec race condition protection
Rails.cache.fetch('popular_products', expires_in: 1.hour, race_condition_ttl: 10.seconds) do
  Product.bestsellers.limit(10).to_a
end

# Invalidation explicite
Rails.cache.delete('dashboard:stats')
Rails.cache.delete_matched('products:*')

Le Russian Doll caching est efficace car seuls les fragments modifiés sont régénérés. Utiliser touch: true sur les associations pour propager l'invalidation.

Question 16 : Comment optimiser les performances d'une application Rails ?

L'optimisation Rails couvre plusieurs aspects : requêtes DB, caching, assets, et architecture. Une approche méthodique avec monitoring est essentielle.

ruby
# Database optimization
# config/database.yml
production:
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  prepared_statements: true
  advisory_locks: true

# app/models/order.rb
class Order < ApplicationRecord
  # Index composés pour les requêtes fréquentes
  # add_index :orders, [:user_id, :status, :created_at]

  # Select only needed columns
  scope :summary, -> { select(:id, :status, :total, :created_at) }

  # Batch processing pour les gros volumes
  def self.process_pending
    pending.find_each(batch_size: 1000) do |order|
      ProcessOrderJob.perform_later(order.id)
    end
  end

  # Éviter les calculs répétitifs
  def self.revenue_by_month
    completed
      .group("DATE_TRUNC('month', created_at)")
      .sum(:total)
  end
end
ruby
# Memory optimization
# config/puma.rb
workers ENV.fetch("WEB_CONCURRENCY") { 2 }
threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
threads threads_count, threads_count

preload_app!

before_fork do
  ActiveRecord::Base.connection_pool.disconnect!
end

on_worker_boot do
  ActiveRecord::Base.establish_connection
end
ruby
# Profiling avec rack-mini-profiler
# Gemfile
group :development do
  gem 'rack-mini-profiler'
  gem 'memory_profiler'
  gem 'stackprof'
end

# config/initializers/mini_profiler.rb
if defined?(Rack::MiniProfiler)
  Rack::MiniProfiler.config.position = 'bottom-right'
  Rack::MiniProfiler.config.start_hidden = true
end
ruby
# Lazy loading et pagination
class ProductsController < ApplicationController
  def index
    @products = Product.active
                       .includes(:category, :primary_image)
                       .page(params[:page])
                       .per(24)

    # Prefetch pour la page suivante
    if @products.next_page
      Rails.cache.fetch("products:page:#{@products.next_page}", expires_in: 5.minutes) do
        Product.active.page(@products.next_page).per(24).to_a
      end
    end
  end
end

Outils essentiels : rack-mini-profiler pour le profiling, bullet pour les N+1, New Relic ou Scout pour le monitoring production.

Security

Question 17 : Quelles sont les bonnes pratiques de sécurité dans Rails ?

Rails inclut des protections par défaut contre les vulnérabilités courantes. Comprendre et configurer correctement ces protections est crucial.

ruby
# CSRF Protection
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # Activé par défaut, génère une exception si token invalide
  protect_from_forgery with: :exception

  # Pour les APIs, utiliser :null_session
  # protect_from_forgery with: :null_session
end

# Dans les vues, le token est inclus automatiquement dans les forms
# <%= form_with ... %> inclut authenticity_token

# Pour les requêtes AJAX
# Ajouter le header X-CSRF-Token avec la valeur de csrf_meta_tags
ruby
# SQL Injection Prevention
# ✅ Paramètres interpolés automatiquement échappés
User.where('email = ?', params[:email])
User.where(email: params[:email])

# ❌ DANGER - Interpolation directe
User.where("email = '#{params[:email]}'")

# ✅ Pour les clauses ORDER dynamiques
ALLOWED_SORTS = %w[name created_at price].freeze
sort_column = ALLOWED_SORTS.include?(params[:sort]) ? params[:sort] : 'name'
Product.order(sort_column)
ruby
# XSS Protection
# Rails échappe automatiquement le HTML dans les vues

# ✅ Échappé automatiquement
<%= user.name %>

# ❌ Dangereux - contenu non échappé
<%== user.bio %>
<%= raw user.bio %>
<%= user.bio.html_safe %>

# ✅ Pour le HTML sûr, utiliser sanitize
<%= sanitize user.bio, tags: %w[p br strong em] %>
ruby
# Strong Parameters
class UsersController < ApplicationController
  def update
    @user.update!(user_params)
  end

  private

  def user_params
    # Whitelist explicite des attributs autorisés
    params.require(:user).permit(:name, :email, :avatar)

    # Pour les admins uniquement
    if current_user.admin?
      params.require(:user).permit(:name, :email, :role, :active)
    else
      params.require(:user).permit(:name, :email)
    end
  end
end
ruby
# Secure Headers
# config/initializers/secure_headers.rb
Rails.application.config.action_dispatch.default_headers = {
  'X-Frame-Options' => 'SAMEORIGIN',
  'X-XSS-Protection' => '1; mode=block',
  'X-Content-Type-Options' => 'nosniff',
  'X-Download-Options' => 'noopen',
  'X-Permitted-Cross-Domain-Policies' => 'none',
  'Referrer-Policy' => 'strict-origin-when-cross-origin'
}

# Content Security Policy
Rails.application.config.content_security_policy do |policy|
  policy.default_src :self
  policy.script_src  :self
  policy.style_src   :self, :unsafe_inline
  policy.img_src     :self, :data, 'https:'
end

Auditer régulièrement avec brakeman (analyse statique de sécurité) et maintenir les gems à jour avec bundle audit.

Question 18 : Comment gérer l'authentification et l'autorisation dans Rails ?

L'authentification vérifie l'identité, l'autorisation contrôle les permissions. Devise gère l'auth, Pundit ou CanCanCan gèrent l'autorisation.

ruby
# Devise setup
# app/models/user.rb
class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :confirmable, :lockable, :trackable

  enum role: { user: 0, moderator: 1, admin: 2 }

  def admin?
    role == 'admin'
  end
end
ruby
# Pundit policies
# app/policies/article_policy.rb
class ArticlePolicy < ApplicationPolicy
  def index?
    true
  end

  def show?
    record.published? || owner_or_admin?
  end

  def create?
    user.present?
  end

  def update?
    owner_or_admin?
  end

  def destroy?
    owner_or_admin?
  end

  def publish?
    user&.admin? || user&.moderator?
  end

  # Scope pour les collections
  class Scope < Scope
    def resolve
      if user&.admin?
        scope.all
      elsif user
        scope.where(published: true).or(scope.where(author: user))
      else
        scope.where(published: true)
      end
    end
  end

  private

  def owner_or_admin?
    user&.admin? || record.author == user
  end
end
ruby
# Controller avec Pundit
class ArticlesController < ApplicationController
  include Pundit::Authorization

  after_action :verify_authorized, except: :index
  after_action :verify_policy_scoped, only: :index

  def index
    @articles = policy_scope(Article).includes(:author).page(params[:page])
  end

  def show
    @article = Article.find(params[:id])
    authorize @article
  end

  def update
    @article = Article.find(params[:id])
    authorize @article

    if @article.update(article_params)
      redirect_to @article, notice: 'Article updated.'
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def publish
    @article = Article.find(params[:id])
    authorize @article

    @article.update!(published: true, published_at: Time.current)
    redirect_to @article, notice: 'Article published.'
  end

  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

  private

  def user_not_authorized
    flash[:alert] = "You are not authorized to perform this action."
    redirect_back(fallback_location: root_path)
  end
end

Pundit est plus explicite et testable que CanCanCan. Chaque action a une policy method correspondante, et les scopes filtrent les collections automatiquement.

Rails Avancé

Question 19 : Expliquez le pattern Repository dans Rails

Le pattern Repository isole la logique d'accès aux données du reste de l'application. Bien que Rails utilise Active Record (pattern différent), Repository peut être utile pour les cas complexes.

ruby
# app/repositories/base_repository.rb
class BaseRepository
  def initialize(model_class)
    @model_class = model_class
  end

  def all
    @model_class.all
  end

  def find(id)
    @model_class.find(id)
  end

  def find_by(attributes)
    @model_class.find_by(attributes)
  end

  def create(attributes)
    @model_class.create(attributes)
  end

  def update(record, attributes)
    record.update(attributes)
  end

  def delete(record)
    record.destroy
  end
end
ruby
# app/repositories/product_repository.rb
class ProductRepository < BaseRepository
  def initialize
    super(Product)
  end

  def active
    @model_class.where(active: true)
  end

  def in_category(category_id)
    @model_class.where(category_id: category_id)
  end

  def search(query)
    @model_class.where('name ILIKE ? OR description ILIKE ?',
                       "%#{query}%", "%#{query}%")
  end

  def with_stock
    @model_class.where('stock_quantity > 0')
  end

  def bestsellers(limit: 10)
    @model_class
      .joins(:order_items)
      .group(:id)
      .order('COUNT(order_items.id) DESC')
      .limit(limit)
  end

  def for_homepage
    active
      .with_stock
      .includes(:category, :primary_image)
      .order(featured: :desc, created_at: :desc)
      .limit(12)
  end
end
ruby
# Utilisation dans un service
class ProductSearchService
  def initialize(repository: ProductRepository.new)
    @repository = repository
  end

  def call(params)
    products = @repository.active
    products = products.in_category(params[:category]) if params[:category]
    products = products.search(params[:query]) if params[:query].present?
    products = products.with_stock if params[:in_stock]
    products
  end
end

# Facilite le testing avec des mocks
RSpec.describe ProductSearchService do
  let(:repository) { instance_double(ProductRepository) }
  let(:service) { described_class.new(repository: repository) }

  it 'filters by category' do
    products = double('products')
    allow(repository).to receive(:active).and_return(products)
    allow(products).to receive(:in_category).with(1).and_return(products)

    service.call(category: 1)

    expect(products).to have_received(:in_category).with(1)
  end
end

Le Repository est optionnel en Rails car Active Record est déjà un excellent pattern. L'utiliser pour des requêtes complexes ou quand l'isolation du storage est importante.

Question 20 : Comment implémenter le pattern CQRS dans Rails ?

CQRS (Command Query Responsibility Segregation) sépare les opérations de lecture et d'écriture. En Rails, cela se traduit par des classes distinctes pour les queries et les commands.

ruby
# app/commands/base_command.rb
class BaseCommand
  include ActiveModel::Validations

  def self.call(*args)
    new(*args).call
  end

  def call
    return failure(errors) unless valid?

    execute
  end

  private

  def execute
    raise NotImplementedError
  end

  def success(data = nil)
    CommandResult.success(data)
  end

  def failure(errors)
    CommandResult.failure(errors)
  end
end

CommandResult = Struct.new(:success, :data, :errors, keyword_init: true) do
  def success? = success
  def failure? = !success

  def self.success(data)
    new(success: true, data: data, errors: [])
  end

  def self.failure(errors)
    new(success: false, data: nil, errors: Array(errors))
  end
end
ruby
# app/commands/orders/create_order_command.rb
module Orders
  class CreateOrderCommand < BaseCommand
    attr_reader :user, :items, :shipping_address

    validates :user, presence: true
    validates :items, presence: true
    validate :validate_items_availability

    def initialize(user:, items:, shipping_address:)
      @user = user
      @items = items
      @shipping_address = shipping_address
    end

    private

    def execute
      order = nil

      ActiveRecord::Base.transaction do
        order = Order.create!(
          user: user,
          shipping_address: shipping_address,
          status: 'pending'
        )

        items.each do |item|
          order.items.create!(
            product_id: item[:product_id],
            quantity: item[:quantity],
            unit_price: Product.find(item[:product_id]).price
          )
        end

        order.calculate_total!
      end

      OrderCreatedEvent.broadcast(order)
      success(order)
    rescue ActiveRecord::RecordInvalid => e
      failure(e.message)
    end

    def validate_items_availability
      items.each do |item|
        product = Product.find_by(id: item[:product_id])

        unless product&.stock_quantity&.>= item[:quantity]
          errors.add(:items, "Product #{item[:product_id]} not available")
        end
      end
    end
  end
end
ruby
# app/queries/orders/user_orders_query.rb
module Orders
  class UserOrdersQuery
    def initialize(user, params = {})
      @user = user
      @params = params
    end

    def call
      orders = @user.orders.includes(:items, items: :product)
      orders = apply_status_filter(orders)
      orders = apply_date_filter(orders)
      orders = apply_sorting(orders)
      orders.page(@params[:page]).per(@params[:per_page] || 20)
    end

    private

    def apply_status_filter(orders)
      return orders unless @params[:status]
      orders.where(status: @params[:status])
    end

    def apply_date_filter(orders)
      orders = orders.where('created_at >= ?', @params[:from]) if @params[:from]
      orders = orders.where('created_at <= ?', @params[:to]) if @params[:to]
      orders
    end

    def apply_sorting(orders)
      case @params[:sort]
      when 'oldest' then orders.order(created_at: :asc)
      when 'total_desc' then orders.order(total: :desc)
      else orders.order(created_at: :desc)
      end
    end
  end
end
ruby
# Controller utilisant CQRS
class OrdersController < ApplicationController
  def index
    @orders = Orders::UserOrdersQuery.new(current_user, filter_params).call
  end

  def create
    result = Orders::CreateOrderCommand.call(
      user: current_user,
      items: order_params[:items],
      shipping_address: order_params[:shipping_address]
    )

    if result.success?
      redirect_to result.data, notice: 'Order created!'
    else
      flash.now[:alert] = result.errors.join(', ')
      render :new, status: :unprocessable_entity
    end
  end
end

CQRS brille pour les applications complexes avec des besoins de lecture/écriture asymétriques. Pour des CRUD simples, c'est une sur-ingénierie.

Question 21 : Comment gérer les WebSockets avec Action Cable ?

Action Cable intègre WebSockets dans Rails pour la communication temps réel bidirectionnelle. Il utilise Redis pour la synchronisation entre serveurs.

ruby
# app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_verified_user
    end

    private

    def find_verified_user
      # Via cookie de session
      if verified_user = User.find_by(id: cookies.encrypted[:user_id])
        verified_user
      # Via JWT pour les APIs
      elsif verified_user = verify_jwt_token
        verified_user
      else
        reject_unauthorized_connection
      end
    end

    def verify_jwt_token
      token = request.params[:token]
      return nil unless token

      decoded = JwtService.decode(token)
      User.find(decoded[:user_id])
    rescue
      nil
    end
  end
end
ruby
# app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
  def subscribed
    @room = ChatRoom.find(params[:room_id])

    # Vérifier les permissions
    unless @room.accessible_by?(current_user)
      reject
      return
    end

    stream_for @room

    # Notifier les autres de la présence
    broadcast_presence(:joined)
  end

  def unsubscribed
    broadcast_presence(:left) if @room
  end

  def send_message(data)
    message = @room.messages.create!(
      user: current_user,
      content: data['content']
    )

    # Broadcast à tous les abonnés
    ChatChannel.broadcast_to(@room, {
      type: 'message',
      message: MessageSerializer.new(message).as_json
    })
  end

  def typing
    ChatChannel.broadcast_to(@room, {
      type: 'typing',
      user: current_user.name
    })
  end

  private

  def broadcast_presence(action)
    ChatChannel.broadcast_to(@room, {
      type: 'presence',
      action: action,
      user: current_user.name,
      online_count: @room.online_users_count
    })
  end
end
app/javascript/channels/chat_channel.jsjavascript
import consumer from "./consumer"

const chatChannel = consumer.subscriptions.create(
  { channel: "ChatChannel", room_id: roomId },
  {
    connected() {
      console.log("Connected to chat")
    },

    disconnected() {
      console.log("Disconnected from chat")
    },

    received(data) {
      switch(data.type) {
        case 'message':
          this.appendMessage(data.message)
          break
        case 'typing':
          this.showTypingIndicator(data.user)
          break
        case 'presence':
          this.updatePresence(data)
          break
      }
    },

    sendMessage(content) {
      this.perform('send_message', { content: content })
    },

    notifyTyping() {
      this.perform('typing')
    }
  }
)

Action Cable gère automatiquement les reconnexions et la synchronisation. En production, configurer Redis comme adapter et dimensionner selon le nombre de connexions concurrentes.

Question 22 : Comment implémenter le multi-tenancy dans Rails ?

Le multi-tenancy permet à une application de servir plusieurs clients (tenants) isolés. Trois approches principales : database-level, schema-level, ou row-level.

ruby
# Row-level multitenancy avec ActsAsTenant ou manual
# app/models/concerns/tenant_scoped.rb
module TenantScoped
  extend ActiveSupport::Concern

  included do
    belongs_to :tenant

    # Scope par défaut au tenant courant
    default_scope -> { where(tenant: Current.tenant) if Current.tenant }

    # Validation du tenant
    before_validation :set_tenant, on: :create
  end

  private

  def set_tenant
    self.tenant ||= Current.tenant
  end
end

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :tenant, :user
end
ruby
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :set_current_tenant

  private

  def set_current_tenant
    Current.tenant = resolve_tenant
    Current.user = current_user
  end

  def resolve_tenant
    # Via subdomain
    if request.subdomain.present? && request.subdomain != 'www'
      Tenant.find_by!(subdomain: request.subdomain)
    # Via header (pour les APIs)
    elsif request.headers['X-Tenant-ID'].present?
      Tenant.find(request.headers['X-Tenant-ID'])
    # Via user
    elsif current_user
      current_user.tenant
    end
  rescue ActiveRecord::RecordNotFound
    redirect_to root_url(subdomain: 'www'), alert: 'Tenant not found'
  end
end
ruby
# app/models/project.rb
class Project < ApplicationRecord
  include TenantScoped

  has_many :tasks
  belongs_to :owner, class_name: 'User'
end

# app/models/user.rb
class User < ApplicationRecord
  include TenantScoped

  has_many :projects, foreign_key: :owner_id

  # Les admins peuvent appartenir à plusieurs tenants
  has_many :tenant_memberships
  has_many :accessible_tenants, through: :tenant_memberships, source: :tenant
end
ruby
# Schema-level avec Apartment gem (PostgreSQL)
# config/initializers/apartment.rb
Apartment.configure do |config|
  config.excluded_models = %w[Tenant User]
  config.tenant_names = -> { Tenant.pluck(:subdomain) }
end

# Utilisation
Apartment::Tenant.switch('acme') do
  # Toutes les requêtes dans ce bloc utilisent le schema 'acme'
  Project.all # SELECT * FROM acme.projects
end

Le row-level est le plus simple mais requiert une attention constante aux leaks. Le schema-level offre une meilleure isolation mais complexifie les migrations. Choisir selon les besoins de sécurité et de scalabilité.

Question 23 : Comment mettre en place une architecture Microservices avec Rails ?

Rails peut servir de base pour une architecture microservices avec une communication via HTTP/gRPC ou message queues. La clé est de bien définir les boundaries.

ruby
# Service client HTTP
# app/services/payment_service_client.rb
class PaymentServiceClient
  include HTTParty

  base_uri ENV.fetch('PAYMENT_SERVICE_URL')

  def initialize
    @options = {
      headers: {
        'Content-Type' => 'application/json',
        'X-Service-Token' => ENV.fetch('SERVICE_TOKEN')
      },
      timeout: 10
    }
  end

  def create_charge(amount:, currency:, source:, metadata: {})
    response = self.class.post('/charges', @options.merge(
      body: { amount: amount, currency: currency, source: source, metadata: metadata }.to_json
    ))

    handle_response(response)
  end

  def get_charge(charge_id)
    response = self.class.get("/charges/#{charge_id}", @options)
    handle_response(response)
  end

  private

  def handle_response(response)
    case response.code
    when 200..299
      ServiceResult.success(response.parsed_response)
    when 400..499
      ServiceResult.failure(response.parsed_response['error'], code: response.code)
    else
      ServiceResult.failure('Service unavailable', code: response.code)
    end
  rescue Net::OpenTimeout, Net::ReadTimeout
    ServiceResult.failure('Service timeout')
  end
end
ruby
# Event-driven communication avec Sidekiq/Redis
# app/events/order_events.rb
module OrderEvents
  class Created
    include Wisper::Publisher

    def call(order)
      broadcast(:order_created, order)
    end
  end
end

# app/listeners/inventory_listener.rb
class InventoryListener
  def order_created(order)
    order.items.each do |item|
      InventoryServiceClient.new.reserve_stock(
        product_id: item.product_id,
        quantity: item.quantity,
        reference: order.id
      )
    end
  end
end

# config/initializers/wisper.rb
Wisper.subscribe(InventoryListener.new, async: true)
Wisper.subscribe(NotificationListener.new, async: true)
ruby
# API Gateway pattern
# app/controllers/api/v1/gateway_controller.rb
module Api
  module V1
    class GatewayController < BaseController
      # Agrégation de plusieurs services
      def dashboard
        results = Parallel.map([:orders, :inventory, :analytics], in_threads: 3) do |service|
          fetch_from_service(service)
        end

        render json: {
          orders: results[0],
          inventory: results[1],
          analytics: results[2]
        }
      end

      private

      def fetch_from_service(service)
        case service
        when :orders
          OrderServiceClient.new.recent_orders(limit: 5)
        when :inventory
          InventoryServiceClient.new.low_stock_alerts
        when :analytics
          AnalyticsServiceClient.new.daily_summary
        end
      rescue => e
        { error: "#{service} unavailable", message: e.message }
      end
    end
  end
end

Pour les microservices Rails : définir des contrats d'API clairs (OpenAPI), implémenter des circuit breakers (gem circuitbox), et utiliser le distributed tracing (gem opentelemetry).

Question 24 : Comment déployer une application Rails en production ?

Le déploiement Rails moderne utilise des containers ou des PaaS. Une configuration production robuste couvre les assets, la base de données, et le monitoring.

ruby
# config/environments/production.rb
Rails.application.configure do
  config.cache_classes = true
  config.eager_load = true
  config.consider_all_requests_local = false

  # Assets
  config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
  config.assets.compile = false
  config.assets.digest = true

  # Logging
  config.log_level = ENV.fetch('LOG_LEVEL', 'info').to_sym
  config.log_tags = [:request_id]
  config.logger = ActiveSupport::Logger.new(STDOUT)
    .tap  { |logger| logger.formatter = Logger::Formatter.new }
    .then { |logger| ActiveSupport::TaggedLogging.new(logger) }

  # Cache
  config.cache_store = :redis_cache_store, {
    url: ENV['REDIS_URL'],
    expires_in: 1.day
  }

  # Force SSL
  config.force_ssl = true
  config.ssl_options = { hsts: { subdomains: true } }

  # Action Mailer
  config.action_mailer.delivery_method = :smtp
  config.action_mailer.smtp_settings = {
    address: ENV['SMTP_HOST'],
    port: ENV['SMTP_PORT'],
    user_name: ENV['SMTP_USER'],
    password: ENV['SMTP_PASSWORD'],
    authentication: :plain,
    enable_starttls_auto: true
  }
end
dockerfile
# Dockerfile
FROM ruby:3.3-alpine AS builder

RUN apk add --no-cache build-base postgresql-dev nodejs yarn

WORKDIR /app

COPY Gemfile Gemfile.lock ./
RUN bundle config set --local deployment true && \
    bundle config set --local without 'development test' && \
    bundle install

COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

COPY . .
RUN bundle exec rails assets:precompile

# Production image
FROM ruby:3.3-alpine

RUN apk add --no-cache postgresql-client tzdata

WORKDIR /app

COPY --from=builder /app /app
COPY --from=builder /usr/local/bundle /usr/local/bundle

ENV RAILS_ENV=production
ENV RAILS_LOG_TO_STDOUT=true

EXPOSE 3000

CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
yaml
# docker-compose.production.yml
version: '3.8'

services:
  web:
    build: .
    environment:
      - DATABASE_URL=postgres://user:pass@db/app_production
      - REDIS_URL=redis://redis:6379/0
      - SECRET_KEY_BASE=${SECRET_KEY_BASE}
    depends_on:
      - db
      - redis
    deploy:
      replicas: 3
      resources:
        limits:
          memory: 512M

  sidekiq:
    build: .
    command: bundle exec sidekiq
    environment:
      - DATABASE_URL=postgres://user:pass@db/app_production
      - REDIS_URL=redis://redis:6379/0
    depends_on:
      - db
      - redis
    deploy:
      replicas: 2

  db:
    image: postgres:15-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      - POSTGRES_PASSWORD=${DB_PASSWORD}

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data

volumes:
  postgres_data:
  redis_data:

Checklist production : SSL obligatoire, secrets via ENV, health checks, backups DB automatisés, monitoring (APM + logs + metrics), et alerting configuré.

Question 25 : Quelles sont les nouveautés de Rails 7+ à connaître ?

Rails 7+ apporte des changements significatifs : Hotwire par défaut, import maps, encrypted credentials améliorés, et de nombreuses optimisations.

ruby
# Hotwire - Turbo Frames
# app/views/articles/index.html.erb
<%= turbo_frame_tag "articles" do %>
  <% @articles.each do |article| %>
    <%= turbo_frame_tag dom_id(article) do %>
      <%= render article %>
    <% end %>
  <% end %>

  <%= link_to "Load more", articles_path(page: @page + 1),
              data: { turbo_frame: "articles" } %>
<% end %>

# Turbo Streams pour les updates temps réel
# app/controllers/comments_controller.rb
def create
  @comment = @article.comments.create!(comment_params.merge(user: current_user))

  respond_to do |format|
    format.turbo_stream
    format.html { redirect_to @article }
  end
end

# app/views/comments/create.turbo_stream.erb
<%= turbo_stream.append "comments", @comment %>
<%= turbo_stream.update "comments_count", @article.comments.count %>
<%= turbo_stream.replace "comment_form", partial: "comments/form", locals: { comment: Comment.new } %>
ruby
# Stimulus controllers
# app/javascript/controllers/search_controller.js
import { Controller } from "@hotwired/stimulus"
import { debounce } from "lodash-es"

export default class extends Controller {
  static targets = ["input", "results"]
  static values = { url: String }

  connect() {
    this.search = debounce(this.search.bind(this), 300)
  }

  async search() {
    const query = this.inputTarget.value
    if (query.length < 2) return

    const response = await fetch(`${this.urlValue}?q=${encodeURIComponent(query)}`)
    this.resultsTarget.innerHTML = await response.text()
  }
}
ruby
# Import Maps (sans bundler JavaScript)
# config/importmap.rb
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"

pin_all_from "app/javascript/controllers", under: "controllers"

# Pins depuis CDN
pin "lodash-es", to: "https://ga.jspm.io/npm:lodash-es@4.17.21/lodash.js"
ruby
# Active Record Encryption (Rails 7+)
# app/models/user.rb
class User < ApplicationRecord
  encrypts :email, deterministic: true  # Permet les recherches
  encrypts :phone_number                 # Non-déterministe par défaut
  encrypts :ssn, deterministic: true, downcase: true
end

# config/credentials.yml.enc
active_record_encryption:
  primary_key: abc123...
  deterministic_key: def456...
  key_derivation_salt: ghi789...
ruby
# Query interface improvements
# Rails 7.1+

# Async queries
users = User.where(active: true).load_async
# Continue processing while query runs
# Access results with users.to_a

# Common Table Expressions (CTE)
User.with(
  recent_orders: Order.where('created_at > ?', 30.days.ago)
).joins('JOIN recent_orders ON recent_orders.user_id = users.id')

# Automatic inverse_of detection
class Author < ApplicationRecord
  has_many :books # inverse_of détecté automatiquement
end

# Strict loading par défaut (évite N+1)
class ApplicationRecord < ActiveRecord::Base
  self.strict_loading_by_default = true
end

Rails 7+ privilégie la simplicité (pas de Webpack par défaut) et le HTML-over-the-wire avec Hotwire. Cette approche réduit la complexité JavaScript tout en offrant une expérience utilisateur moderne.

Conclusion

Les entretiens Ruby on Rails évaluent la maîtrise du framework complet et la compréhension de ses conventions. Les points clés à retenir :

Fondamentaux : MVC, Active Record, migrations, validations et associations

Architecture : Service Objects, Concerns, Query Objects, et patterns CQRS

Performance : N+1 queries, caching (fragment, Russian Doll, low-level), eager loading

Testing : RSpec, FactoryBot, request specs, et bonnes pratiques de test

Sécurité : CSRF, SQL injection, XSS, Strong Parameters, et authentification/autorisation

APIs : RESTful design, JWT, serializers, et versioning

Production : Background jobs, WebSockets, déploiement, et monitoring

La philosophie Rails - Convention over Configuration, DRY, et Rails Way - guide l'ensemble des décisions architecturales. Maîtriser ces principes et savoir quand s'en écarter démontre une expertise solide.

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tags

#ruby on rails
#ruby
#interview
#active record
#entretien technique

Partager

Articles similaires