Ab einer gewissen Grösse wird jede Rails-App unübersichtlich. Models mit 500 Zeilen, Controller, die Geschäftslogik, Validierung und Formatierung gleichzeitig erledigen, Tests, die die halbe Datenbank aufsetzen müssen. Schichtenarchitektur löst dieses Problem, indem sie klare Verantwortlichkeiten definiert.

Was sind die vier Schichten?

  • Präsentationsschicht: Views, Templates, Frontend-Frameworks. Alles, was der Nutzer sieht.
  • Anwendungsschicht: Controller und Service Objects. Koordiniert Workflows, enthält keine Geschäftslogik.
  • Domänenschicht: Geschäftslogik und Regeln. Das Herz der Anwendung.
  • Infrastrukturschicht: Datenbank, externe APIs, Hintergrundjobs. Technisches Fundament.
app/
├── controllers/          # Anwendungsschicht
├── models/               # Domänenschicht
├── services/             # Anwendung/Domäne
├── queries/              # Infrastrukturschicht
├── views/                # Präsentationsschicht

Geschichtetes Rails-Design mit Vladimir Dementyev

Was macht jede Schicht konkret?

Präsentationsschicht

Traditionell: .html.erb-Templates, Partials, Layouts. Modern: React oder Vue.js als Frontend, Rails liefert JSON über APIs.

Die Präsentationsschicht formatiert Daten zur Anzeige. Geschäftslogik hat hier nichts verloren. Ein Presenter übernimmt die Formatierung:

class OrderPresenter
  def initialize(order)
    @order = order
  end

  def formatted_total
    "CHF #{sprintf('%.2f', @order.total_chf)}"
  end

  def formatted_date
    @order.created_at.strftime("%d.%m.%Y")
  end
end

Anwendungsschicht

Controller bleiben schlank. Sie nehmen Requests entgegen und delegieren an Service Objects:

class OrdersController < ApplicationController
  def create
    result = OrderProcessingService.new(order_params, current_user).call

    if result.success?
      redirect_to order_path(result.order)
    else
      render :new, alert: result.error_message
    end
  end
end

Die Anwendungsschicht verwaltet auch Authentifizierung, Autorisierung und Anfrageformatierung.

Domänenschicht

Hier lebt die Geschäftslogik: Validierungen, Berechnungen, Geschäftsregeln. Diese Regeln sind unabhängig von der Datenbank und der Benutzeroberfläche.

Beispiel: MwSt.-Berechnung als eigenes Objekt:

class VatCalculator
  RATE = 0.081 # Schweizer Normalsatz

  def initialize(amount)
    @amount = amount
  end

  def gross_amount
    @amount * (1 + RATE)
  end

  def vat_amount
    @amount * RATE
  end
end

Die Domänenschicht ändert sich am häufigsten (neue Geschäftsregeln, geänderte Anforderungen). Klare Trennung stellt sicher, dass Updates hier den Rest der App nicht stören.

Infrastrukturschicht

ActiveRecord, Sidekiq, externe API-Clients, Datei-Storage. Diese Schicht liefert Daten und Dienste an die Domänenschicht, ohne technische Details offenzulegen.

SchichtPrimäre RolleRails-KomponentenBeispiel
PräsentationDarstellungViews, Helfer, React/VueFormulare, Preisformatierung
AnwendungKoordinierungController, Service ObjectsAuftragsverarbeitung
DomäneGeschäftslogikModels, Ruby-ObjekteMwSt.-Berechnung
InfrastrukturExterne SystemeActiveRecord, Sidekiq, APIsDB-Abfragen, Hintergrundjobs

Abhängigkeitsregel: Jede Schicht darf nur auf die darunter liegenden zugreifen. Ein Controller ruft ein Service Object auf, das ein Model nutzt, das auf ActiveRecord zugreift. Nie umgekehrt.

Warum lohnt sich der Aufwand?

Wartbarkeit

Änderungen bleiben isoliert. Datenbankschema anpassen? Nur die Infrastrukturschicht ist betroffen. MwSt.-Satz ändern? Nur die Domänenschicht. Keine Seiteneffekte in anderen Teilen der App.

Testbarkeit

Unit-Tests werden fokussierter, weil Geschäftslogik in isolierten Objekten lebt:

RSpec.describe VatCalculator do
  it 'calculates gross amount' do
    calc = described_class.new(100)
    expect(calc.gross_amount).to eq(108.10)
  end
end

Kein Datenbank-Setup, keine HTTP-Requests, keine View-Rendering. Tests laufen in Millisekunden.

Teamarbeit

Frontend-Entwickler arbeiten an der Präsentationsschicht, Backend-Entwickler an Service Objects und APIs. Solange die Schnittstellen konsistent bleiben, gibt es keine Merge-Konflikte.

Neue Teammitglieder starten mit einer Schicht und erweitern ihr Verständnis schrittweise, statt die gesamte App erfassen zu müssen.

Wie implementieren Sie die Architektur?

Verzeichnisstruktur aufbauen

