Ruby on Rails sollicitatievragen: Top 25 in 2026

De 25 meest gestelde Ruby on Rails sollicitatievragen. MVC-architectuur, Active Record, migraties, RSpec-testing, REST-APIs met gedetailleerde antwoorden en codevoorbeelden.

Ruby on Rails sollicitatievragen - Volledige gids

Ruby on Rails-sollicitatiegesprekken beoordelen de beheersing van het populairste Ruby-framework, het begrip van de MVC-architectuur, de Active Record-ORM en het vermogen om robuuste webapplicaties te bouwen volgens de filosofie "Convention over Configuration". Deze gids behandelt de 25 meest gestelde vragen, van Rails-grondbeginselen tot geavanceerde productiepatronen.

Tip voor het sollicitatiegesprek

Recruiters waarderen kandidaten die de Rails-filosofie begrijpen: "Convention over Configuration", DRY (Don't Repeat Yourself) en Rails Way-patronen. Uitleggen waarom Rails bepaalde architecturale keuzes maakt, maakt het verschil.

Ruby on Rails-grondbeginselen

Vraag 1: Leg het MVC-patroon in Ruby on Rails uit

Het Model-View-Controller-patroon (MVC) vormt de architecturale kern van Rails. Het scheidt verantwoordelijkheden in drie afzonderlijke lagen voor betere onderhoudbaarheid en testbaarheid van de code.

ruby
# app/models/article.rb
# Het Model beheert de gegevens en de bedrijfslogica
class Article < ApplicationRecord
  # Datavalidaties
  validates :title, presence: true, length: { minimum: 5 }
  validates :body, presence: true

  # Associaties met andere modellen
  belongs_to :author, class_name: 'User'
  has_many :comments, dependent: :destroy
  has_many :tags, through: :article_tags

  # Scopes voor herbruikbare queries
  scope :published, -> { where(published: true) }
  scope :recent, -> { order(created_at: :desc).limit(10) }

  # Levenscyclus-callbacks
  before_save :generate_slug

  private

  def generate_slug
    self.slug = title.parameterize if title_changed?
  end
end
ruby
# app/controllers/articles_controller.rb
# De Controller ontvangt verzoeken en orkestreert het antwoord
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: 'Artikel succesvol aangemaakt.'
    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 %>
<%# De View toont de gegevens in HTML-formaat %>
<article class="article-detail">
  <header>
    <h1><%= @article.title %></h1>
    <p class="meta">
      Door <%= @article.author.name %>      <%= l @article.created_at, format: :long %>
    </p>
  </header>

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

  <%# Partial voor de reacties %>
  <%= render @comments %>
</article>

De typische flow: het verzoek komt aan bij de Router, die het naar de juiste Controller stuurt. De Controller communiceert met het Model om gegevens op te halen of te wijzigen, en geeft die gegevens vervolgens door aan de View voor HTML-rendering.

Vraag 2: Wat is Active Record en hoe werkt de Rails-ORM?

Active Record is de ORM (Object-Relational Mapping) van Rails die het Active Record-patroon implementeert. Elke Model-klasse vertegenwoordigt een databasetabel en elke instantie vertegenwoordigt een rij.

ruby
# app/models/user.rb
# Active Record koppelt kolommen automatisch aan attributen
class User < ApplicationRecord
  # De tabel 'users' wordt automatisch gekoppeld
  # Kolommen: id, email, name, created_at, updated_at

  has_secure_password # BCrypt voor het wachtwoord

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

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

  # Callbacks
  before_save :normalize_email

  # Klassemethoden voor queries
  def self.admins
    joins(:roles).where(roles: { name: 'admin' })
  end

  private

  def normalize_email
    self.email = email.downcase.strip
  end
end
ruby
# Voorbeelden van Active Record-queries
# Rails-console of binnen een service

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

# Lezen met voorwaarden
active_users = User.where(active: true).order(:name)
user = User.find_by(email: 'dev@example.com')

# Geketende queries (lazy evaluation)
recent_admins = User.admins
                    .where('created_at > ?', 1.month.ago)
                    .includes(:profile)
                    .limit(10)

# N+1-preventie met eager loading
articles = Article.includes(:author, :comments).published

# Bijwerken
user.update!(name: 'Alice Martin')

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

Active Record vertaalt Ruby-methoden naar geoptimaliseerde SQL-queries. Methoden zoals where, joins en includes zijn lazy: de query wordt pas uitgevoerd bij het itereren of bij het aanroepen van to_a.

Vraag 3: Leg het migratiesysteem van Rails uit

Migraties maken het mogelijk om het databaseschema met Ruby te versioneren. Ze zijn omkeerbaar en stellen een gecontroleerde evolutie van de gegevensstructuur mogelijk.

ruby
# db/migrate/20260203100000_create_products.rb
# Migratie om een tabel aan te maken
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 en updated_at automatisch
    end

    # Indexen voor performance
    add_index :products, :name
    add_index :products, [:category_id, :active]
  end
end
ruby
# db/migrate/20260203110000_add_slug_to_products.rb
# Migratie om een bestaande tabel te wijzigen
class AddSlugToProducts < ActiveRecord::Migration[7.1]
  def change
    add_column :products, :slug, :string
    add_index :products, :slug, unique: true

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

    # Niet-nullable maken na het vullen
    change_column_null :products, :slug, false
  end
end
bash
# Essentiële migratiecommando's
rails db:migrate              # Openstaande migraties uitvoeren
rails db:rollback             # Laatste migratie ongedaan maken
rails db:rollback STEP=3      # Laatste 3 migraties ongedaan maken
rails db:migrate:status       # Status van migraties bekijken
rails db:seed                 # db/seeds.rb uitvoeren
rails db:reset                # Drop, create, migrate, seed

Migraties moeten omkeerbaar zijn. De methode change is intelligent en kan veelvoorkomende operaties automatisch terugdraaien. Voor complexe gevallen up en down apart gebruiken.

Geavanceerd Active Record

Vraag 4: Hoe N+1-queries optimaliseren in Rails?

Het N+1-probleem treedt op wanneer een initiële query gevolgd wordt door N extra queries om associaties te laden. Rails biedt verschillende eager loading-methoden om dit probleem op te lossen.

ruby
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def index
    # ❌ N+1-PROBLEEM: 1 query + N queries per bestelling
    # @orders = Order.all
    # In de view: order.user.name genereert een query per bestelling

    # ✅ OPLOSSING met includes (eager loading)
    @orders = Order.includes(:user, :items)
                   .where(status: 'completed')
                   .order(created_at: :desc)
    # Genereert in totaal slechts 3 queries
  end

  def show
    # includes: laadt associaties apart (2-3 queries)
    @order = Order.includes(items: :product).find(params[:id])

    # preload: forceert apart laden
    @order = Order.preload(:items, :user).find(params[:id])

    # eager_load: forceert LEFT OUTER JOIN (1 query)
    @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 met standaard includes
  scope :with_details, -> { includes(:user, items: :product) }

  # Counter cache om COUNT-queries te vermijden
  # Vereist: add_column :users, :orders_count, :integer, default: 0
  belongs_to :user, counter_cache: true
end
ruby
# N+1-detectie met de 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 toont waarschuwingen wanneer:
# - Een N+1-query wordt gedetecteerd
# - Onnodige eager loading aanwezig is
# - Een counter cache gebruikt zou moeten worden

De regel: standaard includes gebruiken (Rails kiest de optimale strategie), preload wanneer aparte queries gewenst zijn, eager_load wanneer er op associaties gefilterd wordt.

Vraag 5: Leg Scopes en Query Objects in Rails uit

Scopes kapselen herbruikbare query-voorwaarden in. Voor complexe queries bieden Query Objects een betere organisatie en testbaarheid.

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

  # Scopes met parameters
  scope :cheaper_than, ->(price) { where('price < ?', price) }
  scope :in_category, ->(category) { where(category: category) }

  # Ketenbare scopes
  scope :available, -> { active.in_stock }

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

  # Scope met subquery
  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 voor complexe zoekopdrachten
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

# Gebruik in de controller
@products = ProductsSearchQuery.new(Product.active).call(params)

Scopes zijn perfect voor eenvoudige, herbruikbare voorwaarden. Query Objects passen bij complexe zoekopdrachten met meerdere optionele filters en compositielogica.

Klaar om je Ruby on Rails gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Routing en Controllers

Vraag 6: Hoe werkt RESTful routing in Rails?

Rails moedigt RESTful routes aan die HTTP-werkwoorden koppelen aan CRUD-acties. De router vertaalt URL's naar specifieke controller-aanroepen.

ruby
# config/routes.rb
Rails.application.routes.draw do
  # Standaard RESTful routes (7 acties)
  resources :articles do
    # Geneste routes
    resources :comments, only: [:create, :destroy]

    # Member routes (op een instantie)
    member do
      post :publish
      delete :archive
    end

    # Collection routes (op de collectie)
    collection do
      get :drafts
      get :search
    end
  end

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

  # Aangepaste route
  get 'dashboard', to: 'dashboard#index'

  # Routebeperkingen
  constraints(SubdomainConstraint.new) do
    resources :admin_settings
  end

  # Hoofdroute
  root 'home#index'
end
bash
# rails routes - Toont alle gegenereerde routes
#
# 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

Gegenereerde route-helpers (article_path(@article), new_article_path) maken het mogelijk om URL's dynamisch en onderhoudbaar te refereren.

Vraag 7: Leg callbacks en filters in controllers uit

Callbacks (before_action, after_action, around_action) maken het mogelijk om code voor, na of rond controlleracties uit te voeren.

ruby
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # CSRF-bescherming standaard ingeschakeld
  protect_from_forgery with: :exception

  # Globale callback voor authenticatie
  before_action :authenticate_user!

  # Globale foutafhandeling
  rescue_from ActiveRecord::RecordNotFound, with: :not_found
  rescue_from ActionController::ParameterMissing, with: :bad_request

  private

  def not_found
    render json: { error: 'Resource niet gevonden' }, 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 met opties
  before_action :require_admin
  before_action :set_product, only: [:show, :edit, :update, :destroy]
  after_action :log_activity, only: [:create, :update, :destroy]

  # Voorwaardelijke callback
  before_action :check_stock, only: [:update], if: :stock_changed?

  def create
    @product = Product.new(product_params)

    if @product.save
      redirect_to [:admin, @product], notice: 'Product aangemaakt.'
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    if @product.update(product_params)
      redirect_to [:admin, @product], notice: 'Product bijgewerkt.'
    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

Callbacks worden uitgevoerd in volgorde van declaratie. Gebruik skip_before_action in subklassen om geërfde callbacks uit te schakelen. Vermijd callbacks met te veel bedrijfslogica: geef de voorkeur aan Service Objects.

Services en architectuur

Vraag 8: Hoe Service Objects in Rails implementeren?

Service Objects kapselen complexe bedrijfslogica in die niet thuishoort in Models of Controllers. Ze verbeteren de testbaarheid en volgen het Single Responsibility-principe.

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

  def call
    return failure('Bestelling al verwerkt') 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("Betaling mislukt: #{e.message}")
  rescue InsufficientStockError => e
    failure("Voorraad onvoldoende: #{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: "Bestelling ##{@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: 'Bestelling bevestigd!'
      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

Het Service Object-patroon volgt een eenvoudige conventie: één klasse, één verantwoordelijkheid, één publieke call-methode. Een Result-object teruggeven maakt een nette afhandeling van succes en mislukking mogelijk.

Vraag 9: Leg Concerns in Rails uit

Concerns maken het mogelijk om code tussen Models of Controllers te extraheren en te delen. Ze gebruiken ActiveSupport::Concern voor een schone include-syntax.

ruby
# app/models/concerns/sluggable.rb
# Herbruikbare Concern voor het genereren van slugs
module Sluggable
  extend ActiveSupport::Concern

  included do
    # Code die bij include wordt uitgevoerd
    before_validation :generate_slug, if: :should_generate_slug?
    validates :slug, presence: true, uniqueness: true
  end

  # Klassemethoden
  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

  # Instantiemethoden
  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 # Optioneel, :title als standaard
end

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

  sluggable_source :name
end
ruby
# app/controllers/concerns/pagination.rb
# Concern voor 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

Concerns zijn nuttig voor echt gedeelde code. Vermijd het maken van Concerns alleen om een Model "in te korten": dat verbergt complexiteit zonder die te verminderen.

Testen met RSpec

Vraag 10: Hoe RSpec-tests structureren in Rails?

RSpec is het standaard testframework voor Rails. Een goede teststructuur omvat Model specs, Controller specs, Service specs en integratietesten.

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

RSpec.describe User, type: :model do
  # Factories met 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 'valideert het e-mailformaat' 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 'geeft voor- en achternaam gecombineerd terug' do
      user = build(:user, first_name: 'John', last_name: 'Doe')
      expect(user.full_name).to eq('John Doe')
    end

    it 'verwerkt ontbrekende achternaam' do
      user = build(:user, first_name: 'John', last_name: nil)
      expect(user.full_name).to eq('John')
    end
  end

  describe '.active' do
    it 'geeft alleen actieve gebruikers terug' 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 'wanneer de bestelling geldig is' do
      before do
        allow(PaymentGateway).to receive(:charge).and_return(
          OpenStruct.new(success?: true, transaction_id: 'txn_123')
        )
      end

      it 'verwerkt de bestelling met succes' do
        result = subject.call

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

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

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

    context 'wanneer de betaling mislukt' do
      before do
        allow(PaymentGateway).to receive(:charge).and_return(
          OpenStruct.new(success?: false, error: 'Card declined')
        )
      end

      it 'geeft een mislukkingsresultaat terug' do
        result = subject.call

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

      it 'werkt de bestellingsstatus niet bij' 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 'geeft de productlijst terug' do
      get '/api/v1/products', headers: headers

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

    it 'filtert op categorie' 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: 'Nieuw Product', price: 99.99, category_id: create(:category).id } }
    end

    it 'maakt een nieuw product aan' 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

