--- name: new-app description: Scaffold a new Rails app with event sourcing, following project conventions (todomvc as reference) --- # New App Scaffold ## When to use Use this skill when asked to create a new Rails application in the `apps/` directory. Each app is a standalone Rails application that uses domain modules from `domains/` and the shared `infra` gem. ## Reference `apps/todo_mvc` is the canonical reference for **app structure** (initializer, Configuration, test harness, Makefile). For the **current RES 3.0 wiring and gem versions**, use `apps/rails_application` and `apps/twitter` — todo_mvc still pins RES 2.x, which is stale. When the two disagree, follow rails_application. ## Version policy (do this first, every time) Always scaffold on the **newest Ruby and newest Rails** — do not hardcode versions from this doc, they go stale. - **Newest Rails**: `gem list rails --remote --exact | head -1` (checks RubyGems, not just what's installed locally). As of this writing: Rails 8.1.3. - **Newest Ruby**: pick the highest installed under `~/.rbenv/versions/` (`ls ~/.rbenv/versions | sort -V | tail`). As of this writing: Ruby 4.0.1. Set it in the app's `.ruby-version`. Ruby 4.0.1 works with Rails 8.1 + RES 3.0 + the infra/domain gems. - Confirm the chosen Ruby's gemset actually has the target Rails: `~/.rbenv/versions/{ruby}/bin/rails -v`. Invoke rails/bundle via the **full rbenv path** for the chosen Ruby, e.g. `~/.rbenv/versions/4.0.1/bin/rails`, `~/.rbenv/versions/4.0.1/bin/bundle` — the `rails` shell alias may resolve to a different Ruby. ## Commit cadence Commit frequently at natural checkpoints, not once at the end. **Commit directly on the current branch — do not create a feature branch.** Suggested commits: (1) after generating the app + configuring the Gemfile, (2) after RES wiring is green, (3) after each domain/read model. Per project convention, use the `/commit` skill and never mention Claude in commit messages. ## Step-by-step process ### 1. Gather requirements Before writing any code, clarify: - The **app name** (snake_case, e.g. `crm`, `inventory_tracker`) - Which **domain modules** it will use (existing ones from `domains/` or new ones to be created) - What the app **does** at a high level — what entities, what user actions ### 2. Generate the Rails app Run from the `apps/` directory, using the full rbenv path for the newest Ruby (see Version policy above): ```bash cd apps && ~/.rbenv/versions/{ruby}/bin/rails new {app_name} --database=postgresql --css=tailwind --skip-test ``` `--skip-test` (not `--skip-test-unit`, which no longer exists) drops the default test setup so we can install our own minitest + mutant harness. **Important post-generation steps:** - Set the app's Ruby: `echo "{ruby}" > apps/{app_name}/.ruby-version` (the generator pins the global default, which may not be the newest). - `rails new` creates a nested `.git` directory inside the new app. **It must be removed** so the app is part of the parent repo. `rm -rf` is blocked by the repo's git-safety hook, so **ask the user to run it manually**: `rm -rf apps/{app_name}/.git`. Do not proceed to committing until it's gone. ### 3. Configure Gemfile Add these gems to the generated Gemfile (after `jbuilder`, before the tzinfo/solid gems). Use RES 3.0 to match `rails_application`: ```ruby gem "rails_event_store", ">= 3.0", "< 4.0" gem "arkency-command_bus" gem "infra", path: "../../infra" ``` Rails 8.1 with `--skip-test` does **not** generate a capybara/selenium test group. Just **add** a test group: ```ruby group :test do gem "mutant-minitest" end ``` Run `bundle install` (via the full rbenv path). Commit the generated app + Gemfile now (checkpoint 1). ### 4. Create event store initializer Create `config/initializers/rails_event_store.rb`: ```ruby require "rails_event_store" require "arkency/command_bus" require_relative "../../lib/configuration" Rails.configuration.to_prepare do Rails.configuration.event_store = Infra::EventStore.main Rails.configuration.command_bus = Arkency::CommandBus.new Configuration.new.call(Rails.configuration.event_store, Rails.configuration.command_bus) end ``` ### 5. Create app-level Configuration Create `lib/configuration.rb`. For the **initial scaffold there is no domain yet**, so `call` only wires event linking — this keeps the app bootable and tests green before any domain exists: ```ruby require_relative "../../../infra/lib/infra" class Configuration def call(event_store, command_bus) enable_res_infra_event_linking(event_store) end private def enable_res_infra_event_linking(event_store) [ RailsEventStore::LinkByEventType.new, RailsEventStore::LinkByCorrelationId.new, RailsEventStore::LinkByCausationId.new ].each { |h| event_store.subscribe_to_all_events(h) } end end ``` As domains and read models are added later, extend `call`: `require_relative` the domain, add `{DomainModule}::Configuration.new.call(event_store, command_bus)`, and give each read model its own `enable_*` private method called from `call`. Do not add a `require_relative` to a domain that doesn't exist yet — it will break boot. ### 6. Create event store migration In **RES 3.0 the generator was renamed** — the old `rails_event_store_active_record:migration` no longer exists. Use the `ruby_event_store` namespace with a data type (`jsonb` pairs cleanly with infra's `RailsEventStore::JSONClient`): ```bash cd apps/{app_name} && ~/.rbenv/versions/{ruby}/bin/bundle exec rails generate ruby_event_store:active_record:migration --data-type=jsonb ``` Then `rails db:create && rails db:migrate` (via the full rbenv path). The generated migration creates `event_store_events` and `event_store_events_in_streams` with uuid `event_id` and `jsonb` `data`/`metadata`. ### 7. Create ApplicationController Ensure `app/controllers/application_controller.rb` has: ```ruby class ApplicationController < ActionController::Base allow_browser versions: :modern def command_bus Rails.configuration.command_bus end def event_store Rails.configuration.event_store end end ``` ### 8. Create test helper **Replace** the generated `test/test_helper.rb` (which has `parallelize` and `fixtures :all`): ```ruby ENV["RAILS_ENV"] ||= "test" require_relative "../config/environment" require "rails/test_help" require "mutant/minitest/coverage" ActiveJob::Base.logger = Logger.new(nil) class InMemoryRESTestCase < ActiveSupport::TestCase def before_setup result = super @previous_event_store = Rails.configuration.event_store @previous_command_bus = Rails.configuration.command_bus Rails.configuration.event_store = Infra::EventStore.in_memory Rails.configuration.command_bus = Arkency::CommandBus.new Configuration.new.call( Rails.configuration.event_store, Rails.configuration.command_bus ) result end def before_teardown result = super Rails.configuration.event_store = @previous_event_store Rails.configuration.command_bus = @previous_command_bus result end def event_store Rails.configuration.event_store end def command_bus Rails.configuration.command_bus end end class InMemoryRESIntegrationTestCase < ActionDispatch::IntegrationTest def before_setup result = super @previous_event_store = Rails.configuration.event_store @previous_command_bus = Rails.configuration.command_bus Rails.configuration.event_store = Infra::EventStore.in_memory_rails Rails.configuration.command_bus = Arkency::CommandBus.new Configuration.new.call(Rails.configuration.event_store, Rails.configuration.command_bus) result end def before_teardown result = super Rails.configuration.event_store = @previous_event_store Rails.configuration.command_bus = @previous_command_bus result end def command_bus Rails.configuration.command_bus end end ``` ### 9. Create .mutant.yml Create `.mutant.yml` in the app root. On the initial scaffold there are **no subjects yet**, so start with empty lists (mutant isn't run until there's domain code to cover): ```yaml includes: - test requires: - ./config/environment integration: minitest usage: opensource coverage_criteria: timeout: true process_abort: true matcher: subjects: [] ignore: [] ``` As each read model is added, append its namespace to `subjects` (e.g. `- Tweets*`) and its AR model + `Configuration#call` to `ignore`. See `apps/crm/.mutant.yml` for a fully-populated example. Mutant writes per-run result JSONs to `.mutant/results/`. These are transient artifacts and must never be committed — add this line to the app's `.gitignore` now: ``` /.mutant/results/ ``` ### 10. Register in root Makefile Add targets to the root `Makefile`: ```makefile install-{app_name}: @make -C apps/{app_name} install test-{app_name}: @make -C apps/{app_name} test mutate-{app_name}: @make -C apps/{app_name} mutate ``` Add `install-{app_name}` to the `install:` target, `test-{app_name}` to `test:`, and `mutate-{app_name}` to `mutate:`. ### 11. Create app Makefile Create `Makefile` in the app directory: ```makefile install: @bin/setup dev: @make -j 2 web css test: @bin/rails test mutate: @RAILS_ENV=test bundle exec mutant run tailwind: @bin/rails tailwindcss:build css: @bin/rails tailwindcss:watch web: @bin/rails server -p {port} .PHONY: install dev test mutate tailwind css web ``` Use a unique port per app (3000 for rails_application, 3001+ for new apps). ## Controller pattern Controllers dispatch commands and query read models: ```ruby class ResourceController < ApplicationController def index @records = ReadModelName.all end def create id = SecureRandom.uuid ActiveRecord::Base.transaction do command_bus.call(DomainModule::CreateCommand.new(id: id, ...)) end redirect_to root_path end def update command_bus.call(DomainModule::UpdateCommand.new(id: params[:id], ...)) redirect_to root_path end def destroy command_bus.call(DomainModule::DeleteCommand.new(id: params[:id])) redirect_to root_path end end ``` ## Build order Build the app incrementally, with tests passing at each step: 1. Scaffold Rails app + boilerplate (steps 2-11) 2. Create or reference domain modules (use the `/domain` skill) 3. Add read models one at a time (use the `/read-model` skill) 4. Add controllers + views for each read model 5. Add integration tests 6. Run `make test` and mutation testing ## Gotchas - **Versions go stale**: Do not hardcode Ruby/Rails versions — resolve the newest each time (see Version policy). This doc's concrete numbers (Ruby 4.0.1, Rails 8.1.3) are examples, not pins. - **`rails`/`bundle` alias**: The shell alias may resolve to the wrong Ruby. Always use the full rbenv path: `~/.rbenv/versions/{ruby}/bin/rails`, `~/.rbenv/versions/{ruby}/bin/bundle`. - **Nested `.git`**: `rails new` creates its own git repo. It must be removed before the app can be committed to the parent repo. `rm -rf` is **blocked by the git-safety hook** — ask the user to run `rm -rf apps/{app_name}/.git` manually. - **`.ruby-version`**: The generator pins the rbenv global default, not necessarily the newest — overwrite it with the chosen Ruby. - **RES 3.0 migration generator renamed**: use `ruby_event_store:active_record:migration`, not the old `rails_event_store_active_record:migration` (see step 6). - **`bin/rails` commands** (migrations, generators, tests): Must run from `apps/{app_name}/` directory, not project root. - **Generated boilerplate**: Rails generates channels, kamal/solid config etc. These are harmless but unused — leave them or ask user to clean up. ## Key conventions - Each app is a standalone Rails app in `apps/` - Apps reference domains via `require_relative "../../../domains/{name}/lib/{name}"` - Apps reference infra via `gem "infra", path: "../../infra"` - Read models live in `app/read_models/{name}/configuration.rb` - All event access uses `event.data.fetch(:key)` - Controllers use `command_bus.call(...)` for writes, read models for queries - UUIDs for all business entity IDs (`SecureRandom.uuid`) - Test-first TDD, 100% mutation score