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_param override 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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

API Versioning Is Not Optional Once You Have Real Users

Once an API has real consumers, every change becomes a contract risk. Versioning is the only reliable way to evolve safely without breaking production systems.

Read more

Why Miami Startups Cannot Rely on Local Hiring Alone for Backend Engineering

Miami has built a real startup scene. It hasn't yet built the backend engineering depth to staff it locally.

Read more

Why Productivity Surveillance Harms Remote Developers

Watching every keystroke doesn’t make work faster. It often makes developers anxious, distracted, and less productive.

Read more

Why Good Engineers Think Before They Code

Writing code fast isn’t the same as writing it well. The best engineers pause, plan, and think before their fingers hit the keyboard.

Read more