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.
| Schicht | Primäre Rolle | Rails-Komponenten | Beispiel |
|---|---|---|---|
| Präsentation | Darstellung | Views, Helfer, React/Vue | Formulare, Preisformatierung |
| Anwendung | Koordinierung | Controller, Service Objects | Auftragsverarbeitung |
| Domäne | Geschäftslogik | Models, Ruby-Objekte | MwSt.-Berechnung |
| Infrastruktur | Externe Systeme | ActiveRecord, Sidekiq, APIs | DB-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:
- 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.
- Convention over Configuration: Wir definieren klare Namenskonventionen. Services heissen
VerbNounService(z.B.ProcessPaymentService), Queries heissenNounQuery(z.B.ActiveProductsQuery). Das macht den Code selbstdokumentierend. - Architektur-Tests: Wir nutzen Tools wie
packwerkoder 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. - 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.