Skip to main content
  1. Posts/

Convention Over Configuration

·924 words·5 mins
Photograph By Thomas Delacrétaz
Blog Ruby Ruby on Rails
Table of Contents
Rails for the TypeScript Engineer - This article is part of a series.
Part 2: This Article

The Missing Config Files
#

In a TypeScript project, setting up a new API endpoint means: create a route file, import the handler, configure the router, create the controller, import the model, configure the ORM connection, and wire everything together. In Rails, you run one command:

rails generate scaffold Url target_url:string short_code:string title:string

And you get: a model, a migration, a controller with all CRUD actions, views, routes, tests, and helpers. All wired together. No config.

This is “convention over configuration” — the philosophy Rails is built on. Instead of telling the framework how to connect things, you follow naming conventions and the framework connects them for you.

The Convention That Replaces Configuration
#

In Express or Spring, you explicitly wire every connection:

// Express: you tell the framework everything
import { Router } from "express";
import { UrlController } from "../controllers/url.controller";
const router = Router();
router.get("/urls", UrlController.index);
router.get("/urls/:id", UrlController.show);
router.post("/urls", UrlController.create);
export default router;

In Rails, the convention IS the configuration:

# config/routes.rb
resources :urls
# This single line creates ALL seven RESTful routes:
# GET    /urls          -> UrlsController#index
# GET    /urls/:id      -> UrlsController#show
# GET    /urls/new      -> UrlsController#new
# POST   /urls          -> UrlsController#create
# GET    /urls/:id/edit -> UrlsController#edit
# PATCH  /urls/:id      -> UrlsController#update
# DELETE /urls/:id      -> UrlsController#destroy

The controller is named UrlsController (plural of the model). The model is Url. The table is urls. The views are in app/views/urls/. You don’t configure these relationships — you follow the naming convention.

MVC: Same Pattern, Less Ceremony
#

If you’ve used Express with a service layer or Spring MVC, Rails MVC is the same pattern with less boilerplate:

# app/controllers/urls_controller.rb
class UrlsController < ApplicationController
  def index
    @urls = Url.all
    # Rails automatically renders app/views/urls/index.html.erb
  end

  def show
    @url = Url.find(params[:id])
    # Rails automatically renders app/views/urls/show.html.erb
  end

  def create
    @url = Url.new(url_params)
    if @url.save
      redirect_to @url, notice: "Created!"
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def url_params
    params.require(:url).permit(:target_url)  # strong params — whitelist allowed fields
  end
end

No imports. No res.json(). No @GetMapping. The controller name matches the route. Instance variables (@urls) are automatically available in the view. The view file is found by convention (controller name + action name).

ActiveRecord: The ORM That Feels Like Magic
#

ActiveRecord is Rails’ ORM. If you’ve used Prisma, TypeORM, or JPA/Hibernate, the concept is familiar — but the syntax is more concise.

# Ruby (ActiveRecord)
Url.all                                    # SELECT * FROM urls
Url.find(1)                                # SELECT * FROM urls WHERE id = 1
Url.where(status: "active")                # SELECT * FROM urls WHERE status = 'active'
Url.where("clicks > ?", 100)               # parameterized query
Url.order(created_at: :desc).limit(10)     # ORDER BY created_at DESC LIMIT 10
Url.find_by(short_code: "abc123")          # first match or nil
// TypeScript (Prisma)
await prisma.url.findMany();
await prisma.url.findUnique({ where: { id: 1 } });
await prisma.url.findMany({ where: { status: "active" } });
await prisma.url.findMany({ where: { clicks: { gt: 100 } } });
await prisma.url.findMany({ orderBy: { createdAt: "desc" }, take: 10 });
await prisma.url.findFirst({ where: { shortCode: "abc123" } });

The key differences:

AspectActiveRecordPrisma/TypeORM
Query styleMethod chaining on model classMethod on client with object params
Associationshas_many :ordersSchema-level relations
ValidationsIn the model (validates :email, presence: true)Zod/class-validator (separate)
Migrationsrails generate migration AddStatusToUrls status:stringprisma migrate dev / TypeORM migrations
Null handlingReturns nil or raisesReturns null or throws

ActiveRecord models are also where validations live:

class Url < ApplicationRecord
  validates :target_url, presence: true, format: { with: URI::DEFAULT_PARSER.make_regexp }
  validates :short_code, presence: true, uniqueness: true, length: { is: 6 }

  before_create :generate_short_code

  private

  def generate_short_code
    self.short_code = SecureRandom.alphanumeric(6)
  end
end

In TypeScript, validation logic usually lives in a separate layer (Zod schemas, middleware). In Rails, the model handles it. This is the “fat model, skinny controller” convention — business logic lives in models, controllers just coordinate.

Migrations: Schema as Code
#

rails generate migration CreateUrls target_url:string short_code:string clicks:integer

This generates:

class CreateUrls < ActiveRecord::Migration[7.1]
  def change
    create_table :urls do |t|
      t.string :target_url, null: false
      t.string :short_code, null: false
      t.integer :clicks, default: 0
      t.timestamps  # created_at and updated_at automatically
    end

    add_index :urls, :short_code, unique: true
  end
end

Run with rails db:migrate. Rollback with rails db:rollback. The migration history lives in db/schema.rb — a snapshot of the current database state.

Same concept as Prisma migrations or Flyway/Liquibase in Java, but generated from the command line based on a naming convention: Add{Column}To{Table} or Create{Table} tells the generator what to scaffold.

The Scaffold: Your Starting Point
#

rails generate scaffold Url target_url:string short_code:string title:string clicks_count:integer

This creates everything: model, migration, controller (with all 7 CRUD actions), views, routes, tests, and helpers. It’s a complete working feature in one command.

The convention is to scaffold first, then customize. Delete the actions you don’t need. Add validations to the model. Replace generated views with your design. It’s faster to trim than to build from scratch.

# Want to undo? The destroyer removes exactly what the generator created
rails destroy scaffold Url

The Honest Reaction
#

Coming from TypeScript, my first reaction to Rails conventions was suspicion. Where’s the explicit wiring? How do I know what’s connected to what? What if the convention doesn’t match my use case?

After a short while, the suspicion faded. The conventions are predictable — once you know the patterns, you can read any Rails codebase and immediately know where things are. UrlsController means there’s a Url model and a urls table. app/views/urls/show.html.erb renders when you hit GET /urls/:id. No config to trace.

The next post covers where Rails diverges more sharply from the Node.js ecosystem: Hotwire instead of React, service objects, and Rack middleware.

Aaron Yong
Author
Aaron Yong
Building things for the web. Writing about development, Linux, cloud, and everything in between.
Rails for the TypeScript Engineer - This article is part of a series.
Part 2: This Article

Related

Everything Is an Object
·1061 words·5 mins
Photograph By Thought Catalog
Blog Ruby Ruby on Rails
Ruby syntax through TypeScript eyes — implicit returns, truthiness gotchas, and why 0 is truthy
The Data Structure That's Okay With Being Wrong
·1340 words·7 mins
Photograph By Elimende Inagella
Blog Software Engineering Data Structures
Bloom filters — probabilistic, memory-efficient, and surprisingly useful
The Toolbox Nobody Opens
·1271 words·6 mins
Photograph By Alexander Schimmeck
Blog Software Engineering Computer Science
Data structures you use every day without thinking about them, and the ones you should