Best practices: gebruik let voor data, describe voor methoden/contexten, context voor voorwaarden en it voor specifieke asserties. Eén test moet één ding testen.

Vraag 11: Hoe factories met FactoryBot gebruiken?

FactoryBot maakt het mogelijk om testdata declaratief en onderhoudbaar aan te maken. Factories vervangen statische fixtures.

ruby
# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    # Sequenties voor uniciteit
    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 voor variaties
    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

    # Geërfde factory
    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
# Gebruik in tests
RSpec.describe OrderProcessor do
  # build: niet-gepersisteerde instantie
  let(:user) { build(:user) }

  # create: gepersisteerd in DB
  let(:order) { create(:order, :with_items, user: user) }

  # create_list: meerdere instanties
  let(:products) { create_list(:product, 5) }

  # Traits combineren
  let(:admin) { create(:user, :admin, :with_profile) }

  # Attributen overschrijven
  let(:expensive_order) { create(:order, :with_items, items_count: 10) }

  # build_stubbed: sneller, voor unit tests
  let(:stubbed_user) { build_stubbed(:user) }
end

Geef de voorkeur aan build of build_stubbed boven create wanneer persistentie niet nodig is: dit versnelt de tests aanzienlijk.

Background Jobs

Vraag 12: Hoe Active Job en Sidekiq in Rails gebruiken?

