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.