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.

  • ruby
  • rack
  • web-framework
  • hexagonal-architecture
  • ports-and-adapters
  • orm
  • rspec
Ruby 3.3+ Version 2.0.1 License MIT GitHub RubyGems Getting started guide

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 Relation with where, order, and limit.

  • Generators

    reins generate writes models, controllers, scaffolds, migrations, ports, adapters, and use cases.

  • Migrations

    db:create, db:migrate, db:rollback, and db:schema:dump ship with the CLI.

  • RSpec test framework

    type: :model wraps each example in a transaction. type: :controller brings in Rack::Test and custom matchers.

Architecture

A pure core, explicit ports, swappable adapters.

The Reins hexagonRack and Thor are driving adapters. They call the HttpApp and CommandInvoker ports, which reach the pure Ruby core. The core calls out through the Repository, TemplateEngine and Server ports to the SQLite, Erubis and Puma driven adapters.driven adaptersapplication coredriving adaptersRackThorHttpAppCommandInvokerpure RubyRepositoryTemplateEngineServerSQLiteErubisPuma
Calls enter from the left and leave on the right. Rack and Thor are the driving adapters, and each one calls a driving port: 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:

  1. The core has no knowledge of the outside world. It imports no infrastructure library, and it names no Rack, SQLite3, or Puma constant.
  2. Dependencies point inward. Adapters depend on ports, ports depend on nothing, and the core depends on the ports it consumes.
  3. 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 even require those libraries.
  • Ports (lib/reins/ports/{driving,driven}/**) are Ruby modules with a frozen CONTRACT hash. That hash lists the methods every adapter must implement.
  • Adapters (lib/reins/adapters/{driving,driven}/**) are the concrete implementations. Adapters::Driving::Rack::App is the Rack-facing entry point. Adapters::Driven::Sqlite::Repository is the default persistence adapter, and Adapters::Driven::Memory::Repository is the in-memory one used by tests.

The ports that ship

Two driving ports carry the outside world into the core:

  • HttpApp
  • CommandInvoker

Eleven driven ports carry the core back out:

  • Repository, SchemaInspector, SchemaMigrator
  • TemplateStore, TemplateEngine
  • FileSystem, ProcessRunner, Server
  • EnvReader, Clock, Autoloader

Profiles

Reins::Application picks a profile at boot. A profile is a named bundle of default adapters.

Default adapter per port, by profile.
ProfileRepositoryServerTemplateClockEnvReader
:standard (default)SQLitePumaErubis + FilesystemSystemSystem
:testMemoryErubis + FilesystemFixedMemory
:slimnilnilnilnilnil

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 bundler if you need it.
  • The reins CLI, which arrives with the reins-web gem.
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, holding class Post < ApplicationRecord
  • db/migrate/<timestamp>_create_posts.rb, the table definition
  • app/controllers/posts_controller.rb, with full CRUD: index, show, new, create, edit, update, destroy
  • app/views/posts/{index,show,new,edit}.html.erb plus _form.html.erb
  • spec/models/post_spec.rb, a spec stub
  • A resources :posts line appended to config/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:migrate and db: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, or head twice.
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 session or flash without mounting Rack::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. Use find_by for the nil-on-miss form.
Reins::Model::RecordInvalid
save! or create! failed validation. The exception carries the record.
Reins::IrreversibleMigration
change used an operation that cannot be auto-inverted. Add an explicit down.

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.

Reins carries the MIT license. Ian Johnson writes and maintains it.