Active Job biedt een uniforme interface voor background jobs, ongeacht het backend (Sidekiq, Resque, enz.). Sidekiq is de populaire keuze vanwege zijn performance met Redis.

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

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

  # Sidekiq-opties (bij Sidekiq-backend)
  sidekiq_options retry: 5, backtrace: true

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

    OrderProcessor.new(order).call
  rescue ActiveRecord::RecordNotFound
    # Bestelling verwijderd tussen enqueue en uitvoering
    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 met Sidekiq Enterprise of throttle-gem
  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
# Jobs in de wachtrij plaatsen
# Onmiddellijk
ProcessOrderJob.perform_later(order.id)

# Vertraagd
ProcessOrderJob.set(wait: 5.minutes).perform_later(order.id)

# Op een specifiek tijdstip
ProcessOrderJob.set(wait_until: Date.tomorrow.noon).perform_later(order.id)

# Specifieke wachtrij
ProcessOrderJob.set(queue: :critical).perform_later(order.id)

# Synchroon (voor tests of debugging)
ProcessOrderJob.perform_now(order.id)
ruby
# config/sidekiq.yml
:concurrency: 10
:queues:
  - [critical, 3]    # Hoge prioriteit, gewicht 3
  - [default, 2]     # Gemiddelde prioriteit, gewicht 2
  - [mailers, 1]     # Lage prioriteit, gewicht 1
  - [low, 1]

