How I Structure a Rails App Before Writing a Single Line of Business Logic
by Eric Hanson, Backend Developer at Clean Systems Consulting
The cost of deferred structure
Rails generates a working app out of the box. app/models, app/controllers, app/views — it's all there. The problem is that the default structure is designed to be learnable, not to scale. Every model in one directory, every controller in one directory, every mailer alongside every job alongside every serializer. This is fine at ten models. At forty it's a navigation problem. At eighty it's an onboarding problem.
Retrofitting structure into a mature Rails codebase is one of the most expensive things a team can do — it touches every file, breaks every grep, and generates merge conflicts for weeks. Putting structure in before the code exists costs almost nothing.
Directory structure first
The default app/ directories get two additions immediately: app/services and app/queries. These map to the patterns described elsewhere — service objects for multi-step operations, query objects for non-trivial ActiveRecord queries. Both are autoloaded by Rails without any configuration.
Beyond that, I add directories for the domain concepts the application actually has, not the Rails concepts it generates:
app/
controllers/
api/
v1/
models/
services/
queries/
presenters/
serializers/
jobs/
mailers/
policies/ ← authorization logic (Pundit or hand-rolled)
forms/ ← form objects for multi-model forms
value_objects/ ← domain value types
Each directory has a single, clear responsibility. A developer looking for authorization logic goes to policies/. A developer looking for data transformation goes to serializers/. No spelunking through a 40-file models/ directory trying to find the method that formats a price.
For large domains, namespace by domain concept rather than by layer:
app/
billing/
subscription_service.rb
invoice_generator.rb
payment_gateway.rb
onboarding/
registration_service.rb
welcome_mailer.rb
workspace_initializer.rb
This is the modular monolith approach. Each domain directory contains everything related to that concept. Cross-domain dependencies are explicit — you can see them in the require or include calls. Rails autoloads all of it as long as the file names match the class names.
Concerns folder — set the convention immediately
Rails generates app/models/concerns and app/controllers/concerns. The temptation is to use concerns as a dumping ground. Set the convention in the first PR: concerns are for behavior shared across multiple unrelated models, not for decomposing a single model. A Timestampable concern that adds created_at formatting to any model that needs it is appropriate. An UserNotifications concern that exists because User was getting long is not.
Document this in the project README or a docs/conventions.md. Conventions that aren't written down are conventions that disappear when the person who set them leaves.
Configuration layering
Rails environments (development, test, production) handle deployment-specific config. Application-specific configuration — feature flags, service timeouts, rate limits, external API endpoints — belongs in a dedicated config object, not scattered across initializers:
# config/app_config.rb
module AppConfig
PAYMENT_TIMEOUT = ENV.fetch("PAYMENT_TIMEOUT_SECONDS", "10").to_i
MAX_UPLOAD_SIZE_MB = ENV.fetch("MAX_UPLOAD_SIZE_MB", "10").to_i
FEATURE_FLAGS = {
new_checkout: ENV["FEATURE_NEW_CHECKOUT"] == "true",
beta_dashboard: ENV["FEATURE_BETA_DASHBOARD"] == "true",
}.freeze
end
ENV.fetch with a default is deliberate — it raises KeyError if a required variable is missing with no default, making missing configuration visible at boot rather than at runtime when the feature is first exercised. Constants are frozen at load time.
Load it in an initializer:
# config/initializers/app_config.rb
require Rails.root.join("config/app_config")
Reference it anywhere: AppConfig::PAYMENT_TIMEOUT. No global Settings object with method_missing magic, no gem dependency, no YAML file that requires a restart to change.
Database conventions before the first migration
Three decisions that need to be made before writing any migrations, because changing them later touches every table:
UUIDs vs sequential integers as primary keys. Sequential integer IDs are the Rails default and the right choice for most applications — they're smaller, faster to index, and easier to debug. UUIDs are appropriate when records are created across multiple systems that need to merge without collision, or when exposing IDs in URLs is a security concern. Pick one and enforce it in ApplicationRecord:
# config/initializers/uuid_primary_key.rb — only if you've chosen UUIDs
Rails.application.config.generators do |g|
g.orm :active_record, primary_key_type: :uuid
end
Timestamps on every table. Add t.timestamps to every migration. No exceptions. You will eventually need to know when a record was created or last updated. The cost of adding it later is a migration on a live table.
Database-level constraints. Foreign key constraints and not-null constraints belong in migrations, not just in model validations. Model validations are bypassed by update_column, raw SQL, and data migrations. Database constraints are not:
add_column :orders, :user_id, :bigint, null: false
add_foreign_key :orders, :users
Add the constraint in the migration that adds the column, not as an afterthought.
ApplicationRecord extensions — the right things, not more
Every model inherits from ApplicationRecord. This is the right place for behavior that genuinely applies to every model in the application. It's frequently abused to add behavior that only seems universal:
Appropriate in ApplicationRecord:
- Scope conventions that every model follows (
default_scope— rarely — or a consistent soft-delete pattern) - Custom attribute types that all models might use
- A
#to_paramoverride if URLs use slugs rather than IDs
Not appropriate in ApplicationRecord:
- Search behavior (
searchkick,pg_search) that only some models use - Serialization helpers that belong on specific models
- Any callback that isn't genuinely universal
The test: if you removed this from ApplicationRecord and only added it to the models that need it, would anything break? If no, it doesn't belong there.
Routing structure before controllers
Write the full route file before generating any controllers. It forces a decision about the resource model — which things are resources, which are nested, which are standalone actions — before the implementation makes it expensive to change:
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :users, only: [:show, :update] do
resources :orders, only: [:index, :show, :create]
resource :subscription, only: [:show, :create, :destroy]
end
resources :products, only: [:index, :show]
end
end
end
Versioning the API namespace from the start costs nothing and saves a painful migration later. namespace :v1 with a flat v1 controller directory is the approach that adds the least boilerplate while keeping the door open for v2.
The only: constraint is non-optional. Every route you add is a surface that needs authorization, logging, and documentation. Open only the routes you need.
The README that actually gets written
Before any code, write a README section titled "Architecture Decisions" with three things: the directory structure and what each directory is for, the database conventions (UUID or integer PKs, any non-standard patterns), and the service boundary conventions (when something becomes a service object vs. stays in a model).
This doesn't need to be long. Four paragraphs is enough. The value is that it forces explicit decisions before the code makes them implicit, and it gives every future developer a single place to understand why the app is structured the way it is.
Structure is cheap before the code exists. It's expensive after. An hour of deliberate setup — directories, conventions, database constraints, route skeleton — eliminates weeks of structural debt downstream.