Skip to main content
  1. Posts/

The Rails Way

·918 words·5 mins
Photograph By Aleksandr Popov
Blog Ruby Ruby on Rails
Table of Contents
Rails for the TypeScript Engineer - This article is part of a series.
Part 3: This Article

Two Philosophies
#

Node.js says: “Here’s a runtime. Pick your own framework, router, ORM, template engine, and testing library. Wire them together however you want.” The result is flexible and sometimes chaotic.

Rails says: “Here’s the framework. It includes the router, ORM, template engine, testing library, and mailer. Follow our conventions and you’ll move fast.” The result is opinionated and sometimes constraining.

Neither is wrong. But switching between them requires adjusting how you think about building web applications.

Service Objects: The Missing Layer
#

In Express, you might put business logic in a service class:

class UrlShortenerService {
  static async create(targetUrl: string): Promise<Url> {
    const title = await fetchPageTitle(targetUrl);
    const shortCode = generateUniqueCode();
    return prisma.url.create({ data: { targetUrl, shortCode, title } });
  }
}

Rails doesn’t have a built-in service layer. The convention is “fat model, skinny controller” — business logic lives in the model. But when a model gets too big or an operation spans multiple models, you create a service object:

# app/services/url_shortener_service.rb
class UrlShortenerService
  def self.call(target_url:, title: nil)
    title ||= UrlMetadataService.call(target_url)
    Url.create!(
      target_url: target_url,
      short_code: generate_unique_code,
      title: title
    )
  end

  private_class_method def self.generate_unique_code
    loop do
      code = SecureRandom.alphanumeric(6)
      break code unless Url.exists?(short_code: code)
    end
  end
end

The self.call convention is a Ruby pattern — service objects are called with .call(), making them behave like functions with a namespace. The controller stays thin:

def create
  @url = UrlShortenerService.call(target_url: url_params[:target_url])
  redirect_to @url
rescue ActiveRecord::RecordInvalid => e
  @url = e.record
  render :new, status: :unprocessable_entity
end

This looks familiar if you come from Spring’s @Service pattern. The difference: Rails service objects are plain Ruby classes with no framework annotation. No @Injectable(), no @Service, no dependency injection container. Just a class with a method.

Hotwire: SPAs Without JavaScript
#

This was the biggest paradigm shift. In the TypeScript world, building a dynamic web app means React (or Vue, or Svelte) with a JSON API. The server sends data, the client renders it.

Rails has Hotwire — a completely different approach. The server sends HTML, and Turbo handles page updates without a full reload:

# The controller returns HTML, not JSON
def create
  @url = UrlShortenerService.call(target_url: url_params[:target_url])
  redirect_to @url  # full page navigation, but Turbo makes it feel instant
end

No React. No client-side state management. No useEffect. No fetch('/api/urls').then(r => r.json()). The server renders HTML, Turbo intercepts link clicks and form submissions, and only the changed parts of the page update.

AspectReact SPARails + Hotwire
Data formatJSONHTML
RenderingClient-sideServer-side
State managementuseState/Redux/ZustandServer (session + DB)
JavaScript bundle200KB-2MB~15KB (Turbo + Stimulus)
SEONeeds SSR/SSGBuilt-in (it’s HTML)
ComplexityHigh (two codebases)Low (one codebase)

Hotwire doesn’t replace React for everything. Complex interactive UIs (think Figma, Google Docs) still need a JavaScript framework. But for content-heavy apps, CRUD interfaces, and admin panels, Hotwire gives you SPA-like UX with a fraction of the JavaScript.

ERB vs JSX
#

Rails views use ERB (Embedded Ruby) — HTML with Ruby snippets:

<!-- app/views/urls/index.html.erb -->
<h1>All URLs</h1>

<% @urls.each do |url| %>
  <div class="url-card">
    <h2><%= url.title %></h2>
    <p>Short: <%= url.short_code %></p>
    <p>Target: <%= url.target_url %></p>
    <p>Clicks: <%= url.clicks_count %></p>
  </div>
<% end %>

If you squint, it’s JSX without the component model:

// React equivalent
export function UrlList({ urls }: { urls: Url[] }) {
  return (
    <h1>All URLs</h1>
    {urls.map(url => (
      <div className="url-card" key={url.id}>
        <h2>{url.title}</h2>
        <p>Short: {url.shortCode}</p>
        <p>Target: {url.targetUrl}</p>
        <p>Clicks: {url.clicksCount}</p>
      </div>
    ))}
  );
}

The key difference: ERB templates are rendered on the server and sent as HTML. JSX components are rendered on the client (or server-rendered then hydrated). ERB partials are like React components for reuse:

<!-- app/views/urls/_url.html.erb (partial) -->
<div class="url-card">
  <h2><%= url.title %></h2>
  <p><%= url.short_code %></p>
</div>

<!-- Used in another view -->
<%= render partial: "url", collection: @urls %>

Rack Middleware: Express Middleware’s Cousin
#

Express middleware and Rack middleware follow the same pattern — a chain of functions that process requests in sequence:

// Express middleware
app.use(cors());
app.use(helmet());
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));
app.use(authMiddleware);
app.use("/api", apiRouter);
# Rails middleware stack (simplified)
use Rack::Sendfile
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use Rack::Attack        # rate limiting (like express-rate-limit)
use Rack::Protection    # security headers (like helmet)
run MyApp::Application

Same concept, same execution model. The difference: Rails includes most of what you’d install as npm packages (sessions, cookies, CSRF protection, static files) out of the box. You add middleware for extras like rate limiting (Rack::Attack) and security scanning (Brakeman).

Strong Parameters: The Whitelist
#

Express doesn’t validate or filter request bodies by default — you add middleware (Zod, Joi, express-validator). Rails has “strong parameters” built in:

# Only allow :target_url through — everything else is rejected
def url_params
  params.require(:url).permit(:target_url)
end

If someone sends { url: { target_url: "...", admin: true, clicks: 9999 } }, only target_url gets through. The admin and clicks fields are silently dropped. This prevents mass assignment attacks without any additional library.

The Adjustment Period
#

After all the “choose your own adventure” in the Node.js ecosystem, Rails’ opinions felt limiting at first. Why can’t I use my preferred ORM? Why is the template engine built in? Why doesn’t the router support middleware per-route like Express?

The answer clicked after a while: Rails makes decisions so you don’t have to. Every decision you don’t make is time you spend writing features instead of comparing ORMs. The framework’s opinions are well-tested defaults — you can override them, but you usually don’t need to.

The next post covers testing — RSpec, FactoryBot, and how Rails testing compares to Jest.

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 3: This Article

Related

Convention Over Configuration
·924 words·5 mins
Photograph By Thomas Delacrétaz
Blog Ruby Ruby on Rails
Rails MVC, ActiveRecord, and why the framework makes decisions so you don’t have to
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