:schedule:
  cleanup_job:
    cron: '0 3 * * *'  # Elke dag om 3 uur
    class: CleanupJob

Active Job abstraheert het backend, maar toegang tot specifieke functies (batches, rate limiting) vereist vaak koppeling met het gekozen backend.

Klaar om je Ruby on Rails gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

API-ontwikkeling

Vraag 13: Hoe een RESTful API met Rails bouwen?

Rails maakt het eenvoudig om JSON-API's te bouwen met API-only Controllers en serializers. Een goede API is geversioneerd, gedocumenteerd en veilig.

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 niet gevonden', details: exception.message },
               status: :not_found
      end

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

      def bad_request(exception)
        render json: { error: 'Ongeldig verzoek', 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
# Met de 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

API-best practices: versioneren via namespace, geschikte HTTP-codes gebruiken, collecties pagineren en duidelijke foutmeldingen geven.

Vraag 14: Hoe JWT-authenticatie in Rails implementeren?

JWT (JSON Web Tokens) is een populaire stateless authenticatiemethode voor API's. De token codeert de identiteit en geldigheid van de gebruiker.

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 verlopen'
    rescue JWT::DecodeError
      raise AuthenticationError, 'Ongeldig 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: 'Ongeldige inloggegevens' }, 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, 'Token ontbreekt' 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: 'Gebruiker niet gevonden' }, status: :unauthorized
  end

  def current_user
    @current_user
  end
end

Voor productie overwegen: refresh tokens, blacklisting van tokens bij logout en korte verlooptijden. Gems zoals devise-jwt vereenvoudigen de implementatie.

Caching en performance

Vraag 15: Hoe caching in Rails implementeren?

Rails biedt verschillende cachingniveaus: fragment caching, Russian Doll caching, low-level caching. De keuze hangt af van het gebruiksgeval.

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 met automatische cache-sleutel %>
<% @products.each do |product| %>
  <%# Cache gebaseerd op updated_at van product %>
  <% cache product do %>
    <%= render product %>
  <% end %>
<% end %>

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

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

<%# Voorwaardelijke cache %>
<% cache_if current_user.nil?, @product do %>
  <%= render @product %>
<% end %>
ruby
# app/models/product.rb
class Product < ApplicationRecord
  # Touch op de parent om Russian Doll-cache te invalideren
  belongs_to :category, touch: true

  # Aangepaste cache-sleutel
  def cache_key_with_version
    "#{super}/#{reviews.maximum(:updated_at)&.to_i}"
  end
end
ruby
# Low-level caching in 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 met bescherming tegen race conditions
Rails.cache.fetch('popular_products', expires_in: 1.hour, race_condition_ttl: 10.seconds) do
  Product.bestsellers.limit(10).to_a
end

# Expliciete invalidatie
Rails.cache.delete('dashboard:stats')
Rails.cache.delete_matched('products:*')

Russian Doll caching is effectief omdat alleen gewijzigde fragmenten opnieuw worden gegenereerd. Gebruik touch: true op associaties om invalidatie te verspreiden.

Vraag 16: Hoe de performance van een Rails-applicatie optimaliseren?

Rails-optimalisatie omvat meerdere aspecten: DB-queries, caching, assets en architectuur. Een methodische aanpak met monitoring is essentieel.

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

# app/models/order.rb
class Order < ApplicationRecord
  # Samengestelde indexen voor frequente queries
  # add_index :orders, [:user_id, :status, :created_at]

  # Alleen de benodigde kolommen selecteren
  scope :summary, -> { select(:id, :status, :total, :created_at) }

  # Batchverwerking voor grote volumes
  def self.process_pending
    pending.find_each(batch_size: 1000) do |order|
      ProcessOrderJob.perform_later(order.id)
    end
  end

  # Herhalende berekeningen vermijden
  def self.revenue_by_month
    completed
      .group("DATE_TRUNC('month', created_at)")
      .sum(:total)
  end
end
ruby
# Geheugenoptimalisatie
# 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 met 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 en paginering
class ProductsController < ApplicationController
  def index
    @products = Product.active
                       .includes(:category, :primary_image)
                       .page(params[:page])
                       .per(24)

    # Prefetch voor de volgende pagina
    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

Essentiële tools: rack-mini-profiler voor profiling, bullet voor N+1-detectie, New Relic of Scout voor productiemonitoring.

Beveiliging

Vraag 17: Wat zijn de beveiligingsbest practices in Rails?

Rails bevat standaardbeveiligingen tegen veelvoorkomende kwetsbaarheden. Het is cruciaal deze beveiligingen te begrijpen en correct te configureren.

ruby
# CSRF-bescherming
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # Standaard ingeschakeld, gooit een exception als het token ongeldig is
  protect_from_forgery with: :exception

  # Voor API's :null_session gebruiken
  # protect_from_forgery with: :null_session
end

# In views wordt het token automatisch in formulieren opgenomen
# <%= form_with ... %> bevat authenticity_token

# Voor AJAX-verzoeken
# Voeg de header X-CSRF-Token toe met de waarde van csrf_meta_tags
ruby
# SQL Injection-preventie
# ✅ Geïnterpoleerde parameters worden automatisch ge-escaped
User.where('email = ?', params[:email])
User.where(email: params[:email])

# ❌ GEVAAR - Directe interpolatie
User.where("email = '#{params[:email]}'")

# ✅ Voor dynamische ORDER-clausules
ALLOWED_SORTS = %w[name created_at price].freeze
sort_column = ALLOWED_SORTS.include?(params[:sort]) ? params[:sort] : 'name'
Product.order(sort_column)
ruby
# XSS-bescherming
# Rails escapet HTML in views automatisch

# ✅ Automatisch ge-escaped
<%= user.name %>

# ❌ Gevaarlijk - niet ge-escapete inhoud
<%== user.bio %>
<%= raw user.bio %>
<%= user.bio.html_safe %>

# ✅ Voor veilige HTML, sanitize gebruiken
<%= 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
    # Expliciete whitelist van toegestane attributen
    params.require(:user).permit(:name, :email, :avatar)

    # Alleen voor administrators
    if current_user.admin?
      params.require(:user).permit(:name, :email, :role, :active)
    else
      params.require(:user).permit(:name, :email)
    end
  end
end
ruby
# Beveiligingsheaders
# 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

Regelmatig auditen met brakeman (statische beveiligingsanalyse) en gems up-to-date houden met bundle audit.

Vraag 18: Hoe authenticatie en autorisatie in Rails afhandelen?

Authenticatie verifieert de identiteit, autorisatie regelt de rechten. Devise beheert auth, Pundit of CanCanCan beheren autorisatie.

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 voor collecties
  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 met 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: 'Artikel bijgewerkt.'
    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: 'Artikel gepubliceerd.'
  end

  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

  private

  def user_not_authorized
    flash[:alert] = "U bent niet bevoegd om deze actie uit te voeren."
    redirect_back(fallback_location: root_path)
  end
end

Pundit is explicieter en beter testbaar dan CanCanCan. Elke actie heeft een corresponderende policy-methode en scopes filteren collecties automatisch.

Geavanceerd Rails

Vraag 19: Leg het Repository-patroon in Rails uit

Het Repository-patroon isoleert de data access-logica van de rest van de applicatie. Hoewel Rails Active Record gebruikt (een ander patroon), kan Repository nuttig zijn voor complexe gevallen.

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
# Gebruik in een 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

# Maakt testen met mocks gemakkelijk
RSpec.describe ProductSearchService do
  let(:repository) { instance_double(ProductRepository) }
  let(:service) { described_class.new(repository: repository) }

  it 'filtert op categorie' 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

Repository is optioneel in Rails omdat Active Record al een uitstekend patroon is. Gebruik het voor complexe queries of wanneer storage-isolatie belangrijk is.

Vraag 20: Hoe het CQRS-patroon in Rails implementeren?

CQRS (Command Query Responsibility Segregation) scheidt lees- en schrijfoperaties. In Rails vertaalt dit zich naar afzonderlijke klassen voor queries en 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]} niet beschikbaar")
        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 die CQRS gebruikt
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: 'Bestelling aangemaakt!'
    else
      flash.now[:alert] = result.errors.join(', ')
      render :new, status: :unprocessable_entity
    end
  end
