CtrlK
BlogDocsLog inGet started
Tessl Logo

rails-project-starter

Scaffold a production-ready Rails 8.x API with Ruby 3.3+, ActiveRecord, Devise authentication, Pundit authorization, RSpec testing, service objects, and Sidekiq background jobs.

Install with Tessl CLI

npx tessl i github:achreftlili/deep-dev-skills --skill rails-project-starter
What are skills?

71

Does it follow best practices?

Validation for skill structure

SKILL.md
Review
Evals

Rails Project Starter

Scaffold a production-ready Rails 8.x API with Ruby 3.3+, ActiveRecord, Devise authentication, Pundit authorization, RSpec testing, service objects, and Sidekiq background jobs.

Prerequisites

  • Ruby >= 3.3
  • Bundler >= 2.5
  • Node.js >= 20.x (for asset pipeline, skip for API-only)
  • PostgreSQL
  • Redis (for Sidekiq, Action Cable, caching)

Scaffold Command

# API-only application
rails new <project-name> --api --database=postgresql --skip-test
cd <project-name>

# Add essential gems to Gemfile, then bundle
bundle add devise devise-jwt pundit
bundle add sidekiq
bundle add rspec-rails factory_bot_rails faker --group "development, test"
bundle add shoulda-matchers --group test

# Setup
rails db:create
rails generate rspec:install
rails generate devise:install
rails generate devise User
rails generate pundit:install
rails db:migrate

Full-stack (non-API) variant

rails new <project-name> --database=postgresql --skip-test --css=tailwind

Project Structure

app/
  controllers/
    application_controller.rb     # Base controller — auth, error handling
    api/
      v1/
        users_controller.rb       # Versioned API controllers
        posts_controller.rb
  models/
    application_record.rb
    user.rb                       # Devise model, associations, validations
    post.rb
  policies/
    application_policy.rb         # Pundit base policy
    post_policy.rb
  services/
    application_service.rb        # Base service object
    users/
      create_user.rb              # Service objects per domain action
      update_user.rb
  serializers/                    # Or app/views/api/v1/ with jbuilder
    user_serializer.rb
  jobs/
    application_job.rb
    send_welcome_email_job.rb     # Sidekiq background job
config/
  routes.rb
  database.yml
  initializers/
    devise.rb
    sidekiq.rb
    cors.rb                       # rack-cors configuration
db/
  migrate/
  schema.rb
  seeds.rb
spec/
  models/
  requests/                       # Integration tests (preferred over controller specs)
  services/
  factories/
  rails_helper.rb
  spec_helper.rb
.env                               # Environment variables (do not commit)
.env.example                       # Template for required env vars (commit this)

Key Conventions

  • API versioning via namespace: /api/v1/resource
  • Thin controllers — business logic in service objects, not controllers
  • One service object per business action, returning a result
  • ActiveRecord models for validations, associations, scopes — no business logic
  • Pundit policies mirror models 1:1 for authorization
  • RSpec request specs for integration testing (not controller specs)
  • Factory Bot for test data — no fixtures
  • Sidekiq for all async work (email, reports, imports)
  • Strong parameters in controllers via private resource_params method
  • Respond with consistent JSON structure: { data: ..., meta: ... } or { error: ... }

Essential Patterns

Base API Controller — app/controllers/api/v1/base_controller.rb

module Api
  module V1
    class BaseController < ApplicationController
      include Pundit::Authorization

      before_action :authenticate_user!

      rescue_from ActiveRecord::RecordNotFound do |e|
        render json: { error: e.message }, status: :not_found
      end

      rescue_from ActiveRecord::RecordInvalid do |e|
        render json: { error: e.record.errors.full_messages }, status: :unprocessable_entity
      end

      rescue_from Pundit::NotAuthorizedError do
        render json: { error: "Not authorized" }, status: :forbidden
      end

      private

      def paginate(scope)
        page = (params[:page] || 1).to_i
        per_page = [(params[:per_page] || 20).to_i, 100].min
        offset = (page - 1) * per_page

        {
          data: scope.limit(per_page).offset(offset),
          meta: { page: page, per_page: per_page, total: scope.count }
        }
      end
    end
  end
