tacoda / reinsMIT
A Rack-based Ruby web framework with the surface of Rails, built around a Cockburn-strict hexagonal architecture internally. Routing, controllers, views, an ORM, migrations, generators, middleware, environments, autoloading, and an RSpec test framework ship in the box. Every piece of infrastructure sits behind a swappable adapter, so app authors get a Rails-shaped surface and contributors get a clean port seam.
Overview
A Rails-shaped surface over a strict hexagon.
Reins gives app authors the surface they already know. Reins::Controller, Reins::Model::Base, and route { resources :foo } behave the way the names suggest. Underneath, the framework is a Cockburn-strict hexagon: a pure core, explicit ports, and swappable adapters.
You do not have to think about the hexagon to build an app. That architecture starts to matter when you want to test a layer in isolation. You feel it again when you swap an adapter, or when you add a port for a domain capability of your own.
Install
Reins requires Ruby 3.3 or newer.
On RubyGems the framework goes by reins-web, because another gem already holds the name reins. Add it to your Gemfile:
# Gemfile
gem "reins-web"
bundle install
To get the reins command on its own, install the gem directly:
gem install reins-web
Quickstart
A new app in 30 seconds.
reins new blog
cd blog
bin/setup
reins generate scaffold Post title:string body:text
reins db:migrate
reins server
Open http://localhost:8000/posts and you have working CRUD.
The slim profile
Pass --slim when you want every adapter slot left as an explicit placeholder. Use that profile if you plan to wire your own adapters from scratch.
reins new blog --slim
The full walkthrough is further down this page. Start at build a blog with Reins.
Core features
Features built to put you in control.
-
Rack-based
Built on Rack, the community standard interface between Ruby web servers and frameworks.
-
Convention over configuration
Models, controllers, and views load automatically. The autoloader is Zeitwerk, behind an Autoloader port.
-
Built-in ORM
Validations, associations, and a chainable
Relationwithwhere,order, andlimit. -
Generators
reins generatewrites models, controllers, scaffolds, migrations, ports, adapters, and use cases. -
Migrations
db:create,db:migrate,db:rollback, anddb:schema:dumpship with the CLI. -
RSpec test framework
type: :modelwraps each example in a transaction.type: :controllerbrings in Rack::Test and custom matchers.
Architecture
A pure core, explicit ports, swappable adapters.
HttpApp or CommandInvoker. Both ports reach the same pure Ruby core. The core never calls an adapter directly. It calls a driven port instead, so Repository reaches SQLite, TemplateEngine reaches Erubis, and Server reaches Puma. Swap the adapter on the far side and the core does not change.Why hexagonal
Alistair Cockburn named the pattern in 2005 to describe one recurring problem: applications get strangled by their I/O. The HTTP framework, the database, the template engine, and the queue each end up touching every layer. So a change to "use a different database" becomes a change everywhere.
Hexagonal flips that relationship. The application is one thing, and every piece of I/O is an adapter the application can swap. Driving adapters call into the app: HTTP, a CLI, a message queue. Driven adapters are what the app calls out to: the database, the file system, a third-party API. Between the two sit the ports, which are the application's interface contracts.
What "Cockburn-strict" means
Three rules are non-negotiable:
- The core has no knowledge of the outside world. It imports no infrastructure library, and it names no
Rack,SQLite3, orPumaconstant. - Dependencies point inward. Adapters depend on ports, ports depend on nothing, and the core depends on the ports it consumes.
- Anything crossing a port is a plain value object, not an infrastructure type.
The payoff is testability. The entire core runs without booting Rack, opening a database, or touching disk. Swapping SQLite for Postgres becomes "write a new adapter against the existing Repository port." Adding a capability becomes "define a port, write the adapter."
The three layers
- Core (
lib/reins/core/**) is pure domain. It knows nothing about Rack, SQLite, Erubis, Puma, Thor, or the filesystem. A spec enforces the boundary (spec/reins/core_boundary_spec.rb), so the core cannot evenrequirethose libraries. - Ports (
lib/reins/ports/{driving,driven}/**) are Ruby modules with a frozenCONTRACThash. That hash lists the methods every adapter must implement. - Adapters (
lib/reins/adapters/{driving,driven}/**) are the concrete implementations.Adapters::Driving::Rack::Appis the Rack-facing entry point.Adapters::Driven::Sqlite::Repositoryis the default persistence adapter, andAdapters::Driven::Memory::Repositoryis the in-memory one used by tests.
The ports that ship
Two driving ports carry the outside world into the core:
HttpAppCommandInvoker
Eleven driven ports carry the core back out:
Repository,SchemaInspector,SchemaMigratorTemplateStore,TemplateEngineFileSystem,ProcessRunner,ServerEnvReader,Clock,Autoloader
Profiles
Reins::Application picks a profile at boot. A profile is a named bundle of default adapters.
| Profile | Repository | Server | Template | Clock | EnvReader |
|---|---|---|---|---|---|
:standard (default) | SQLite | Puma | Erubis + Filesystem | System | System |
:test | Memory | — | Erubis + Filesystem | Fixed | Memory |
:slim | nil | nil | nil | nil | nil |
reins new myapp uses :standard. reins new myapp --slim uses :slim, so every adapter slot is visible and nil in your config/application.rb. You fill them in yourself.
Add your own port and adapter
App authors can extend the hexagon. To integrate a payment provider, generate the port and an adapter for it:
reins generate port payment_gateway
reins generate adapter stripe --port=payment_gateway
The port file declares its direction and contract through a small DSL:
require "reins/port"
module PaymentGateway
extend Reins::Port
direction :driven
contract charge: 3, # (amount, currency, source_id)
refund: 1 # (charge_id)
end
The extend Reins::Port line is the visible signal that this module is a port. direction and contract set up the DIRECTION and CONTRACT constants, then register the port in Reins::Port.all.
The adapter includes the port and implements every method. A test adapter follows the same pattern with an in-memory store. Wire the adapter at the composition root, in config/application.rb:
class Blog::Application < Reins::Application
profile :standard
adapters do |a|
a.payment_gateway = MyApp::Adapters::Stripe.new(api_key: ENV.fetch("STRIPE_KEY"))
end
end
Port presets
The framework's own ports ship as presets. Reach for one when you want to swap a port wholesale, or when you want to read the pattern.
reins generate port --rack # HTTP driving port + Rack adapter
reins generate port --sqlite # Repository + SchemaInspector + SchemaMigrator
reins generate port --puma # Server port + Puma adapter
reins generate port --memory # in-memory test adapters
reins generate port --list # print every preset
Test your adapter
Every adapter spec asserts the port contract:
it "responds to every method on the PaymentGateway port contract" do
PaymentGateway::CONTRACT.each_key do |name|
expect(adapter).to respond_to(name), "missing #{name}"
end
end
That is the contract test. Beyond it, write the behavior specs you would write for any class: feed input, check output.
CLI reference
Every command the reins executable ships with.
reins new <name> [--slim] # scaffold a runnable project
reins server # boot Puma on port 8000
reins routes # print the route table
reins console # IRB with the app loaded
reins generate controller Posts index show
reins generate model Post title:string body:text
reins generate scaffold Post title:string body:text
reins generate migration AddPublishedAtToPosts published_at:datetime
reins generate port NAME [--driving | --driven] # new port module
reins generate adapter NAME --port=PORT # new adapter for a port
reins generate port --PRESET # rack | sqlite | thor | puma | ...
reins generate port --list # show every preset
reins generate test PORT_NAME # spy + use-case spec for a port
reins generate use_case NAME [dep ...] # application service object
reins generate config [--slim] # write the default config block
reins db:create / db:drop / db:migrate / db:rollback / db:schema:dump
reins test # runs `bundle exec rspec`
Build a blog with Reins
A getting-started guide that exercises every layer.
This guide builds a small blog from scratch. By the end you will have created posts, listed them, validated them, and added comments. If you have used Rails, the shape will feel familiar, and each step flags the differences as they come up.
Each step is short. Run the commands in order, because later steps depend on files that earlier steps write.
Prerequisites
- Ruby 3.3 or newer. Check with
ruby -v. - Bundler. Run
gem install bundlerif you need it. - The
reinsCLI, which arrives with thereins-webgem.
gem install reins-web
reins -h
1. Create the application
reins new blog
cd blog
bin/setup
reins new writes the project skeleton. bin/setup then runs bundle install and creates the development database. Here is the tree it produces:
blog/
├── Gemfile # reins-web, puma, sqlite3, erubis, zeitwerk, rackup, plus rspec and rerun
├── config.ru # Rack entry point
├── bin/{reins,setup,console}
├── config/
│ ├── application.rb # Blog::Application < Reins::Application
│ ├── routes.rb # Reins.application.route do ... end
│ ├── database.yml # one section per env
│ └── environments/{development,test,production}.rb
├── app/
│ ├── controllers/{application_controller,welcome_controller}.rb
│ ├── models/application_record.rb
│ └── views/
│ ├── layouts/application.html.erb
│ └── welcome/index.html.erb
├── db/migrate/
├── public/{404,422,500}.html
└── spec/spec_helper.rb
Boot it:
reins server
Visit http://localhost:8000. You will see "It works!", served from app/views/welcome/index.html.erb.
2. Say hello
WelcomeController#index renders the welcome page. Open app/views/welcome/index.html.erb and edit it:
<h1>Welcome to my blog</h1>
<p>Built with <a href="https://rubygems.org/gems/reins-web">Reins</a>.</p>
To see the change, stop the server with Ctrl-C and run reins server again. For automatic reloading during development, boot the server through rerun instead:
bundle exec rerun reins server
reins routes prints the route table:
Prefix Verb URI Pattern Controller#Action
root GET / welcome#index
3. Generate a Post resource
A blog needs posts. Generate the scaffold:
reins generate scaffold Post title:string body:text
That command creates:
app/models/post.rb, holdingclass Post < ApplicationRecorddb/migrate/<timestamp>_create_posts.rb, the table definitionapp/controllers/posts_controller.rb, with full CRUD: index, show, new, create, edit, update, destroyapp/views/posts/{index,show,new,edit}.html.erbplus_form.html.erbspec/models/post_spec.rb, a spec stub- A
resources :postsline appended toconfig/routes.rb
Apply the migration:
reins db:migrate
Boot the server again and visit http://localhost:8000/posts. You can create, list, edit, and delete posts. reins routes now includes the seven RESTful actions:
Prefix Verb URI Pattern Controller#Action
root GET / welcome#index
posts GET /posts posts#index
new_post GET /posts/new posts#new
POST /posts posts#create
post GET /posts/:id posts#show
edit_post GET /posts/:id/edit posts#edit
PUT /posts/:id posts#update
PATCH /posts/:id posts#update
DELETE /posts/:id posts#destroy
The named-route helpers are available in views and controllers: posts_path, post_path(id), new_post_path, and edit_post_path(id).
4. Add validations
Open app/models/post.rb:
class Post < ApplicationRecord
validates :title, presence: true, length: { in: 1..100 }
validates :body, presence: true
end
Visit /posts/new and submit an empty form. The scaffold renders the form again, but it does not show the errors yet. Edit app/views/posts/_form.html.erb to display them:
<%= form_with(url: "/posts", method: record.persisted? ? :put : :post) %>
<% if record.errors.full_messages.any? %>
<ul class="errors">
<% record.errors.full_messages.each do |msg| %>
<li><%== msg %></li>
<% end %>
</ul>
<% end %>
<div>
<%= label :title %><br>
<%= text_field :title, value: record.title %>
</div>
<div>
<%= label :body %><br>
<%= text_area :body, value: record.body %>
</div>
<div><%= submit %></div>
</form>
Invalid submissions now show their errors inline. The scaffold's create action already returns 422 on validation failure, so this edit only changes what the user sees.
5. Customize the index view
Open app/views/posts/index.html.erb. The scaffold writes a bare table, so replace it with something more useful:
<h1>All posts</h1>
<p><%= link_to "New post", new_post_path, class: "btn" %></p>
<% @records.each do |post| %>
<article>
<h2><%= link_to post.title, post_path(post.id) %></h2>
<p><%= post.body %></p>
</article>
<% end %>
link_to, new_post_path, and post_path are all built-in helpers.
6. Use Reins.logger
The framework writes to log/<env>.log at the configured level. Add a log line to the create action in app/controllers/posts_controller.rb:
def create
@record = Post.new(record_params)
if @record.save
Reins.logger.info("Created post #{@record.id} - #{@record.title.inspect}")
redirect_to "/posts/#{@record.id}"
else
render :new, status: :unprocessable_entity
end
end
Tail the log:
tail -f log/development.log
7. Add comments
Posts need comments. Generate a model and its migration:
reins generate model Comment post_id:integer body:text
reins db:migrate
Wire up the association in app/models/post.rb:
class Post < ApplicationRecord
has_many :comments, foreign_key: "post_id"
validates :title, presence: true, length: { in: 1..100 }
validates :body, presence: true
end
And in app/models/comment.rb:
class Comment < ApplicationRecord
belongs_to :post
validates :body, presence: true
end
Now render the comments in app/views/posts/show.html.erb:
<h1><%= @record.title %></h1>
<p><%= @record.body %></p>
<h2>Comments</h2>
<% @record.comments.each do |comment| %>
<article>
<p><%= comment.body %></p>
</article>
<% end %>
@record.comments returns a Reins::Model::Relation, so you can chain calls on it: @record.comments.order(created_at: :desc).limit(5).
To let readers post comments, add a route in config/routes.rb:
Reins.application.route do
root "welcome#index"
resources :posts
post "/posts/:post_id/comments", "comments#create", as: :post_comments
end
Generate a CommentsController:
reins generate controller Comments create
Then edit app/controllers/comments_controller.rb:
class CommentsController < ApplicationController
def create
post = Post.find(params[:post_id])
comment = Comment.new(body: params[:body], post_id: post.id)
if comment.save
redirect_to post_path(post.id)
else
render plain: "Comment invalid: #{comment.errors.full_messages.join(', ')}",
status: :unprocessable_entity
end
end
end
Add a comment form to app/views/posts/show.html.erb:
<h2>Add a comment</h2>
<form action="<%= post_comments_path(post_id: @record.id) %>" method="post">
<%= text_field :body %>
<%= submit "Post comment" %>
</form>
Visit a post, write a comment, and submit it. The comment appears.
8. Test the model
The model generator wrote a stub at spec/models/post_spec.rb. Open it and add real specs:
require "spec_helper"
RSpec.describe Post, type: :model do
it "requires a title" do
expect(Post.new(title: nil, body: "x")).not_to be_valid
end
it "saves a valid post" do
post = Post.new(title: "Hello", body: "World")
expect(post.save).to be(true)
expect(Post.count).to eq(1)
end
end
The type: :model metadata wraps each example in a database transaction, and that transaction rolls back at the end. So tests do not leak state into each other. Run them:
reins test
Or run RSpec directly:
bundle exec rspec
9. Test the controller
# spec/controllers/posts_controller_spec.rb
require "spec_helper"
RSpec.describe PostsController, type: :controller do
let(:app) { Rack::Builder.parse_file("config.ru") }
it "GET /posts returns 200" do
get "/posts"
expect(last_response).to have_http_status(:ok)
end
it "POST /posts with valid params redirects to the new post" do
post "/posts", post: { title: "Hi", body: "There" }
expect(last_response).to redirect_to("/posts/1")
end
end
The type: :controller metadata includes Rack::Test::Methods and the custom matchers, have_http_status and redirect_to.
10. Deploy notes
For production, set REINS_ENV on each command:
REINS_ENV=production reins db:create
REINS_ENV=production reins db:migrate
REINS_ENV=production reins server
config/environments/production.rb already sets eager_load = true, so the autoloader requires every file at boot and no per-request Module#autoload happens. That file also sets log_level = :info. Add or remove middleware in the same file.
What you used
Those ten steps exercise every layer of Reins:
- Routing:
root,resources, and named verb routes - Controllers: filters,
render,redirect_to,params,flash - Views: layouts, partials, helpers, auto-escape
- Models: validations, associations, the chainable
Relation - Migrations: scaffolded, then
db:migrateanddb:rollback - Generators:
new,generate scaffold,generate model,generate controller - Environments and autoloading: Zeitwerk-backed,
Reins.env,Reins.config - Testing:
type: :model,type: :controller, custom matchers
To go deeper, read the source. lib/reins/ is intentionally small, one file per concern, and the specs in spec/reins/ double as runnable examples.
Common errors
- Reins::DoubleResponse
- Your action called
render,redirect_to, orheadtwice. - Reins::MissingTemplate
- Auto-render could not find the template. Check
app/views/<controller>/<action>.html.erb. - Reins::ParameterMissing
params.require(...)got a nil or empty value.- Reins::SessionMiddlewareMissing
- You used
sessionorflashwithout mountingRack::Session::Cookie. - Reins::AdapterMissing
- A port has no adapter wired at the composition root. Set the slot in
config/application.rb. - Reins::ContractViolation
- An adapter does not implement every method in its port's
CONTRACT. - Reins::Model::RecordNotFound
Model.find(id)could not find a row. Usefind_byfor the nil-on-miss form.- Reins::Model::RecordInvalid
save!orcreate!failed validation. The exception carries the record.- Reins::IrreversibleMigration
changeused an operation that cannot be auto-inverted. Add an explicitdown.
Where to go next
Keep digging.
-
README.md
Top-level overview and the CLI reference.
-
GUIDE.md
The same walkthrough, plus the contributor view of the architecture.
-
CHANGELOG.md
What landed in each milestone.
-
Source
lib/reins/is small enough to read end to end in an afternoon.