How it works

This bulletin board is built entirely on ECS Rails — every screen you've seen maps to a component-based domain model. A post, a product, a profile and an order each have a How this works panel beside their buttons, showing the declarations and the rows behind that page.

Entities are composed from components

A Post isn't a wide table. It's an identity row composed from catalogue components — standard parts that ship with the gem, each under a slot that names its role:

class Post < ApplicationEntity
  component Text,    prefix: :title                               # post.title
  component Text,    prefix: :body                                # post.body
  component State,   prefix: :publish, states: %w[draft published] # post.publish_state
  component Counter, prefix: :likes                               # post.likes
  relates_to :author, User                                        # post.author
  has_many :comments, via: :post                                  # post.comments
end

This whole board was written after rails g ecs_rails:install and one db:migrate, and never needed another migration: the db/migrate directory holds exactly one file. A post's title, its body and a group's rules all live in one texts table; what tells them apart is the slot. And because Text declares value as its primary attribute, post.title is the string and post.title_text the component behind it.

Markers → badges

The Moderator and ★ Admin badges on People are marker components. A user is a moderator exactly when the row exists — declared with marker :moderator, set with user.add(:moderator), asked with user.moderator?. One markers table for every marker. No STI.

Lazy by default

A component costs nothing until a value differs from its default — no row, no query. A new person with no bio has no row in texts, yet user.bio still works.

Query by composition

The posts list is Post.with_component(State, prefix: :publish, status: "published") — it filters by what an entity is made of, and scopes to the entity type so a shared component can't leak across types. Its search box is with_component(SearchVector) { matching(q) } and “most liked” is order_by_component(Counter, prefix: :likes, direction: :desc) — a scalar subquery in ORDER BY, because EXISTS can't sort.

One component, two slots

A group has a name, a description and house rules — the same Text component declared three times under three slots. Same table, one row per slot, read as group.rules.

Relationships & join entities

relates_to :author, User links a post to its author, and has_many :posts, via: :author on the user reads them back as a real collection — both rows in one relationships table. A group membership is a join entity carrying two relationships and a role.

The market: filters, sort, and a system that never names an entity

A product is nine catalogue components and a relationship — price is Money under the slot price, the SKU an Identifier, stock a Counter, categories a Tags, the listing lifecycle a State — and, like the board, it needed no migration. Every filter on the market page is one clause on the same query:

Product.listed
       .with_component(Money, prefix: :price) { where("amount_cents <= ?", 5000) }
       .with_component(Rating, "stars >= ?", 4)
       .with_component(Tags) { tagged("books") }
       .order_by_component(Money, :amount_cents, prefix: :price)

A seller's people are Employment join entities — the same shape as a group membership, carrying the same Role component — and the same User is a customer and an employee at once, with no subclass for either. Search is fed by an indexer that walks the texts table and never names Post or Product: a component table is blind to entity type, so one system serves them all.

Checkout: a system over components, and copies not links

Demo::Checkout is a plain Ruby object that turns a Basket into a paid Order with an Invoice in one transaction: it copies the customer's shipping and billing Address slots and each product's Money onto the order lines — snapshots, never links, so a later price edit leaves history alone — totals them with Money#+ (which refuses to mix currencies), charges a simulated gateway, and lets the State component transition itself to paid. A declined card raises, and the whole thing rolls back. What the gem does not give a system — scheduling, idempotency, retries — is on the backlog, on purpose.

Geocoding: one system, every address

Demo::Geocoder gives each Address a Geolocation in the same slot — a seller's address and a customer's shipping and billing addresses in one pass over the addresses table, without naming User or Company. An entity takes part by declaring the pair. Run it, edit an address and run it again on the geocoder page. Its coordinates are simulated.

Validation merges onto the entity

The Email component validates its address; the error surfaces as user.errors[:"email.address"] and reads naturally in the form. Try adding a person with a bad email.

Source & design docs on GitHub, the gem on RubyGems.