end

CQRS schittert in complexe applicaties met asymmetrische lees-/schrijfbehoeften. Voor eenvoudig CRUD is het over-engineering.

Vraag 21: Hoe WebSockets met Action Cable afhandelen?

Action Cable integreert WebSockets in Rails voor bidirectionele realtime communicatie. Het gebruikt Redis voor synchronisatie tussen servers.

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 sessie-cookie
      if verified_user = User.find_by(id: cookies.encrypted[:user_id])
        verified_user
      # Via JWT voor API's
      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])

    # Rechten controleren
    unless @room.accessible_by?(current_user)
      reject
      return
    end

    stream_for @room

    # Anderen op de hoogte stellen van aanwezigheid
    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']
    )

    # Naar alle abonnees broadcasten
    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 handelt heraansluitingen en synchronisatie automatisch af. In productie Redis als adapter configureren en schalen op basis van gelijktijdige verbindingen.

Vraag 22: Hoe multi-tenancy in Rails implementeren?

Multi-tenancy stelt een applicatie in staat om meerdere geïsoleerde klanten (tenants) te bedienen. Drie hoofdaanpakken: op database-, schema- of rij-niveau.

ruby
# Multitenancy op rij-niveau met ActsAsTenant of handmatig
# app/models/concerns/tenant_scoped.rb
module TenantScoped
  extend ActiveSupport::Concern

  included do
    belongs_to :tenant

    # Standaardscope op de huidige tenant
    default_scope -> { where(tenant: Current.tenant) if Current.tenant }

    # Tenant-validatie
    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 subdomein
    if request.subdomain.present? && request.subdomain != 'www'
      Tenant.find_by!(subdomain: request.subdomain)
    # Via header (voor API's)
    elsif request.headers['X-Tenant-ID'].present?
      Tenant.find(request.headers['X-Tenant-ID'])
    # Via gebruiker
    elsif current_user
      current_user.tenant
    end
  rescue ActiveRecord::RecordNotFound
    redirect_to root_url(subdomain: 'www'), alert: 'Tenant niet gevonden'
  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

  # Administrators kunnen tot meerdere tenants behoren
  has_many :tenant_memberships
  has_many :accessible_tenants, through: :tenant_memberships, source: :tenant
