ECS Rails

Compose ordinary Rails models from reusable components. Install their tables once; add entity types, labelled slots, relationships and markers in Ruby.

This guide is for 0.3.0, the zero-migrations release. Upgrading from 0.2.x? See Upgrading an existing installation and the changelog.

Quickstart: a contacts directory

You need Ruby 3.2 or newer, Bundler, Rails 7.1–8.x and a running PostgreSQL server. Your PostgreSQL role must be able to create databases. Use a new app and database for this tutorial; the script creates example records each run.

From a directory where you keep projects:

rails new contacts --minimal --database=postgresql
cd contacts
bundle add ecs_on_rails --version "~> 0.3.0"

To follow unreleased work on main instead, clone the repository and use bundle add ecs_on_rails --path ../ecs_rails/gem.

Install the catalogue, then create and migrate the app's database:

bin/rails generate ecs_rails:install --sets core commerce
bin/rails db:create db:migrate

The generator writes ApplicationEntity, ApplicationComponent, catalogue classes and an initializer under app/entities and config/initializers. Components are top-level constants even though their files live in app/entities/components. The install migration creates the selected catalogue tables. This tutorial selects core commerce to include Money; omitting --sets installs just core.

Generate the three entity classes from installed components:

bin/rails generate ecs_rails:entity Contact name email home:address avatar:image
bin/rails generate ecs_rails:entity Company name:text email
bin/rails generate ecs_rails:entity Note body:text

The generator writes only Ruby classes. Add the marker and inverse relationship to app/entities/contact.rb, so its declarations read:

class Contact < ApplicationEntity
  component Name
  component Email
  component Address, prefix: :home
  component Image, prefix: :avatar
  marker :featured
  has_many :notes, via: :author
end

The generated app/entities/company.rb is ready to use. A company's name is a labelled Text, while Name holds a person's given/family names:

class Company < ApplicationEntity
  component Text, prefix: :name
  component Email
end

Add relates_to :author, Contact to app/entities/note.rb. Links use an installed shared table:

class Note < ApplicationEntity
  component Text, prefix: :body
  relates_to :author, Contact
end

Create app/services/email_directory.rb (create the directory too). A system is just Ruby operating on components. This one reads addresses from any entity type without knowing Contact or Company:

class EmailDirectory
  def self.call
    Email.where.not(address: nil).order(:address).distinct.pluck(:address)
  end
end

Create script/quickstart.rb:

contact = Contact.create!
puts contact.email.persisted?          # false: a virtual component
puts contact.email.verified            # false: its column default

contact.update!(name_given: "Ada", name_family: "Lovelace",
                email_address: "[email protected]", home_address_country: "AU")
contact.add(:featured)
contact.notes.create!(body: "Met at Ruby meetup")
Company.create!(name: "Analytical Engines", email_address: "[email protected]")

puts contact.name.initials             # AL: behavior on the component
puts contact.notes.sole.body           # Met at Ruby meetup
puts Contact.with_component(Address, prefix: :home, country: "AU").count # 1
puts Contact.with_marker(:featured).count # 1
puts EmailDirectory.call              # [email protected], then [email protected]

Run it:

bin/rails runner script/quickstart.rb
bin/rails zeitwerk:check

There is still one migration. name_given delegates to name.given; home_address_country delegates to the labelled Address. A primary attribute also gets the bare slot name: note.body is the string, note.body_text is its component. The relationship reader note.author returns a Contact or nil.

Render the directory

Create app/controllers/contacts_controller.rb:

class ContactsController < ApplicationController
  def index
    @contacts = Contact.order(:id).preload(:name, :email)
  end
end

Create app/views/contacts/index.html.erb:

<h1>Contacts</h1>
<% @contacts.each do |contact| %>
  <p><%= contact.name_given %>: <%= contact.email_address %></p>
<% end %>

Replace config/routes.rb:

Rails.application.routes.draw do
  root "contacts#index"
end

Run bin/rails server and open http://localhost:3000. You should see Ada: [email protected]. For a larger working application, follow the demo setup.

Generating more entities

After installing and migrating, this command writes app/entities/person.rb:

bin/rails generate ecs_rails:entity Person name email mobile:phone work:phone home:address
class Person < ApplicationEntity
  component Name
  component Email
  component Phone, prefix: :mobile
  component Phone, prefix: :work
  component Address, prefix: :home
end

