Kirei
Kirei is a strictly typed Ruby micro/REST-framework for building scalable and performant APIs. It is built from the ground up to be clean and easy to use. Kirei is based on Sequel as an ORM, Sorbet for typing, and Rack as web server interface. It strives to have zero magic and to be as explicit as possible.
Kirei's main advantages over other frameworks are its strict typing, low memory footprint, and built-in high-performance logging and pluggable metric-tracking toolkit. It is opinionated in terms of tooling, allowing you to focus on your core-business. It is a great choice for building APIs that need to scale.
Kirei (きれい) is a Japanese adjective that primarily means "beautiful" or "pretty." It can also be used to describe something that is "clean" or "neat."
👉 AI-generated wiki available on
Why another Ruby framework?
TL;DR:
- zero magic
- strict typing
- very few low level dependencies
- low memory footprint
- high performance
- simple to understand
Versioning
This gem follows SemVer, however only after a stable release 1.0.0 is made.
A changelog is maintained via the GitHub Releases page.
Installation
Via rubygems:
gem 'kirei'Test the latest version via git:
gem 'kirei', git: 'git@github.com:swiknaba/kirei', branch: :mainUsage
Initial Set Up
Scaffold a new project:
bundle exec kirei new "MyApp"Quick Start
Find a test app in the spec/test_app directory. It is a fully functional example of a Kirei app.
Models
All models must inherit from T::Struct and include Kirei::Model. They must implement id which must hold the primary key of the table. The primary key must be named id and be a String. Kirei generates it on create with generate_human_id (for example user_K7mQ2xRtB9vN) unless the caller passes one; database sequences are not supported. Override human_id_length or human_id_prefix per model to change the format.
Kirei models are immutable by convention - all properties are defined using const and updating a record returns a new instance rather than mutating the original. This immutability, combined with strict typing, makes them naturally suitable for both traditional data-centric applications and domain-driven design approaches.
In a domain-driven design, Kirei::Model serves as the persistence layer, while domain concepts are expressed through Entity and ValueObject. The domain layer might combine or transform data from multiple models to match the domain's understanding, keeping the internal data structure separate from the public interface.
class User < T::Struct
extend T::Sig
include Kirei::Model
const :id, String
const :name, String
end
user = User.find_by({ name: 'John' }) # T.nilable(User)
users = User.where({ name: 'John' }) # T::Array[User]Updating a record returns a new instance. The original instance is not mutated:
updated_user = user.update({ name: 'Johnny' })
user.name # => 'John'
updated_user.name # => 'Johnny'Delete keeps the original object intact. Returns true if the record was deleted. Calling delete multiple times will return false after the first (successful) call.
success = user.delete # => T::Boolean
# or delete by any query:
User.query.where('...').delete # => Integer, number of deleted recordsTo build more complex queries, Sequel can be used directly:
query = User.query.where({ name: 'John' })
query = query.where('...') # "query" is a 'Sequel::Dataset' that you can chain as you like
query = query.limit(10)
users = User.resolve(query) # T::Array[User]
first_user = User.resolve_first(query) # T.nilable(User)
# you can also cast the raw result manually
first_user = User.from_hash(query.first.stringify_keys)Domain Objects
Kirei provides support for Domain-Driven Design patterns through Kirei::Domain::Entity and Kirei::Domain::ValueObject. Here's how to use them:
# An Entity is identified by its ID
class Flight < T::Struct
include Kirei::Domain::Entity
const :id, String
const :flight_number, String
const :departure_airport_id, String
const :arrival_airport_id, String
const :scheduled_departure_at, Time
const :status, String
sig { returns(T::Boolean) }
def can_board?
Time.now.utc <= scheduled_departure_at && status == 'on_time'
end
end
# A Value Object is identified by its attributes
# I.e. two Coordinates with the same lat/long will be equal
# regardless of object identity
class Coordinates < T::Struct
include Kirei::Domain::ValueObject
const :latitude, Float
const :longitude, Float
sig { returns(String) }
def to_s
"#{latitude},#{longitude}"
end
sig { params(other_coords: Coordinates).returns(Float) }
def distance_to(other_coords)
# Implement e.g. Haversine here.
end
end
# Usage example
coords = Coordinates.new(latitude: 37.6188, longitude: -122.3750)
coords2 = Coordinates.new(latitude: 37.6188, longitude: -122.3750)
coords == coords2 # true
flight = Flight.new(id: 123, flight_number: 'UA123', status: 'on_time', scheduled_departure_at: Time.now.utc)
flight2 = Flight.new(id: 123, flight_number: 'UA123', status: 'delayed', scheduled_departure_at: Time.now.utc)
flight == flight2 # true - same entity even with different statusDatabase Migrations
Read the Sequel Migrations documentation for detailed information.
Sequel.migration do
up do
create_table(:airports) do
primary_key :id
String :name, null: false
end
end
down do
drop_table(:airports)
end
endApplying migrations:
# create the database
bundle exec rake db:create
# drop the database
bundle exec rake db:drop
# apply all pending migrations
bundle exec rake db:migrate
# annotate the models with the schema
# this runs automatically after each migration
bundle exec rake db:annotate
# roll back the last n migration
STEPS=1 bundle exec rake db:rollback
# run db/seeds.rb to seed the database
bundle exec rake db:seed
# scaffold a new migration file
bundle exec rake 'db:migration[CreateAirports]'Routing
Define routes anywhere in your app; by convention, they are defined in config/routes.rb:
# config/routes.rb
module Kirei::Routing
Router.add_routes(
[
Route.new(
verb: Verb::GET,
path: "/livez",
controller: Controllers::Health,
action: "livez",
),
Route.new(
verb: Verb::GET,
path: "/airports",
controller: Controllers::Airports,
action: "index",
),
Route.new(
verb: Verb::GET,
path: "/airports/:iata",
controller: Controllers::Airports,
action: "show",
),
],
)
endControllers
Controllers can be defined anywhere; by convention, they are defined in the app/controllers directory.
Three render helpers are available:
| Method | Use case |
|---|---|
render(body, status:, headers:) |
Raw string responses (plain text, pre-serialized data) |
render_json(data, status:, headers:) |
JSON responses with automatic serialization |
render_error(errors, status:, headers:) |
JSON:API-compliant error responses |
render_json accepts multiple data types:
data type |
Behavior |
|---|---|
String |
Pass-through (assumed to be pre-serialized JSON) |
Hash / Array
|
Serialized via Oj.dump
|
Object responding to #serialize (e.g. T::Struct) |
Calls .serialize, then Oj.dump if the result is not a String |
| Anything else | Raises ArgumentError
|
module Controllers
class Airports < Kirei::Controller
extend T::Sig
sig { returns(T.anything) }
def index
search = T.let(params.fetch("q", nil), T.nilable(String))
service = Kirei::Services::Runner.call("Airports::Filter") do
Airports::Filter.call(search)
end
return render_error(service.errors, status: 400) if service.failed?
render_json(service.result.map(&:serialize))
end
sig { returns(T.anything) }
def show
iata = T.must(params.fetch("iata", nil)) # named param from dynamic route
airport = Kirei::Services::Runner.call("Airports::Find") do
Airports::Find.call(iata) # T.nilable(Airport)
end
return render(status: 204) if airport.nil?
render_json(airport) # T::Struct — calls .serialize automatically
end
end
endServices can be PORO. You can wrap an execution in Kirei::Services::Runner which will emit a standardized logline and track its execution time.
module Airports
class Filter
extend T::Sig
sig do
params(
search: T.nilable(String),
).returns(T::Array[Airport])
end
def self.call(search)
return Airport.all if search.nil?
#
# SELECT *
# FROM "airports"
# WHERE (("name" ILIKE 'xx%') OR ("id" ILIKE 'xx%'))
#
query = Airport.query.where(Sequel.ilike(:name, "#{search}%"))
query = query.or(Sequel.ilike(:id, "#{search}%"))
Airport.resolve(query)
end
end
endMetrics
Kirei ships with a pluggable metrics interface via Kirei::Metrics::Backend. Three backends are included:
| Backend | Description |
|---|---|
LoggingBackend |
Default. Prints metrics to stdout via puts — great for local development and small MVPs. |
StatsdBackend |
Wraps statsd-instrument. Add gem 'statsd-instrument' to your Gemfile. |
NullBackend |
No-op — silently discards all metrics. |
The backend exposes three methods: increment, measure, and gauge.
Configure the backend in your app:
class MyApp < Kirei::App
# Use StatsD (requires `gem 'statsd-instrument'` in Gemfile)
config.metrics_backend = Kirei::Metrics::StatsdBackend.new
# Or disable metrics entirely
config.metrics_backend = Kirei::Metrics::NullBackend.new
endEmit custom metrics anywhere via Kirei::Logging::Metric:
Kirei::Logging::Metric.call("airports_search_term", 1, tags: { "query" => search })Request timing and service execution timing are tracked automatically.
To build a custom backend (e.g. Prometheus, OpenTelemetry), subclass Kirei::Metrics::Backend and implement increment, measure, and gauge.
Web servers and request bodies
Kirei runs on any Rack server. It routes by PATH_INFO, accepts any readable rack.input, and keeps the request env fiber-local, so both thread-based servers (Puma) and fiber-based servers (Falcon) work. With Falcon, also set config.db_global_extensions = [:fiber_concurrency] (see below).
JSON and form bodies are buffered in memory. Bound them per app:
class MyApp < Kirei::App
config.max_request_body_bytes = 65_536 # nil (default) means unlimited
endA larger body gets a 413 JSON:API error with code payload_too_large. Multipart uploads stream to tempfiles through Rack and are not limited by this setting. Malformed JSON returns 400 with code malformed_json, and a JSON body that is not an object returns 400 with code invalid_json_body.
Database connection
Kirei::App.raw_db_connection opens one Sequel database per process. Configure it in your app:
class MyApp < Kirei::App
# Global extensions change Sequel itself and are loaded via `Sequel.extension`
# before the connection exists. Use `:fiber_concurrency` with Async/Falcon.
config.db_global_extensions = [:fiber_concurrency]
# Database extensions are loaded on the connection via `Sequel::Database#extension`.
config.db_extensions += [:pgvector]
# Pool bounds and per-connection session setup; `nil` keeps the Sequel default.
config.db_max_connections = Integer(ENV.fetch("DB_POOL_SIZE", "5"))
config.db_pool_timeout = Float(ENV.fetch("DB_POOL_TIMEOUT", "2"))
config.db_connect_timeout = 5
config.db_connect_sqls = ["SET statement_timeout = '10s'"]
endA global extension passed to db_extensions is silently ignored by Sequel, so keep the two lists apart.
Goes well with these gems
- pagy for pagination
- argon2 for password hashing
- rack-session for session management
-
pgvector for vector columns — add
:pgvectortoApp.config.db_extensions
Middlewares
If you place custom middlewares under config/middleware/*.rb, they will be automatically loaded, see app.rb "load configs".
However, you still need to use them in the config.ru file, see the generated config.ru file, this gives you full control over the order in which middlewares are executed:
# Load middlewares here
use(Rack::Reloader, 0) if TestApp.environment == "development"
# add more custom middlewares here, e.g.
use(Middleware::Example)
# Launch the app
run(TestApp.new)Middleware provided by a gem like rack-session must be added to the config.ru file as well if you want to use it.
Contributions
We welcome contributions from the community. Before starting work on a major feature, please get in touch with us either via email or by opening an issue on GitHub. "Major feature" means anything that changes user-facing features or significant changes to the codebase itself.
Please commit small and focused PRs with descriptive commit messages. If you are unsure about a PR, please open a draft PR to get early feedback. A PR must have a short description ("what"), a motiviation ("why"), and, if applicable, instructions how to test the changes, measure performance improvements, etc.
Publishing a new version
run
bin/releasewhich will guide you through the release process.