Architectural boundary enforcement for Rails apps with agent-readable remediation messages.
Part of the harness engineering toolkit -- custom RuboCop cops that enforce Rails conventions and communicate fixes to both humans and AI coding agents.
Standard RuboCop tells you what's wrong. rubocop-harness tells your AI agent how to fix it:
# Standard RuboCop
Method has too many lines. [15/10]
# rubocop-harness
[Harness] `create` is 15 lines (max 10). Extract logic into a service
object in app/services/.
Every offense message includes:
- What's wrong
- Where to put the fix
- Which pattern to use
Your linter becomes a communication channel to the robot.
Add to your Gemfile:
group :development, :test do
gem "rubocop-harness", github: "TelosLabs/rubocop-harness", require: false
endThen in .rubocop.yml:
plugins:
- rubocop-harness| Cop | Default | Description |
|---|---|---|
Harness/ControllerMethodLength |
Max: 10 | Controllers should be thin. Extract long methods into service objects. |
Harness/ModelMethodLength |
Max: 7 | Models own domain logic, but methods should be small. Decompose into smaller methods, concerns, or value objects. |
Harness/RestfulActions |
Enabled | Controllers should only define the 7 RESTful actions (index, show, new, create, edit, update, destroy). |
Harness/ServiceInterface |
Enabled | Service objects must define a call or save instance method. |
Harness/NoQueriesInControllers |
Enabled | No ActiveRecord query methods in controllers. Move to model scopes or query objects. find and find_by allowed by default. |
Harness/JobMethodLength |
Max: 7 | Jobs should be thin. Delegate to a service object. |
Harness/NoPresentationInModels |
Enabled | No presentation logic (HTML, route helpers) in models. Move to presenters or view helpers. |
Harness/NoBareRescue |
Enabled | No blanket rescue (rescue, rescue => e, rescue StandardError) as the sole handler. Rescue a specific error class. Blanket backstops after a specific clause are allowed. |
All cops are enabled by default with warning severity.
Override any cop in your .rubocop.yml:
# Adjust method length limits
Harness/ControllerMethodLength:
Max: 15
Harness/ModelMethodLength:
Max: 10
# Allow specific non-RESTful actions
Harness/RestfulActions:
AllowedMethods:
- search
- export
# Require only `call` (not `save`)
Harness/ServiceInterface:
RequiredMethods:
- call
# Also flag find and find_by
Harness/NoQueriesInControllers:
AllowedMethods: []Enforces a maximum method length in app/controllers/. Skips concerns.
# bad - 11+ lines
def create
@user = User.new(user_params)
@user.role = determine_role(params[:role])
@user.team = Team.find(params[:team_id])
# ... 8 more lines
end
# good - delegate to a service
def create
result = CreateUserService.new(user_params).call
respond_with result
endEnforces a maximum method length in app/models/. Default max is 7 (stricter than controllers because model methods should be small and focused). Models own domain logic, but long methods should be decomposed into smaller private methods, concerns, or value objects.
Flags any public method in a controller that isn't one of the 7 RESTful actions.
# bad
class UsersController < ApplicationController
def archive # not RESTful
end
end
# good - extract to a new controller
class Users::ArchivesController < ApplicationController
def create
end
endPrivate and protected methods are ignored -- helper methods in controllers are fine.
Services in app/services/ must define call or save:
# bad
class OrderProcessor < ApplicationService
def process # non-standard interface
end
end
# good - Pattern A
class OrderProcessor < ApplicationService
def call
# returns data
end
end
# good - Pattern B
class OrderCreator < ApplicationService
def save
# persists data, returns true/false
end
endFlags ActiveRecord query methods (where, joins, includes, order, pluck, destroy_all, etc.) in controller files. Controllers are the presentation layer — query construction belongs in the domain layer (model scopes) or application layer (services).
# bad — query building in the controller
def index
@users = User.where(active: true).order(:name)
end
# good — model scope (preferred for simple filtering/ordering)
def index
@users = User.active.ordered_by_name
end
# good — service object (for multi-model orchestration or side effects)
def index
@users = ListUsersService.new(filters: params[:filters]).call
endfind and find_by are allowed by default since @resource = Model.find(params[:id]) is a standard Rails pattern. Set AllowedMethods: [] to flag them too.
Enforces a maximum method length in app/jobs/. Jobs should delegate to services, not contain business logic.
# bad - business logic in the job
def perform(user_id)
user = User.find(user_id)
user.update!(onboarded: true)
WelcomeMailer.with(user: user).welcome.deliver_later
Analytics.track("user_onboarded", user_id: user.id)
user.projects.create!(name: "My First Project")
Slack::NotifyChannel.call("#signups", "New user: #{user.email}")
AuditLog.record(:onboard, user: user)
user.update!(onboarded_at: Time.current)
end
# good - delegate to a service
def perform(user_id)
user = User.find(user_id)
OnboardUserService.new(user).call
endFlags presentation logic in models — HTML generation, route helpers, and ActionController helpers belong in presenters, decorators, or view helpers, not in the domain layer.
# bad - HTML in the model
def badge
content_tag(:span, role_name, class: "badge")
end
# bad - route helpers in the model
class User < ApplicationRecord
include Rails.application.routes.url_helpers
end
# good - use a presenter
class UserPresenter
def badge
content_tag(:span, user.role_name, class: "badge")
end
endFlags a blanket rescue that is the only handler for a block. A class-less rescue / rescue => e, or an explicit rescue StandardError/rescue Exception, catches the entire error tree and hides the specific failure the code should handle.
# bad - sole blanket handler, hides what actually failed
def load_dashboard_experiment
load_challenge_tile
rescue => e
Rollbar.error(e)
@dashboard_assignment = nil
end
# good - rescue the specific error you expect
def load_dashboard_experiment
load_challenge_tile
rescue Dashboards::ExperimentError => e
Rollbar.error(e)
@dashboard_assignment = nil
endA blanket rescue is allowed as a backstop after a specific clause: the specific clause handles known failures meaningfully, the blanket clause reports the unexpected ones.
# good - specific handling plus a reporting backstop
def call_api
client.get(url)
rescue Faraday::Error => e
retry_later(e)
rescue => e
Rollbar.error(e)
endRuns on app/**/*.rb and lib/**/*.rb by default. Top-level boundaries that legitimately need a lone rescue StandardError (some jobs, rake tasks, middleware) should use an inline # rubocop:disable Harness/NoBareRescue or an Exclude glob, making the blanket rescue a deliberate, visible choice.
These cops enforce layered architecture boundaries, not style preferences. Inspired by Layered Design for Ruby on Rails Applications (Dementyev, 2023), they ensure code stays in the right layer:
- Presentation layer (controllers/views): request handling only — no query building, no business logic
- Application layer (services): orchestration across models, external integrations, side effects
- Domain layer (models): business rules, validations, scopes, state transitions
These cops fill gaps that rubocop-shopify and rubocop-rails don't cover:
- rubocop-shopify disables all Metrics cops. rubocop-harness applies different limits per architectural layer.
- rubocop-rails enforces action ordering (
Rails/ActionOrder). rubocop-harness enforces that only RESTful actions exist. - Neither enforces service object interfaces, query-free controllers, or layer-specific method size limits.
The cop messages are deliberately written for AI agents to parse and act on. When an agent sees [Harness] ... Move query logic to a model scope or query object., it knows exactly what to do without additional context.
bundle install
bundle exec rspecMIT