end
ruby
# Op schema-niveau met de Apartment-gem (PostgreSQL)
# config/initializers/apartment.rb
Apartment.configure do |config|
  config.excluded_models = %w[Tenant User]
  config.tenant_names = -> { Tenant.pluck(:subdomain) }
end

# Gebruik
Apartment::Tenant.switch('acme') do
  # Alle queries in dit blok gebruiken het 'acme'-schema
  Project.all # SELECT * FROM acme.projects
end

Rij-niveau is het eenvoudigst maar vereist constante aandacht voor lekken. Schema-niveau biedt betere isolatie maar bemoeilijkt migraties. Kies op basis van beveiligings- en schaalbaarheidseisen.

Vraag 23: Hoe een microservices-architectuur met Rails opzetten?

Rails kan dienen als basis voor een microservices-architectuur met communicatie via HTTP/gRPC of message queues. De sleutel is het goed definiëren van de grenzen.

ruby
# HTTP-service-client
# 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 niet beschikbaar', code: response.code)
    end
  rescue Net::OpenTimeout, Net::ReadTimeout
    ServiceResult.failure('Service-timeout')
  end
end
ruby
# Event-driven communicatie met 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-patroon
# app/controllers/api/v1/gateway_controller.rb
module Api
  module V1
    class GatewayController < BaseController
      # Meerdere services aggregeren
      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} niet beschikbaar", message: e.message }
      end
    end
  end