References name existing component classes, not database column types. name or Name selects the default slot; home:address selects an Address labelled home. Use / or :: for namespaces, such as CRM/Person and billing/contact_email. The entity goes under the configured entities_path; components resolve through the app's normal autoloading, including renamed or bespoke classes. Namespaced output uses absolute component references.

Missing components produce install/upgrade/--sets guidance and the bespoke component escape hatch. Invalid references and duplicate component/slot pairs fail before writing. Other method/delegation conflicts are reported by the DSL when the class loads; edit ordinary Ruby for only:, except:, slot options, relationships and markers. Run bin/rails zeitwerk:check after editing.

Existing files use Rails/Thor's conflict handling; --skip preserves them, --force overwrites, and --pretend previews. bin/rails destroy ecs_rails:entity Person removes the generated file, not its database records. The generator emits no migration, component file or empty spec.

Presence, values and query costs

An absent component reader returns a virtual object with database column defaults. A first read on a saved entity may issue a SELECT even when no row exists. The reader caches the result; preloading can avoid per-entity reads. contact.avatar_image.persisted? is false until that slot has a stored row. contact.has?(Image, prefix: :avatar) checks persisted presence; a virtual object's Ruby truthiness does not imply presence.

Saving an entity persists touched components whose values differ from defaults. An untouched virtual component skips validation; touched components validate and merge errors onto the entity. save returns false for invalid input, and save! raises ActiveRecord::RecordInvalid. Blank strings differ from nil defaults; normalize optional form fields with .presence when blank should mean absent. Changing a persisted component back to defaults does not automatically delete it.

with_component and without_component filter stored rows, not virtual values. For example, a virtual Counter reads zero but does not match a query for a stored Counter with count: 0. Component queries need not be declared on the entity and can match independently stored rows. Markers make presence explicit: add(:featured), remove(:featured) and featured?.

Use ordinary Rails association preloads for the slots a page reads, as the controller above does. includes_components(Address) loads all declared Address slots. Relationship targets can be nested with Note.preload(author_relationship: { target: :name }). See the performance comparison for measured costs.

Updating, replacing and deleting

Prefer reader updates followed by an entity save:

contact.email.assign_attributes(address: "[email protected]")
contact.save!
# Equivalent delegated update:
contact.update!(email_address: "[email protected]")
Component operation Behavior
contact.email Returns a cached component, possibly virtual.
contact.email = Email.new(address: "[email protected]") Replaces atomically and immediately on a saved owner; waits for owner save on a new owner. Default-only replacements remain virtual.
contact.email = nil Removes the row; the next reader returns a virtual component.
contact.reload_email / contact.reset_email Discard both component and association caches. Reload reads immediately; reset defers the read.
contact.build_email, create_email, create_email! Intentionally raise EcsRails::InvalidComponent; use the reader or delegated update.

Replacement preserves the original row if validation fails, raising ActiveRecord::RecordInvalid on a saved owner. Wrong types, another owner's component and moving a persisted component between slots are rejected. After an outer transaction rolls back, reload the owner before using its cached state. Raw association mutation and validation-bypassing writes are outside this contract.

Relationships. note.author = Contact.new(...) saves that new target, its touched components and the link in the owner's transaction when note.save! runs. An invalid target prevents the save. Wrong-type objects raise on assignment; wrong-type or missing IDs fail validation, exposed under note.errors["author_relationship.target"]. Nil is allowed; reading an unset link or assigning then clearing a new target creates no target or link row. Targets accept subclasses of the declared class. The foreign key protects existence, but writes bypassing validation also bypass the Ruby target-type check.

Deletion. Destroying an entity deletes its component rows through PostgreSQL ON DELETE CASCADE, bypassing those components' destroy callbacks. Explicit contact.email.destroy runs component destroy callbacks and resets the owner reader to virtual defaults. Replacement also runs the old component's destroy callbacks, then makes the replacement available through the reader. Destroying a relationship's target nullifies target_id; reload a previously cached owner to observe it. The referring entity survives.

Inverse collection APIs such as contact.notes.create! are supported Rails associations. Deleting from an inverse collection removes the link, not the child entity. dependent: :destroy / :delete_all on an inverse also applies to link rows. To destroy the notes themselves, explicitly call contact.notes.each(&:destroy) before destroying the contact. Choose this in the application: invoices, for example, may need to survive their former owner.

When another migration is needed

“Zero migrations” means composition from the installed catalogue. New slots, markers and entity types reuse those tables. New catalogue schema versions and bespoke storage still need migrations.

For a bespoke temperature reading, run:

bin/rails generate ecs_rails:component Temperature celsius:decimal
bin/rails db:migrate

Inspect the generated migration before running it; decimal precision, scale and defaults are application choices. The generator also writes a model and an RSpec example (running that spec requires an RSpec Rails setup). Create app/entities/weather_station.rb:

class WeatherStation < ApplicationEntity
  component Temperature
end

Create script/temperature.rb:

station = WeatherStation.create!(temperature_celsius: 21.5)
puts station.reload.temperature.celsius # 21.5
bin/rails runner script/temperature.rb
bin/rails zeitwerk:check

This app now has a second migration and a dedicated temperatures table.

Upgrading an existing installation

Back up the database and try the upgrade on a copy first. From 0.2.x, change the Gemfile entry to gem "ecs_on_rails", "~> 0.3.0", then run bundle update ecs_on_rails. Use development (without eager loading) for generation: an old app needs the new generated classes before it can fully boot. Set RAILS_ENV=development in your shell, then run:

bin/rails generate ecs_rails:upgrade

Choose --sets core commerce if the app needs Money. Review the generated migrations, then run:

bin/rails db:migrate

The upgrade moves old relationship backing tables and marker rows into shared tables, preserving their IDs and links. Replace old marker declarations such as component Moderator with marker :moderator, update add(Moderator) / remove(Moderator) calls to symbols, and remove the obsolete marker class file after migration. Review the generator's output for your app's names. Bespoke components remain; do not remove their classes.

bin/rails zeitwerk:check
bin/rails generate ecs_rails:upgrade

Also run your application's tests. Repeating the generator on the completed schema should produce no new migration. Upgrade checks column definitions, unique/partial indexes and foreign keys before writing files. Incompatible structures need an explicit repair/backfill migration; the generator does not convert values or silently replace constraints. New constraints validate existing rows when the migration runs. See the upgrade design.

Compatibility checks

The gem requires Ruby >= 3.2 and Rails >= 7.1, < 9. The representative CI matrix covers Ruby/Rails 3.2/7.1, 3.2/7.2, 3.3/8.0, 3.2/8.1, 3.4/8.1 and 4.0/8.1, resolving current patches within each Rails minor. It runs PostgreSQL gem specs and packaged fresh-install/0.2.2-upgrade checks for each entry. The package check extracts the marked code and Rails command blocks from this README, then verifies the tutorial's stored results, rendered page and bespoke component. The demo suite, eager loading and a 100% public-API documentation gate run separately.

JSON is constrained to version 2 because supported Rails decoders still use positional option hashes incompatible with JSON 3. This is a runtime dependency, so a packaged consumer receives the same constraint as the test suite.

To reproduce a Rails series locally from gem/:

export BUNDLE_GEMFILE="$PWD/gemfiles/rails_7.1.gemfile"
bundle install
DATABASE_URL=postgresql:///ecs_rails_test bundle exec rspec
DATABASE_URL=postgresql:///ecs_rails_test bundle exec ruby script/package_smoke.rb

The package smoke script creates its own uniquely named PostgreSQL databases; its connection role needs permission to create databases. It removes only those databases after the run. CI uploads each built gem with its resolved dependency lockfile. This tests released versions, not future Ruby/Rails releases.

Documentation

  • Architecture — the invariants and design background.
  • v0.1 retrospective — what was built, what the demo found, what's next.
  • Source walkthrough — follow composition, validation, checkout and indexing through the code.
  • ADRs — why the design is the way it is.
  • RFCs — feature contracts and their amendments.
  • Backlog — what deliberately isn't built yet.
  • Friction log — the demo's running verdict on the API.

Development

Requires Ruby >= 3.2 and a running PostgreSQL.

createdb ecs_rails_test
bundle install
bundle exec rspec

Set DATABASE_URL to point the suite at a different database.

Names

ecs_rails everywhere except the Gemfile — see ADR-0007.

| | | |---|---| | GitHub repo | ecs_rails | | RubyGems gem | ecs_on_rails | | Ruby module | EcsRails | | require | ecs_rails | | Generators | ecs_rails:install, :component, :entity, :upgrade |

Only the published gem name differs. RubyGems collapses -, _ and case when comparing names, so ecs-rails, ecs_rails and ecsrails are one name — and it belongs to an unrelated, still-maintained gem. ecs_on_rails keeps the rails keyword without the rails- prefix that convention reserves for Rails Core Team gems.

Licence

MIT. See LICENSE.txt.