end

Resource Controller — app/controllers/api/v1/users_controller.rb

module Api
  module V1
    class UsersController < BaseController
      skip_before_action :authenticate_user!, only: [:create]

      def index
        users = policy_scope(User).order(created_at: :desc)
        result = paginate(users)
        render json: {
          data: result[:data].map { |u| UserSerializer.new(u).as_json },
          meta: result[:meta]
        }
      end

      def show
        user = User.find(params[:id])
        authorize user
        render json: { data: UserSerializer.new(user).as_json }
      end

      def create
        result = Users::CreateUser.call(user_params)

        if result.success?
          render json: { data: UserSerializer.new(result.user).as_json },
                 status: :created
        else
          render json: { error: result.errors }, status: :unprocessable_entity
        end
      end

      def update
        user = User.find(params[:id])
        authorize user
        user.update!(user_params)
        render json: { data: UserSerializer.new(user).as_json }
      end

      def destroy
        user = User.find(params[:id])
        authorize user
        user.destroy!
        head :no_content
      end

      private

      def user_params
        params.require(:user).permit(:email, :name, :password, :password_confirmation)
      end
    end
  end
end

Service Object — app/services/users/create_user.rb

module Users
  class CreateUser
    attr_reader :user, :errors

    def self.call(params)
      new(params).call
    end

    def initialize(params)
      @params = params
      @errors = []
    end

    def call
      @user = User.new(@params)

      ActiveRecord::Base.transaction do
        @user.save!
        SendWelcomeEmailJob.perform_later(@user.id)
      end

      self
    rescue ActiveRecord::RecordInvalid => e
      @errors = e.record.errors.full_messages
      self
    end

    def success?
      @errors.empty?
    end
  end
end

Model — app/models/user.rb

class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :jwt_authenticatable, jwt_revocation_strategy: JwtDenylist

  has_many :posts, dependent: :destroy

  validates :name, presence: true, length: { maximum: 100 }
  validates :email, presence: true, uniqueness: true

  scope :active, -> { where(active: true) }
  scope :recent, -> { order(created_at: :desc) }
end

Pundit Policy — app/policies/post_policy.rb

class PostPolicy < ApplicationPolicy
  def index?
    true
  end

  def show?
    true
  end

  def create?
    user.present?
  end

  def update?
    owner?
  end

  def destroy?
    owner? || user.admin?
  end

  class Scope < Scope
    def resolve
      if user&.admin?
        scope.all
      else
        scope.where(published: true)
      end
    end
  end

  private

  def owner?
    record.user_id == user.id
  end
end

Serializer — app/serializers/user_serializer.rb

class UserSerializer
  def initialize(user)
    @user = user
  end

  def as_json
    {
      id: @user.id,
      email: @user.email,
      name: @user.name,
      created_at: @user.created_at.iso8601,
      updated_at: @user.updated_at.iso8601
    }
  end
end

Background Job — app/jobs/send_welcome_email_job.rb

class SendWelcomeEmailJob < ApplicationJob
  queue_as :default
  retry_on StandardError, wait: :polynomially_longer, attempts: 3

  def perform(user_id)
    user = User.find(user_id)
    UserMailer.welcome(user).deliver_now
  end
end

Routes — config/routes.rb

Rails.application.routes.draw do
  devise_for :users, controllers: {
    sessions: "api/v1/sessions",
    registrations: "api/v1/registrations"
  }

  namespace :api do
    namespace :v1 do
      resources :users, only: [:index, :show, :create, :update, :destroy]
      resources :posts
    end
  end

  # Sidekiq Web UI (admin only in production)
  require "sidekiq/web"
  mount Sidekiq::Web => "/sidekiq"

  get "up" => "rails/health#show", as: :rails_health_check
end

Sidekiq Config — config/initializers/sidekiq.rb

Sidekiq.configure_server do |config|
  config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
end

Sidekiq.configure_client do |config|
  config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
end

CORS Config — config/initializers/cors.rb

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins ENV.fetch("ALLOWED_ORIGINS", "*")
    resource "*",
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head],
      expose: ["Authorization"]
  end
end