end

Voor Rails-microservices: heldere API-contracten definiëren (OpenAPI), circuit breakers implementeren (gem circuitbox) en distributed tracing gebruiken (gem opentelemetry).

Vraag 24: Hoe een Rails-applicatie naar productie deployen?

Moderne Rails-deployment maakt gebruik van containers of PaaS. Een robuuste productieconfiguratie omvat assets, database en 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
  }

  # SSL forceren
  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

# Productie-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:

Productie-checklist: SSL verplicht, secrets via ENV, health checks, geautomatiseerde DB-backups, monitoring (APM + logs + metrics) en geconfigureerde alerting.

Vraag 25: Wat zijn de nieuwe features in Rails 7+ die je moet kennen?

Rails 7+ brengt belangrijke wijzigingen: Hotwire standaard, import maps, verbeterde versleutelde credentials en talloze optimalisaties.

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 "Meer laden", articles_path(page: @page + 1),
              data: { turbo_frame: "articles" } %>
<% end %>

# Turbo Streams voor realtime updates
# 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 (zonder JavaScript-bundler)
# 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 vanaf 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  # Zoekopdrachten mogelijk
  encrypts :phone_number                 # Niet-deterministisch standaard
  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
# Verbeteringen in de query-interface
# Rails 7.1+

# Asynchrone queries
users = User.where(active: true).load_async
# Verder verwerken terwijl de query loopt
# Resultaten openen met 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')

