Rails Architect
Purpose
Design and implement backend features following Rails conventions with our specific stack: Rails + Panko + PostgreSQL/PostGIS + Redis + Sidekiq + Centrifugo.
Design Protocol
1. Data Model First
- Start with the PostgreSQL schema — tables, columns, types, constraints
- Use PostGIS types (
st_point, geography) for any location data
- Design indexes upfront: foreign keys, unique constraints, partial indexes, GiST for spatial
- Plan JSONB columns for flexible/polymorphic data
- Write reversible migrations with
change method
2. Model Layer
- Define associations, validations, scopes
- Use
has_many through: for join tables
- Extract complex queries to scopes or query objects
- Use
ActiveRecord::Enum for status fields
- Add database-level constraints alongside model validations
3. API Design
- RESTful controllers with standard 7 actions
- Panko serializers for every response — never render raw models
- Separate list and detail serializers for performance
- Use
pagy for pagination with cursor support
- Consistent error responses:
{ error: String, code: Integer, details: Object? }
4. Service Layer
- Extract business logic to
app/services/ (any wrapper directory — the tree below illustrates
shape, not a path to hardcode)
- Single responsibility:
CreateOrder, ProcessPayment, GeofenceCheck
- Return result objects for success/failure handling
- Inject dependencies for testability
5. Background Processing
- Jobs live in
app/jobs/ (any wrapper directory — backend/ is one team's naming, not a contract)
- Anything over ~100ms of work belongs in a job, not the request
- Separate queues:
default, critical, mailers, low_priority
- Pass IDs, not objects. A serialized record is a snapshot of a row that may have changed by
the time the job runs;
find it fresh.
- Idempotent jobs — because Sidekiq already retries 25 times over ~20 days, by default. You
did not configure that; it is the default, and "no retry policy" means three weeks of them. Set
sidekiq_options retry: deliberately on every job. If you use ActiveJob's retry_on, know that
it does not replace Sidekiq's retries — it stacks on top, so retry_on ..., attempts: 5 runs
30 attempts, not 5. A job that dies lands in the Dead set silently: alert on it.
Depth → @skills/std-error-handling/references/background-jobs.md
6. Caching Strategy
- Redis-backed
Rails.cache.fetch with explicit TTLs
- Cache Panko-serialized responses at controller level
- Fragment caching for repeated computations
- Cache invalidation on model callbacks
7. Real-time (Centrifugo)
- Design channel topology:
chat:room_123, user:456, location:fleet
- Publish from Rails via Centrifugo HTTP API
- Use Redis pub/sub for internal event distribution
- JWT-based channel authorization
Reference Architecture
backend/app/
├── controllers/
│ └── api/v1/ # Versioned API controllers
├── models/ # ActiveRecord models with validations
├── serializers/ # Panko::Serializer classes
├── services/ # Business logic service objects
├── jobs/ # Sidekiq background jobs
├── policies/ # Pundit authorization policies
├── queries/ # Complex query objects
└── channels/ # Centrifugo channel helpers
Common Patterns
PostGIS Spatial Query
# Find locations within radius
Location.where(
"ST_DWithin(coordinates, ST_MakePoint(?, ?)::geography, ?)",
longitude, latitude, radius_meters
)
Panko Serializer with Association
class OrderSerializer < Panko::Serializer
attributes :id, :status, :total_cents, :created_at
has_one :customer, serializer: CustomerListSerializer
has_many :items, serializer: OrderItemSerializer
end
Sidekiq Job with Redis Cache
class GeofenceCheckJob < ApplicationJob
queue_as :default
def perform(vehicle_id, lat, lng)
vehicle = Vehicle.find(vehicle_id)
geofences = Rails.cache.fetch("active_geofences", expires_in: 5.minutes) do
Geofence.active.to_a
end
# Check and notify...
end
end
See references/rails-patterns.md for comprehensive patterns library.
Owned elsewhere — do not duplicate
This skill designs the shape. These own the rules, are scoped to Rails work, and carry the
bad/good pairs:
- Authorization — a policy that is never called is not authorization;
index needs
policy_scope, not authorize; devise-jwt does not revoke by default →
@skills/std-rails-conventions/references/authorization.md
- Migrations, locking,
lock_timeout — a migration that waits is more dangerous than one
that fails, because every query queues behind it →
@skills/std-database/references/locking-and-timeouts.md
- Sidekiq retries, the Dead set, ActiveJob vs
Sidekiq::Job →
@skills/std-error-handling/references/background-jobs.md
- The API error envelope (
error, code, status, details, requestId — exactly one
shape) → @skills/std-api-design/references/errors-rails.md
- Pagination — cursor is the default, 25/100,
pagy →
@skills/std-api-design/references/pagination-rails.md
- Request tracing across the async boundary →
@skills/std-monitoring/references/request-tracing.md
1---2name: rails-architect3description: Design and implement Rails backend features with Panko serializers, PostgreSQL/PostGIS, Redis caching, Sidekiq jobs, and Centrifugo real-time channels. Use this skill whenever someone asks to build a backend feature, design a Rails model, create an API endpoint, architect a service, or says things like "build the backend for X", "design the model layer", "add a Sidekiq job", "implement caching for X", "create the controller", or "how should we structure this service". Also trigger when someone mentions Panko serializer design, PostGIS spatial queries, Redis cache strategy, or Centrifugo channel topology.4---56# Rails Architect78## Purpose9Design and implement backend features following Rails conventions with our specific stack: Rails + Panko + PostgreSQL/PostGIS + Redis + Sidekiq + Centrifugo.1011## Design Protocol1213### 1. Data Model First14- Start with the PostgreSQL schema — tables, columns, types, constraints15- Use PostGIS types (`st_point`, `geography`) for any location data16- Design indexes upfront: foreign keys, unique constraints, partial indexes, GiST for spatial17- Plan JSONB columns for flexible/polymorphic data18- Write reversible migrations with `change` method1920### 2. Model Layer21- Define associations, validations, scopes22- Use `has_many through:` for join tables23- Extract complex queries to scopes or query objects24- Use `ActiveRecord::Enum` for status fields25- Add database-level constraints alongside model validations2627### 3. API Design28- RESTful controllers with standard 7 actions29- Panko serializers for every response — never render raw models30- Separate list and detail serializers for performance31- Use `pagy` for pagination with cursor support32- Consistent error responses: `{ error: String, code: Integer, details: Object? }`3334### 4. Service Layer35- Extract business logic to `app/services/` (any wrapper directory — the tree below illustrates36 shape, not a path to hardcode)37- Single responsibility: `CreateOrder`, `ProcessPayment`, `GeofenceCheck`38- Return result objects for success/failure handling39- Inject dependencies for testability4041### 5. Background Processing42- Jobs live in `app/jobs/` (any wrapper directory — `backend/` is one team's naming, not a contract)43- Anything over ~100ms of work belongs in a job, not the request44- Separate queues: `default`, `critical`, `mailers`, `low_priority`45- **Pass IDs, not objects.** A serialized record is a snapshot of a row that may have changed by46 the time the job runs; `find` it fresh.47- **Idempotent jobs — because Sidekiq already retries 25 times over ~20 days, by default.** You48 did not configure that; it is the default, and "no retry policy" means three weeks of them. Set49 `sidekiq_options retry:` deliberately on every job. If you use ActiveJob's `retry_on`, know that50 it does **not** replace Sidekiq's retries — it stacks on top, so `retry_on ..., attempts: 5` runs51 30 attempts, not 5. A job that dies lands in the Dead set silently: alert on it.52 Depth → `@skills/std-error-handling/references/background-jobs.md`5354### 6. Caching Strategy55- Redis-backed `Rails.cache.fetch` with explicit TTLs56- Cache Panko-serialized responses at controller level57- Fragment caching for repeated computations58- Cache invalidation on model callbacks5960### 7. Real-time (Centrifugo)61- Design channel topology: `chat:room_123`, `user:456`, `location:fleet`62- Publish from Rails via Centrifugo HTTP API63- Use Redis pub/sub for internal event distribution64- JWT-based channel authorization6566## Reference Architecture6768```69backend/app/70├── controllers/71│ └── api/v1/ # Versioned API controllers72├── models/ # ActiveRecord models with validations73├── serializers/ # Panko::Serializer classes74├── services/ # Business logic service objects75├── jobs/ # Sidekiq background jobs76├── policies/ # Pundit authorization policies77├── queries/ # Complex query objects78└── channels/ # Centrifugo channel helpers79```8081## Common Patterns8283### PostGIS Spatial Query84```ruby85# Find locations within radius86Location.where(87 "ST_DWithin(coordinates, ST_MakePoint(?, ?)::geography, ?)",88 longitude, latitude, radius_meters89)90```9192### Panko Serializer with Association93```ruby94class OrderSerializer < Panko::Serializer95 attributes :id, :status, :total_cents, :created_at96 has_one :customer, serializer: CustomerListSerializer97 has_many :items, serializer: OrderItemSerializer98end99```100101### Sidekiq Job with Redis Cache102```ruby103class GeofenceCheckJob < ApplicationJob104 queue_as :default105106 def perform(vehicle_id, lat, lng)107 vehicle = Vehicle.find(vehicle_id)108 geofences = Rails.cache.fetch("active_geofences", expires_in: 5.minutes) do109 Geofence.active.to_a110 end111 # Check and notify...112 end113end114```115116See references/rails-patterns.md for comprehensive patterns library.117118## Owned elsewhere — do not duplicate119120This skill designs the shape. These own the rules, are scoped to Rails work, and carry the121bad/good pairs:122123- **Authorization** — a policy that is never called is not authorization; `index` needs124 `policy_scope`, not `authorize`; `devise-jwt` does not revoke by default →125 `@skills/std-rails-conventions/references/authorization.md`126- **Migrations, locking, `lock_timeout`** — a migration that *waits* is more dangerous than one127 that fails, because every query queues behind it →128 `@skills/std-database/references/locking-and-timeouts.md`129- **Sidekiq retries, the Dead set, ActiveJob vs `Sidekiq::Job`** →130 `@skills/std-error-handling/references/background-jobs.md`131- **The API error envelope** (`error`, `code`, `status`, `details`, `requestId` — exactly one132 shape) → `@skills/std-api-design/references/errors-rails.md`133- **Pagination** — cursor is the default, 25/100, `pagy` →134 `@skills/std-api-design/references/pagination-rails.md`135- **Request tracing across the async boundary** →136 `@skills/std-monitoring/references/request-tracing.md`