app/
├── controllers/          # Anwendungsschicht
├── models/               # Domänenschicht
├── views/                # Präsentationsschicht
├── services/             # Anwendung/Domäne
├── queries/              # Infrastrukturschicht
├── forms/                # Anwendungsschicht
├── presenters/           # Präsentationsschicht
├── adapters/             # Infrastrukturschicht
└── jobs/                 # Infrastrukturschicht

Service Objects als Vermittler

Statt dass Controller mehrere Models direkt ansprechen, delegieren sie an einen Service:

class SwissOrderProcessingService
  def initialize(order_params, user)
    @order_params = order_params
    @user = user
  end

  def call
    return failure("Ungültige Adresse") unless valid_swiss_address?

    order = create_order
    payment_result = process_payment(order)
    return failure(payment_result.error) unless payment_result.success?

    schedule_shipping(order)
    send_confirmation_email(order)
    success(order)
  end

  private

  def valid_swiss_address?
    SwissAddressValidator.new(@order_params[:shipping_address]).valid?
  end

  def process_payment(order)
    SwissPaymentService.new(order).process
  end
end

Query Objects für komplexe Abfragen

class ProductSearchQuery
  def initialize(params = {})
    @params = params
  end

  def call
    products = Product.includes(:category, :brand)
    products = filter_by_price(products)
    products = filter_by_availability(products)
    products = filter_by_language(products)
    products
  end

  private

  def filter_by_price(products)
    return products unless @params[:min_price] || @params[:max_price]

    products = products.where("price_chf >= ?", @params[:min_price]) if @params[:min_price]
    products = products.where("price_chf <= ?", @params[:max_price]) if @params[:max_price]
    products
  end
end

Form Objects für Multi-Model-Formulare

class CustomerRegistrationForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :email, :string
  attribute :password, :string
  attribute :company_name, :string
  attribute :vat_number, :string
  attribute :postal_code, :string

  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :postal_code, format: { with: /\A\d{4}\z/, message: "muss eine gültige PLZ sein" }

  def save
    return false unless valid?

    ActiveRecord::Base.transaction do
      user = User.create!(email: email, password: password, company_name: company_name)
      create_billing_address(user)
      send_welcome_email(user)
    end
    true
  rescue StandardError
    false
  end
end

Dependency Injection für Testbarkeit

class OrderProcessingService
  def initialize(order_params, user, payment_gateway: PaymentGateway.new)
    @order_params = order_params
    @user = user
    @payment_gateway = payment_gateway
  end
end

In Tests übergeben Sie ein Mock-Gateway. In Produktion wird der echte Service genutzt.

Praktische Umsetzung: Der USEO-Ansatz

Schichtenarchitektur klingt in der Theorie einfach, scheitert aber oft an der Umsetzung in bestehenden Projekten. So gehen wir bei USEO vor:

  1. Inkrementelle Migration: Wir reissen nie alles auf einmal um. Stattdessen identifizieren wir die “schmerzhaftesten” Controller und Models und extrahieren dort zuerst Service Objects und Query Objects. Ein typischer Startpunkt: der fetteste Controller der App.
  2. Convention over Configuration: Wir definieren klare Namenskonventionen. Services heissen VerbNounService (z.B. ProcessPaymentService), Queries heissen NounQuery (z.B. ActiveProductsQuery). Das macht den Code selbstdokumentierend.
  3. Architektur-Tests: Wir nutzen Tools wie packwerk oder eigene RSpec-Matcher, um Schichtgrenzen zu enforieren. Wenn ein Model direkt auf einen Controller zugreift, schlägt der Test fehl. Das verhindert, dass die Architektur über Zeit erodiert.
  4. Result Objects statt Exceptions: Service Objects geben strukturierte Result Objects zurück (success(data) / failure(error)), statt Exceptions für Kontrollfluss zu nutzen. Das macht den Code vorhersehbar und vereinfacht Error-Handling in Controllern.

Dieser Ansatz hat sich besonders bei der Modernisierung von Legacy-Rails-Apps bewährt, wo die Schichtenarchitektur schrittweise eingeführt werden muss.

FAQs

Was unterscheidet die Anwendungsschicht von der Domänenschicht?

Die Anwendungsschicht koordiniert Workflows: Sie nimmt Requests entgegen, ruft die richtige Geschäftslogik auf und gibt Ergebnisse zurück. Die Domänenschicht enthält die eigentlichen Regeln: Validierungen, Berechnungen, Geschäftsprozesse. Die Anwendungsschicht weiss, was passieren soll. Die Domänenschicht weiss, wie es passiert.

Wann lohnt sich Schichtenarchitektur nicht?

Bei sehr kleinen Apps (unter 10 Models, 1-2 Entwickler) kann der Overhead die Vorteile überwiegen. Rails’ Convention over Configuration funktioniert gut für einfache CRUD-Anwendungen. Sobald die Geschäftslogik komplex wird oder mehrere Entwickler gleichzeitig arbeiten, zahlt sich die Investition aus.

Was sind Service Objects und Query Objects?

Service Objects kapseln Geschäftsprozesse, die über ein einzelnes Model hinausgehen (z.B. Bestellabwicklung). Query Objects kapseln komplexe Datenbankabfragen und halten Models sauber. Beide verbessern Testbarkeit und Wiederverwendbarkeit.

Verwandte Artikel