# Automatische inverse_of-detectie
class Author < ApplicationRecord
  has_many :books # inverse_of automatisch gedetecteerd
end

# Strict loading standaard (vermijdt N+1)
class ApplicationRecord < ActiveRecord::Base
  self.strict_loading_by_default = true
end

Rails 7+ geeft de voorkeur aan eenvoud (geen Webpack standaard) en HTML-over-the-wire met Hotwire. Deze aanpak vermindert de JavaScript-complexiteit en biedt tegelijkertijd een moderne gebruikerservaring.

Conclusie

Ruby on Rails-sollicitatiegesprekken beoordelen de beheersing van het hele framework en het begrip van de conventies. Belangrijke punten om te onthouden:

Grondbeginselen: MVC, Active Record, migraties, validaties en associaties

Architectuur: Service Objects, Concerns, Query Objects en CQRS-patronen

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

Testen: RSpec, FactoryBot, request specs en testing best practices

Beveiliging: CSRF, SQL injection, XSS, Strong Parameters en authenticatie/autorisatie

API's: RESTful design, JWT, serializers en versionering

Productie: background jobs, WebSockets, deployment en monitoring

De Rails-filosofie (Convention over Configuration, DRY en Rails Way) stuurt alle architecturale beslissingen. Deze principes beheersen en weten wanneer ervan af te wijken, toont solide expertise.

Begin met oefenen!

Test je kennis met onze gespreksimulatoren en technische tests.

Tags

#ruby on rails
#ruby
#sollicitatie
#active record
#technische sollicitatie

Delen

Gerelateerde artikelen