Ruby on Rails ist für viele Entwickler*innen die erste Wahl, wenn es darum geht, schnell funktionale Webanwendungen zu bauen. Auch bei mindmatters setzen wir das Framework in der individuellen Softwareentwicklung gerne ein. Rails bringt ein mächtiges MVC-Pattern, eine große Community und eine enorme Entwicklungsgeschwindigkeit mit sich.
Doch Rails hat auch seine Tücken – vor allem, wenn Projekte wachsen. Die standardmäßige Ordnerstruktur von Rails skaliert schlecht, weil sie eine Trennung nach Art statt Funktionalität erzwingt: Alle Controller liegen zusammen, alle Models liegen zusammen, alle Services liegen zusammen. Das widerspricht einem wichtigen Prinzip der Softwareentwicklung:
„Things that change together should be together.“
Bei großen Anwendungen fällt schnell auf, dass die Standardstruktur von Rails für kleine Projekte optimiert ist – aber nicht für langfristig wachsende Systeme. Ein typisches Beispiel:
app/controllers/cart_controller.rb
app/controllers/products_controller.rb
app/controllers/profile_controller.rb
app/models/cart.rb
app/models/product.rb
app/models/profile.rb
app/services/cart_service.rb
app/services/products_service.rb
app/services/profile_service.rb
spec/controllers/cart_controller_spec.rb
spec/controllers/products_controller_spec.rb
spec/controllers/profile_controller_spec.rb
spec/models/cart_spec.rb
spec/models/product_spec.rb
spec/models/profile_spec.rb
spec/services/cart_service_spec.rb
spec/services/products_service_spec.rb
spec/services/profile_service_spec.rb
Die Dateiablage orientiert sich an der Art der Datei (Controller, Model, Service) – aber nicht an der Funktionalität oder am Bounded Context. Das führt dazu, dass zusammengehörige Dateien in völlig unterschiedlichen Verzeichnissen liegen. Wer an einem Feature arbeitet, muss ständig zwischen weit entfernten Ordnern navigieren.
Eine naheliegende Lösung wäre der Einsatz von Rails Engines, um eigenständige Module zu kapseln. Doch Engines sind schwergewichtig, erfordern es, eigene Abhängigkeiten zu verwalten, und eignen sich eher für vollständig gekapselte Subsysteme als für eine saubere Strukturierung innerhalb einer Anwendung.
Um unsere Rails-Anwendungen skalierbarer zu gestalten, haben wir bei mindmatters ein modules/-Verzeichnis eingeführt. Hier ordnen wir die Codebasis nicht nach Typen, sondern nach Features oder Bounded Contexts.
Statt Dateien nach ihrer Funktion zu gruppieren, legen wir alle zusammengehörigen Dateien an einem Ort ab:
modules/shopping/cart/cart.rb
modules/shopping/cart/cart_spec.rb
modules/shopping/cart/cart_controller.rb
modules/shopping/cart/cart_controller_spec.rb
modules/shopping/cart/carts_service.rb
modules/shopping/cart/carts_service_spec.rb
modules/shopping/product/product.rb
modules/shopping/product/product_spec.rb
modules/shopping/product/products_controller.rb
modules/shopping/product/products_controller_spec.rb
modules/shopping/product/products_service.rb
modules/shopping/product/products_service_spec.rb
modules/users/profile.rb
modules/users/profile_spec.rb
modules/users/profile_controller.rb
modules/users/profile_controller_spec.rb
modules/users/profile_service.rb
modules/users/profile_service_spec.rb
Was bringt das?
Damit Rails unser modules/-Verzeichnis erkennt, müssen wir es in der config/application.rb registrieren:
modules = root.join('modules')
config.autoload_paths << modules
config.eager_load_paths << modules
Tests sollten vom Autoloader ignoriert werden:
Rails.autoloaders.main.ignore(Rails.root.join('modules/**/*_spec.rb'))
Und schon funktioniert das Laden der Module nahtlos. Tests laufen weiterhin mit:
bundle exec rspec .
Viele Bibliotheken, die mit Rails arbeiten, sind auf die Standardstruktur optimiert – aber lassen sich leicht anpassen. Als Beispiel nehmen wir hier FactoryBot. Anstatt Dateien in einem factories/-Verzeichnis zu speichern, speichern wir diese in _factories.rb*-Dateien und binden sie über eine Initializer-Konfiguration ein:
if Rails.env.local?
Rails.application.config.factory_bot.definition_file_paths += ['modules/**/*_factories.rb']
Rails.root.glob('modules/**/*_factories.rb').each { |file| require file }
end
Auch hier muss der Autoloader angepasst werden, um die Factory-Dateien auszunehmen:
Rails.autoloaders.main.ignore(Rails.root.join('modules/**/*_factories.rb'))
Andere Third-Party-Tools sind in unserer Erfahrung ähnlich leicht anzupassen.
Ein weiterer Vorteil unserer Modularisierung ist die Zwangstrennung durch Namensräume. Rails neigt dazu, einen globalen Namespace zu verwenden. Ein häufiges Beispiel:
Standard-Rails:
class User < ApplicationRecord
end
Problem: Jede Rails-App hat eine User-Klasse. Aber bedeutet User jetzt den eingeloggten Benutzer? Oder vielleicht auch eine Person, die mit Daten arbeitet, die auch ein eingeloggter Benutzer sein kann - aber nicht muss? Häufig wird dann unbewusst die User-Klasse auf alle möglichen Personen ausgedehnt, die eigentlich gar nichts mit den Benutzern des Systems selbst zu tun haben.
Die Modularisierung macht die Trennung explizit:
module Authentication
class User < ApplicationRecord
end
end
module Billing
class User < ApplicationRecord
end
end
Diese Namensräume sorgen dafür, dass es explizit wird, welcher Typ User gemeint ist – und verhindern, dass Modelle unbewusst über verschiedene Bounded Contexts hinweg verwendet werden.
Die Rails-Standardstruktur ist für kleine Projekte ideal – aber für größere Anwendungen problematisch. Unsere feature-orientierte Strukturierung mit einem modules/-Verzeichnis sorgt für:
✅ Bessere Wartbarkeit: Entwickler*innen arbeiten in klar abgegrenzten Modulen statt in vielen verteilten Verzeichnissen.
✅ Saubere Namensräume: Der globale Namespace wird reduziert, was Missverständnisse und ungewollte Abhängigkeiten verhindert.
Natürlich ist dieser Ansatz ein Umdenken für viele Rails-Entwickler*innen – aber langfristig zahlt er sich aus. Gerade für größere Anwendungen in der individuellen Softwareentwicklung hat sich dieses Muster bei uns bewährt.