Migration — db/migrate/YYYYMMDD_create_users.rb

class CreateUsers < ActiveRecord::Migration[8.0]
  def change
    create_table :users, id: :uuid do |t|
      t.string :email, null: false
      t.string :name, null: false
      t.string :encrypted_password, null: false, default: ""
      t.boolean :admin, default: false
      t.boolean :active, default: true

      # Devise fields
      t.string :reset_password_token
      t.datetime :reset_password_sent_at
      t.datetime :remember_created_at

      t.timestamps
    end

    add_index :users, :email, unique: true
    add_index :users, :reset_password_token, unique: true
  end
end

RSpec Request Spec — spec/requests/api/v1/users_spec.rb

require "rails_helper"

RSpec.describe "Api::V1::Users", type: :request do
  let(:user) { create(:user) }
  let(:headers) { auth_headers(user) }

  describe "GET /api/v1/users" do
    before { create_list(:user, 3) }

    it "returns paginated users" do
      get "/api/v1/users", headers: headers
      expect(response).to have_http_status(:ok)

      body = JSON.parse(response.body)
      expect(body["data"].length).to eq(4) # 3 + the auth user
      expect(body["meta"]["total"]).to eq(4)
    end
  end

  describe "POST /api/v1/users" do
    let(:valid_params) do
      { user: { email: "new@example.com", name: "New User", password: "password123" } }
    end

    it "creates a user" do
      expect {
        post "/api/v1/users", params: valid_params, as: :json
      }.to change(User, :count).by(1)

      expect(response).to have_http_status(:created)
    end
  end
end

Factory — spec/factories/users.rb

FactoryBot.define do
  factory :user do
    email { Faker::Internet.unique.email }
    name { Faker::Name.name }
    password { "password123" }
    password_confirmation { "password123" }

    trait :admin do
      admin { true }
    end
  end
end

First Steps After Scaffold

  1. Copy .env.example to .env and fill in DATABASE_URL, REDIS_URL, and ALLOWED_ORIGINS
  2. Run bundle install to install all gem dependencies
  3. Create the database and run migrations: bin/rails db:create db:migrate
  4. Optionally seed the database: bin/rails db:seed
  5. Start the dev server: bin/rails server
  6. Verify the health endpoint: curl http://localhost:3000/up

Common Commands

# Development server
bin/rails server

# Console
bin/rails console

# Database
bin/rails db:create
bin/rails db:migrate
bin/rails db:seed
bin/rails db:rollback

# Generate
bin/rails generate model Post title:string body:text user:references
bin/rails generate migration AddPublishedToPosts published:boolean

# Tests
bundle exec rspec
bundle exec rspec spec/requests/
bundle exec rspec spec/models/user_spec.rb:15  # specific line

# Sidekiq
bundle exec sidekiq

# Routes
bin/rails routes

# Lint
bundle exec rubocop
bundle exec rubocop -a  # auto-fix

Integration Notes

  • Auth: Devise handles user registration, login, password reset. devise-jwt adds stateless JWT auth for APIs — tokens in Authorization header.
  • Authorization: Pundit policies are plain Ruby classes. Call authorize record in controllers and policy_scope(Model) for scoped queries.
  • Background Jobs: Sidekiq uses Redis. Jobs are enqueued with perform_later (Active Job) or perform_async (Sidekiq native). Use perform_later for framework portability.
  • Caching: Rails 8 includes Solid Cache by default. Use Rails.cache.fetch with expiry. Fragment caching in views with cache helper.
  • Database: PostgreSQL with UUID primary keys (add enable_extension "pgcrypto" in migration). Use config/database.yml with DATABASE_URL env var in production.
  • Testing: RSpec with Factory Bot, Shoulda Matchers, and Faker. Request specs over controller specs. Use DatabaseCleaner or transactional fixtures.
  • Docker: Use ruby:3.3-slim base image. Multi-stage: install gems in build stage, copy to runtime stage. Precompile assets if full-stack.
  • Deployment: Rails 8 includes Kamal for Docker-based deployment. Run kamal setup to deploy to a VPS.
Repository
achreftlili/deep-dev-skills
Last updated
Created

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.