You want to add a new resource to your Rails app. You know the drill — rails generate, tweak the migration, add validations, write the controller actions, set up strong parameters, configure routes. It's not hard, but it's fifteen minutes of mechanical work before you get to the interesting part.
Kiro handles that scaffolding for you. But unlike a basic generator, it understands Rails conventions — RESTful routing, ActiveRecord patterns, strong parameters — and produces code that follows them idiomatically. You describe what you want in natural language, and Kiro generates the model, migration, controller, and routes that a senior Rails dev would write by hand.
In this post, we'll walk through generating a complete Article resource with Kiro — a blog post with title, body, author, publication status, and a few metadata fields. Then we'll ask Kiro to layer on validations and scopes. The goal: show how Kiro respects Rails' convention-over-configuration philosophy rather than fighting it.
The Setup
Kiro supports Ruby as one of its officially listed programming languages. It's built on VS Code, so your existing Ruby and Rails extensions, themes, and keybindings carry over. Open your Rails project in Kiro and you're ready to go.
Kiro offers two session types: Vibe sessions for conversational coding, and Spec sessions for structured development (requirements, design, then implementation tasks). For generating a straightforward resource like this, a Vibe session works well. For a more complex feature — say, a full publishing workflow with drafts, scheduling, and notifications — you'd use a Spec session to plan before implementing.
Generating the Resource
In a Vibe session, you describe what you want:
Create an Article resource for a blog application. The article should have: title (string), body (text), author_name (string), published (boolean, default false), slug (string), view_count (integer, default 0), and published_at (datetime, nullable). Generate the model, migration, controller with full RESTful actions, and routes.
Kiro reads your existing project structure — your Gemfile, existing models, route file, controller patterns — and generates code that fits. It doesn't produce code in a vacuum; it matches the conventions already in your codebase.
The Migration
Kiro generates a migration that follows Rails naming conventions — timestamped filename, create_ prefix, plural table name:
# db/migrate/20260706120000_create_articles.rb
class CreateArticles < ActiveRecord::Migration[7.1]
def change
create_table :articles do |t|
t.string :title, null: false
t.text :body, null: false
t.string :author_name, null: false
t.boolean :published, default: false, null: false
t.string :slug, null: false
t.integer :view_count, default: 0, null: false
t.datetime :published_at
t.timestamps
end
add_index :articles, :slug, unique: true
add_index :articles, :published
add_index :articles, :published_at
end
end
A few things to note:
-
Null constraints on required fields. Rails developers know that database-level constraints complement model validations. Kiro adds
null: falsewhere it makes sense —title,body,author_nameshouldn't be blank at the database level. -
Defaults for boolean and integer fields. A
publishedcolumn without a default creates a three-state boolean (true, false, nil), which causes headaches with ActiveRecord'swhere(published: false)queries. Settingdefault: falseavoids that. -
Indexes on query-heavy columns. The
sluggets a unique index (since you'll look up articles by slug).publishedandpublished_atget indexes because you'll filter and sort on them. -
published_atis nullable. A draft has no publication date. Kiro infers this from the presence of thepublishedboolean — if there's a separate "is published?" flag, the timestamp should be optional.
This is the kind of migration a developer with a few years of Rails experience would write. Not the bare minimum, but not over-engineered either.
The Model
# app/models/article.rb
class Article < ApplicationRecord
# Validations
validates :title, presence: true, length: { maximum: 255 }
validates :body, presence: true
validates :author_name, presence: true, length: { maximum: 100 }
validates :slug, presence: true, uniqueness: true, format: { with: /\A[a-z0-9\-]+\z/ }
validates :view_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
# Callbacks
before_validation :generate_slug, on: :create
private
def generate_slug
self.slug = title.parameterize if title.present? && slug.blank?
end
end
This model demonstrates several ActiveRecord conventions:
Validations mirror the database constraints. The presence: true validations on title, body, and author_name match the null: false in the migration. Belt and suspenders — the database constraint catches anything that slips past the model layer.
Length limits are explicit. Without length: { maximum: 255 }, a user could submit a 10,000-character title. The string column type in most databases truncates at 255 characters anyway, so the validation makes the limit visible in Ruby.
The slug format uses a regex. The /\A[a-z0-9\-]+\z/ pattern ensures slugs are URL-safe: lowercase letters, numbers, and hyphens only. \A and \z anchor to the actual string boundaries (not line boundaries like ^ and $).
The callback generates the slug from the title. String#parameterize is a Rails method that converts "My First Blog Post" into "my-first-blog-post". The callback only fires on create, and only if slug is blank — so you can still set a custom slug if you want.
The Controller
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
before_action :set_article, only: %i[show edit update destroy]
def index
@articles = Article.all.order(created_at: :desc)
end
def show
end
def new
@article = Article.new
end
def create
@article = Article.new(article_params)
if @article.save
redirect_to @article, notice: "Article was successfully created."
else
render :new, status: :unprocessable_entity
end
end
def edit
end
def update
if @article.update(article_params)
redirect_to @article, notice: "Article was successfully updated."
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@article.destroy
redirect_to articles_url, notice: "Article was successfully destroyed."
end
private
def set_article
@article = Article.find(params[:id])
end
def article_params
params.require(:article).permit(:title, :body, :author_name, :slug, :published, :published_at)
end
end
Let's break down what Rails conventions are at work here:
RESTful Routing
The controller has exactly seven actions: index, show, new, create, edit, update, destroy. These map to the standard HTTP verbs and URL patterns that resources :articles generates in Rails:
| Action | HTTP Verb | Path | Purpose |
|---|---|---|---|
| index | GET | /articles | List all articles |
| show | GET | /articles/:id | Show one article |
| new | GET | /articles/new | Render create form |
| create | POST | /articles | Save new article |
| edit | GET | /articles/:id/edit | Render edit form |
| update | PATCH/PUT | /articles/:id | Update article |
| destroy | DELETE | /articles/:id | Delete article |
No custom actions, no non-RESTful routes. This is Rails' convention-over-configuration in its purest form.
Strong Parameters
The article_params method uses params.require(:article).permit(...) to whitelist exactly which attributes can be mass-assigned. This is Rails' defense against mass-assignment vulnerabilities. Notice that view_count is deliberately excluded — it shouldn't be user-settable through a form submission. Only title, body, author_name, slug, published, and published_at are permitted.
before_action and DRY
The set_article callback runs before show, edit, update, and destroy — the four actions that operate on an existing record. This avoids repeating @article = Article.find(params[:id]) in each action.
Status Codes on Render
The render :new, status: :unprocessable_entity on validation failure returns HTTP 422. This is important for Turbo (Hotwire) in Rails 7+ — Turbo expects a 422 status to know that it should replace the form with the re-rendered version showing validation errors.
The Routes
# config/routes.rb (addition)
resources :articles
One line. That's it. resources :articles generates all seven RESTful routes mapping to the seven controller actions. This is the convention-over-configuration payoff — because the controller follows the naming convention (ArticlesController with standard action names), and the model follows its convention (Article mapping to the articles table), one line of routing config wires everything together.
Adding Validations
Now let's ask Kiro to add more business logic. In the same Vibe session:
Add a validation that ensures published_at is set when published is true. Also add a validation that the title is unique within the scope of the author_name.
Kiro updates the model:
# app/models/article.rb
class Article < ApplicationRecord
# Validations
validates :title, presence: true, length: { maximum: 255 }
validates :title, uniqueness: { scope: :author_name,
message: "has already been used by this author" }
validates :body, presence: true
validates :author_name, presence: true, length: { maximum: 100 }
validates :slug, presence: true, uniqueness: true, format: { with: /\A[a-z0-9\-]+\z/ }
validates :view_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validate :published_at_required_when_published
# Callbacks
before_validation :generate_slug, on: :create
private
def generate_slug
self.slug = title.parameterize if title.present? && slug.blank?
end
def published_at_required_when_published
if published? && published_at.blank?
errors.add(:published_at, "must be set when article is published")
end
end
end
Two things to highlight:
Scoped uniqueness. uniqueness: { scope: :author_name } means two different authors can both have an article titled "Getting Started," but the same author can't. The scope creates a compound uniqueness check.
Custom validation method. The published_at_required_when_published method uses validate (no s) instead of validates — that's the Rails API for custom validation logic. It calls errors.add to attach an error message to a specific field. The published? method is automatically generated by ActiveRecord for boolean columns.
Adding Scopes
Next request:
Add scopes to the Article model: published (where published is true, ordered by published_at descending), drafts (where published is false), and by_author(author_name) that filters by author.
Kiro adds the scopes:
# app/models/article.rb
class Article < ApplicationRecord
# Scopes
scope :published, -> { where(published: true).order(published_at: :desc) }
scope :drafts, -> { where(published: false) }
scope :by_author, ->(name) { where(author_name: name) }
# Validations
validates :title, presence: true, length: { maximum: 255 }
validates :title, uniqueness: { scope: :author_name,
message: "has already been used by this author" }
validates :body, presence: true
validates :author_name, presence: true, length: { maximum: 100 }
validates :slug, presence: true, uniqueness: true, format: { with: /\A[a-z0-9\-]+\z/ }
validates :view_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validate :published_at_required_when_published
# Callbacks
before_validation :generate_slug, on: :create
private
def generate_slug
self.slug = title.parameterize if title.present? && slug.blank?
end
def published_at_required_when_published
if published? && published_at.blank?
errors.add(:published_at, "must be set when article is published")
end
end
end
Scopes in Rails are class-level methods that return ActiveRecord::Relation objects. Because they return relations, they're chainable:
# Find published articles by a specific author
Article.published.by_author("Jane Smith")
# Find draft articles, most recent first
Article.drafts.order(created_at: :desc)
# Count published articles
Article.published.count
Kiro uses the lambda syntax (-> { ... }) which is standard Rails practice for scopes. The by_author scope takes an argument, so its lambda accepts a parameter. All three scopes produce SQL that hits the indexes we defined in the migration (published and published_at).
What Kiro Understands About Rails Conventions
Through this example, Kiro demonstrated awareness of several Rails conventions:
Naming conventions. Model name Article (singular, CamelCase) maps to table articles (plural, snake_case), controller ArticlesController (plural), and route resources :articles. Kiro doesn't need you to specify these mappings — it infers them.
File placement. Models go in app/models/, controllers in app/controllers/, migrations in db/migrate/. The migration filename includes a timestamp. These are non-negotiable in Rails, and Kiro follows them.
RESTful design. Seven standard actions, no custom routes for basic CRUD. If you need non-RESTful behavior, you'd ask for it — but the default is the conventional seven.
Strong parameters. Whitelisting at the controller level, excluding fields that shouldn't be mass-assigned (view_count, id, timestamps).
Database-level safety. Null constraints, defaults, and indexes in the migration complement the model validations. Kiro treats the database schema as a contract, not just a storage mechanism.
Validation API. Using validates for declarative validations, validate for custom methods, uniqueness: { scope: ... } for compound uniqueness, and format: { with: ... } for regex patterns.
Using Steering for Team Conventions
If your team has specific Rails conventions — say you always use UUIDs as primary keys, or you prefer frozen_string_literal comments at the top of every file, or you use pundit for authorization — you can codify those in a Kiro steering file.
Create a file at .kiro/steering/rails-conventions.md:
# Rails Conventions
- Use UUID primary keys for all new models (`id: :uuid`)
- Add `# frozen_string_literal: true` to the top of every Ruby file
- Controllers that require authentication should include `before_action :authenticate_user!`
- Use `pundit` for authorization: include `authorize @resource` in show, edit, update, destroy
- Prefer `find_by!` over `find` when looking up by slug
- Always add database indexes for foreign keys and columns used in `where` clauses
With this steering file in place, Kiro will follow these conventions in every session without you repeating them. The next time you ask it to generate a resource, it'll use UUIDs, add the frozen string literal pragma, and include Pundit authorization checks.
Using Hooks for Automatic Quality Checks
Kiro's hooks let you automate checks that run when specific events occur. For a Rails project, you might set up a hook that runs RuboCop after any file save in app/:
{
"version": "v1",
"hooks": [{
"name": "RuboCop on Save",
"trigger": "PostFileSave",
"matcher": "app/.*\\.rb$",
"action": { "type": "command", "command": "bundle exec rubocop --format simple" }
}]
}
Or a hook that reminds the agent to add model specs when creating a new model:
{
"version": "v1",
"hooks": [{
"name": "Remind model specs",
"trigger": "PostFileCreate",
"matcher": "app/models/.*\\.rb$",
"action": { "type": "agent", "prompt": "A new model was created. Ensure a corresponding RSpec model spec exists in spec/models/ with tests for validations and scopes." }
}]
}
These hooks act like a team lead reviewing your work in real-time — catching things that slip through when you're focused on the feature logic.
When to Use Spec Mode
For this blog post example, a Vibe session was plenty. But for larger features — think a full commenting system with nested replies, moderation, and notifications — Kiro's Spec workflow is more appropriate. It would:
- Generate requirements — Define what the commenting system should do (EARS notation): "When a user submits a comment, the system shall associate it with the parent article and notify the article author."
-
Produce a design — Map out the
Commentmodel, its associations (belongs_to :article,belongs_to :user, self-referentialhas_many :replies), the controller actions, and the notification mechanism. - Break into tasks — Create ordered implementation steps: migration first, then model with associations, then controller, then notification job, then views.
Each task is implemented and verified before moving to the next. This prevents the "generate everything at once and debug for an hour" problem that happens with complex features.
Key Takeaways
Kiro generates idiomatic Rails code. It follows naming conventions, file placement, RESTful design, and the ActiveRecord API without being told to.
It understands the relationship between layers. Database constraints complement model validations. Indexes match query patterns. Strong parameters exclude sensitive fields.
Iterative refinement works naturally. Generate the base resource, then ask for validations, scopes, callbacks, or associations. Each addition builds on what's already there.
Steering files codify team standards. Your UUID preference, your authorization library, your testing conventions — write them once and Kiro follows them in every session.
Hooks automate quality gates. Run RuboCop on save, prompt for specs on new models, lint migrations before committing. The tooling works with your existing Ruby ecosystem.
Convention over configuration cuts both ways. Rails gives you conventions. Kiro respects them. The result is less time configuring and more time building the parts of your application that are actually unique.
Rails developers already benefit from a framework that makes decisions for them. Kiro extends that philosophy into the development workflow itself — you describe what you need, and it generates code that follows the conventions you'd follow by hand, just faster.
Top comments (0)