I've been writing TypeScript for about 5 years. React, Next.js, Node, NestJS, the whole ecosystem. Recently accepted a role at an LMS company where Ruby on Rails is the primary stack.
My background is full-stack, but Rails wasn't part of my day-to-day. I wanted to get ahead of the learning curve before starting, and what better way to understand an LMS than to build one yourself?
So I did. Eight days later, I deployed Decypher, a gamified personal development platform with AI integration, habit tracking, and a progression system.
Here's what I learned.
Day 1-2: Convention Over Configuration
Coming from TypeScript where you configure everything explicitly, Rails works differently.
The mental shift: Your database columns automatically become model attributes. No decorators, no explicit mappings. If your missions table has a title column, @mission.title just works.
class Mission < ApplicationRecord
# That's it. title, description, status, all accessible
# No attr_accessor, no type definitions
endCompare that to TypeORM or Prisma where you're writing schemas, decorators, and type definitions. Rails trades explicit control for speed.
The routing was intuitive. One line generates seven RESTful routes:
resources :missions
# GET /missions, POST /missions, GET /missions/:id, etc.I kept a cheat sheet mapping the flow: URL > Route > Controller > Model > View. Once that clicked, everything else fell into place.
Day 3: ERB Is Just JSX Without the Build Step
If you know React, ERB templates feel familiar. The biggest adjustment? Partials (Rails components) are named with underscores (_mission_card.html.erb), but you reference them without the underscore (render "mission_card"). Caught me a few times.
Form helpers were a pleasant surprise. Rails generates CSRF tokens automatically, no manual protection needed:
<%= form_with model: @mission do |f| %>
<%= f.text_field :title %>
<%= f.submit "Save" %>
<% end %>Day 4: The Model Layer
Coming from the "services everywhere" pattern in Node/NestJS, Rails' "fat model, thin controller" approach is a different philosophy.
Associations are declarative and powerful:
class Mission < ApplicationRecord
belongs_to :user
belongs_to :domain
has_many :objectives, dependent: :destroy
endThat dependent: :destroy means deleting a mission automatically cleans up its objectives. No orphaned records, no manual cascade logic.
The bang (!) convention was new to me: update returns false on failure, update! raises an exception. This lets you write cleaner conditional logic:
if @mission.commence!
redirect_to @mission
else
render :show, alert: "Failed to start"
endModules vs Classes: The Mental Model That Clicked
This one tripped me up. In TypeScript, I'm used to thinking of shared code as packages in a monorepo, @shared/utils that gets imported wherever needed.
Rails has modules for that. But here's the key difference: classes can be instantiated, hold state, and create objects. Modules cannot be instantiated, hold no state, and exist to share behavior as mixins.
Modules are like your packages/shared/ folder. You include them in classes that need that behavior:
module MissionsHelper
def status_color(status)
case status
when "active" then "bg-blue-400"
when "completed" then "bg-emerald-400"
end
end
endThen any controller or view can use status_color(). No imports, no setup, Rails auto-loads helpers into views.
Once I mapped "module = shared package" in my head, the architecture made way more sense.
Day 5: Hotwire Changed How I Think About Interactivity
I went in expecting to bolt on a React frontend. Instead, I discovered Hotwire (Turbo + Stimulus), which is Rails' approach to interactivity.
The mental model is different. In React, you're managing state client-side and the UI reacts to changes. With Turbo Streams, the server sends back HTML fragments and you target specific DOM elements by ID to update them.
# Controller
respond_to do |format|
format.turbo_stream
end<%= turbo_stream.replace "mission_header" do %>
<%= render "mission_header", mission: @mission %>
<% end %>Click an objective complete? The server responds with the updated HTML and Turbo swaps out just that element. No fetch calls, no client-side state management.
Stimulus controllers handle the JavaScript I did need: sidebar toggles, confirmation modals, loading states. About 150 lines of JS total for the entire app.
It's a different pattern. You're way more dependent on the server to give you hydrated data instead of doing that work client-side. Not better or worse, just different. It's going to take some getting used to, but I can see how it simplifies certain types of apps.
Day 6: Service Objects for AI Integration
When I integrated the Anthropic API for "The Operator" (the app's AI assistant), I extracted the logic into service objects, a pattern that felt familiar from NestJS:
# app/services/operator/domain_setup.rb
module Operator
class DomainSetup < Base
def generate_plan(user, domain, goals_input)
response = call_api(system_prompt, user_prompt)
parse_json_response(response)
end
end
endThe service handles API calls, JSON parsing, and error handling. The controller stays thin, just coordination:
def generate
result = Operator::DomainSetup.new.generate_plan(@user, @domain, params[:goals])
# Handle result
endDay 7: Deployment
Rails 8 ships with Solid Queue (background jobs) and Solid Cable (WebSockets) built in. No Redis required for basic deployments.
Docker multi-stage builds kept the image lean. GitHub Actions handled CI (RuboCop, Brakeman security scans, bundle-audit).
Total time from "works locally" to "live at decypher.life": about 4 hours.
Day 8: Polish and Surprises
Stuff I didn't expect:
- Chart.js just works via importmap. No npm, no bundler. One line in importmap.rb and it's available everywhere.
- Tailwind v4 integration is seamless. bin/dev runs the CSS watcher alongside the Rails server.
- The generators save hours. rails generate model Mission title:string status:string creates the model, migration, and test files.
- Database commands are intuitive. rails db:reset drops, creates, migrates, and seeds. One command to rebuild everything.
What I'd do differently:
- Use scaffolds earlier for standard CRUD, then customize
- Set up RuboCop from day one. I had 300+ linting offenses to fix at the end. Not fun.
- Lean into Turbo Streams more. Wrote some JavaScript I didn't need.
The Final Stats
- Days to MVP: 8
- Models: 16
- Controllers: 12
- View templates: 51
- Lines of custom JS: ~150
- Linting errors fixed last minute: 300+
- External JS frameworks: 0
Would I Use Rails Again?
I'm about to use it every day, so yeah.
After 8 days, I can see why people have strong opinions about Rails in both directions. The conventions speed things up when you follow them. The "magic" can be frustrating when you don't understand what's happening.
Ruby itself feels different coming from TypeScript. No explicit types, symbols everywhere, blocks instead of arrow functions. It's an adjustment.
The patterns do transfer though. Fat models, thin controllers, service objects for complex logic. These ideas aren't Rails-specific. Working through this made me think differently about how I structure Node apps too.
Building an LMS to prep for a job at an LMS company was probably overkill. But I learned more in 8 days of building than I would've in weeks of tutorials.
Decypher is live at decypher.life if you want to check it out.