Architecture (Clean Architecture + Trailblazer 2.1)
Dependencies
- •trailblazer-operation
- •trailblazer-rails
- •dry-monads
- •dry-validation
File Structure (Hybrid: Trailblazer Concepts + Rails Conventions)
code
app/
concepts/
game/
operation/
move_player.rb
contract/
move_player.rb
player/
operation/
create.rb
contract/
create.rb
components/ ← Reusable UI components (Phlex)
game/
player_card.rb
ui/
button.rb
views/ ← Page-level views (Phlex)
games/
index.rb
show.rb
players/
index.rb
controllers/ ← Thin controllers
channels/ ← WebSocket channels
models/ ← ActiveRecord (persistence only)
queries/ ← Query objects
services/ ← Domain services
Implementation Notes
This file focuses on patterns and examples. For requirements, see:
Example: custom collection actions can support Turbo Frames when they describe a domain subset. See:
Operations Flow (typical)
- •
Modelstep (load record fromapp/models/) - •
Contract::Build(fromapp/concepts/{domain}/contract/) - •
Contract::Validate - •Domain/service steps
- •
Contract::Persist - •Broadcast step
For Verification & Requirements
See VERIFICATION_CHECKLIST.md for complete requirements.
Common Development Patterns
Adding a Page
- •Create
app/controllers/{domain}_controller.rb - •Create
app/views/{domain}/action.rb(inherit fromViews::Base) - •Create route in
config/routes.rb - •Create
config/locales/{en,es}/{file}.yml - •Use scoped keys:
t(".title")
Example:
ruby
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def index
@articles = Articles::PublishedQuery.call
end
end
# app/views/articles/index.rb
module Views
module Articles
class Index < Views::Base
def view_template
h1 { t(".title") }
# ... rest of view
end
end
end
end
# config/routes.rb
get "articles", to: "articles#index", as: :articles
# config/locales/en/pages.yml
en:
articles:
index:
title: "Articles"
Adding a Component
- •Create
app/components/{domain}/name.rb(inherit fromComponents::Base) - •Create locale keys in
config/locales/{en,es}/components.yml - •Use full path keys:
t("components.domain.section.key") - •Include
**attrsfor Stimulus support
Example:
ruby
# app/components/articles/card.rb
module Components
module Articles
class Card < Components::Base
def initialize(article:, **attrs)
@article = article
@attrs = attrs
end
def view_template
div(class: "card", **@attrs) do
h2 { @article.title }
p { t("components.articles.card.read_more") }
end
end
end
end
end
# config/locales/en/components.yml
en:
components:
articles:
card:
read_more: "Read more"
Adding a Turbo Frame
- •Create
app/controllers/{domain}_controller.rbwith action - •Create
app/views/{domain}/action.rbwithturbo_frame_tag - •Add route:
get "path", to: "{domain}#action", as: :route_name - •In main view:
turbo_frame_tag("id", src: route_name_path, loading: :lazy) - •Add i18n translations
Example:
ruby
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def featured
@articles = Articles::FeaturedQuery.call.limit(3)
end
end
# app/views/articles/featured.rb
module Views
module Articles
class Featured < Views::Base
def view_template
turbo_frame_tag("featured_articles") do
h2 { t(".title") }
@articles.each do |article|
render Components::Articles::Card.new(article: article)
end
end
end
end
end
end
# In main page view:
turbo_frame_tag(
"featured_articles",
src: featured_articles_path,
loading: :lazy
) do
p { t(".loading") }
end
# config/routes.rb
get "articles/featured", to: "articles#featured", as: :featured_articles
Adding Stimulus Interaction
- •Create
app/javascript/controllers/{domain}/{feature}_controller.js - •Add data attributes to component:
data: { controller: "domain--feature", ... } - •Use
static targets,valuesfor data binding - •Dispatch custom events for communication
Example:
javascript
// app/javascript/controllers/articles/filter_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form", "results"]
static values = {
url: String
}
async filter(event) {
event.preventDefault()
const formData = new FormData(this.formTarget)
const params = new URLSearchParams(formData)
const response = await fetch(`${this.urlValue}?${params}`)
const html = await response.text()
this.resultsTarget.innerHTML = html
// Dispatch event for other controllers
this.dispatch("filtered", { detail: { count: results.length } })
end
}
ruby
# In component:
div(data: {
controller: "articles--filter",
articles__filter_url_value: articles_path
}) do
form(data: { articles__filter_target: "form" }) do
# form fields
end
div(data: { articles__filter_target: "results" }) do
# results
end
end