A minimalist, service-oriented backend framework for Crystal, inspired by Service Oriented Architecture (SOA) from frameworks like FeathersJS and designed around three ideas: simplicity, explicitness, and performance.
require "alumna"
# Schema definition
MessageSchema = Alumna::Schema.new
.str("body", min_length: 1, max_length: 500)
.str("author", min_length: 1)
.bool("read", required: false)
# Authentication rule
Authenticate = Alumna::Rule.new do |ctx|
token = ctx.headers["authorization"]?
token == "Bearer my-secret" ? nil : Alumna::ServiceError.unauthorized
end
app = Alumna::App.new
# Messages service based on Memory adapter
app.use "/messages", Alumna.memory(MessageSchema) {
before Authenticate
before validate, on: :write
}
# Done
app.listen(3000) # binds to 127.0.0.1:3000 by default- Philosophy
- Status
- Installation
- Core Architecture
- 1. Services
- 2. Schemas
- 3. Rules
- 4. Built-in Rules
- 5. Server Configuration & Multi-threading
- Full Example
- Serialization
- Testing
- Roadmap
- Design Decisions and Trade-offs
- Contributing
- License
Most backend frameworks ask you to learn their full architecture before you can write a single working endpoint. Alumna takes the opposite approach.
The entire model fits in your head at once. There is no magic, no dependency injection container, no decorator metadata, and no complex resolver chain. Every moving piece is visible and explicit. A developer new to the codebase can read a service definition and understand the full execution path in minutes.
Alumna inherits Crystal's performance characteristics: ahead-of-time compilation, a single self-contained binary, no runtime dependencies, and throughput that benchmarks consistently alongside Go and Rust—all with a syntax beautifully close to Ruby.
Alumna is in active early development. The following core pieces are complete and tested:
- ✅ HTTP layer with RESTful routing and content negotiation
- ✅ Rule pipeline with explicit
before,after, anderrorphases - ✅ Deep schema validation with path-tracing for nested arrays/objects
- ✅ Zero-allocation validation formats resolved at definition time
- ✅ In-memory adapter implementing the full service interface
- ✅ JSON and MessagePack serialization
- ✅ Rich
RuleContextwith safe, zero-allocation views for headers and params - ✅ Advanced query parsing (
$limit,$skip,$sort,$select,$in,$gt, etc.) - ✅ Safe multi-threading and graceful server shutdown
- ✅ Cross-platform CI with full test coverage
- ✅ Path normalization and duplicate-route protection
- ✅ Strict request-body limits enforced on all IO entry points
- ✅
providerfield on context distinguishing"rest"from future"websocket"
See the Roadmap for what is coming next.
Add Alumna to your shard.yml:
dependencies:
alumna:
github: alumna/backendThen run shards install. Require it in your project:
require "alumna"Crystal 1.19.1 or later is required.
Alumna's architecture revolves around three decoupled concepts:
- Services: Objects that expose a standard set of data methods (
find,get,create,update,patch,remove,options) and are automatically mounted as RESTful HTTP APIs.optionsis reserved for CORS preflights and has no business logic by default. - Schemas: Declarative definitions of data shapes, used for both strict input validation and structural hints for databases.
- Rules: Single-responsibility functions (middlewares) that handle concerns like authentication, logging, or rate-limiting. They run in a flat, predictable pipeline.
A service in Alumna acts as a data adapter. You don't write "controllers" and "routes" manually. Instead, you mount a service to a path, and Alumna automatically wires up the HTTP REST verbs to the service's methods.
For simple resources, you can use the built-in MemoryAdapter block syntax:
app.use "/messages", Alumna.memory(MessageSchema) do
before Authenticate
after AddRequestId
endIf you need to override business logic, you can define a full class:
class UserService < Alumna::MemoryAdapter
def initialize
super(UserSchema)
before validate, on: :write
end
def find(ctx)
# custom find logic here
super
end
end
app.use "/users", UserService.newWhen a service is mounted, Alumna exposes it automatically following standard REST conventions:
| HTTP Verb | Path | Service Method |
|---|---|---|
GET |
/users |
find |
GET |
/users/:id |
get |
POST |
/users |
create |
PUT |
/users/:id |
update |
PATCH |
/users/:id |
patch |
DELETE |
/users/:id |
remove |
OPTIONS |
/users or /users/:id |
options |
Alumna automatically parses URL query strings into a strongly-typed ctx.query object. It natively supports MongoDB/FeathersJS-style comparison operators and nested dot-notation.
# GET /users?age[$gte]=18&status[$in]=active,pending&billing.plan=pro&$limit=10&$sort=age:-1
ctx.query.filters["age"] # => [{op: Op::Gte, value: "18"}]
ctx.query.filters["status"] # => [{op: Op::In, value: ["active", "pending"]}]
ctx.query.filters["billing.plan"] # => [{op: Op::Eq, value: "pro"}]
ctx.query.limit # => 10
ctx.query.sort # => [{"age", -1}]Supported operators: $eq (default), $ne, $gt, $gte, $lt, $lte, $in, $nin.
The built-in MemoryAdapter implements all of these out of the box. When building custom database adapters, you simply read ctx.query to compile your SQL/NoSQL statements.
To connect a real database, inherit from Alumna::Service and implement its six abstract methods. Each method receives the full RuleContext and returns a typed value.
class PostgresUserService < Alumna::Service
def initialize(@db : DB::Database)
super(UserSchema)
self.before(Authenticate)
end
def find(ctx : RuleContext) : Array(Hash(String, AnyData)) | ServiceError
# query @db using ctx.query.filters, limit, skip, and sort
[] of Hash(String, AnyData)
end
def get(ctx : RuleContext) : Hash(String, AnyData)? | ServiceError
# query @db using ctx.id
nil
end
def create(ctx : RuleContext) : Hash(String, AnyData) | ServiceError
# insert ctx.data into @db, return the created record
{} of String => AnyData
end
def update(ctx : RuleContext) : Hash(String, AnyData) | ServiceError
# full replace of ctx.id with ctx.data
{} of String => AnyData
end
def patch(ctx : RuleContext) : Hash(String, AnyData) | ServiceError
# partial update of ctx.id with ctx.data
{} of String => AnyData
end
def remove(ctx : RuleContext) : Bool | ServiceError
# delete record at ctx.id, return true if deleted
false
end
endA schema describes the fields a service works with.
UserSchema = Alumna::Schema.new
.str("name", min_length: 2, max_length: 100)
.str("email", format: :email)
.int("age")
.bool("admin", required: false) # required is true by defaultSupported field types: :str, :int, :float, :bool, :nullable, :hash, :array (or Alumna::FieldType::Str, etc.).
You can also use the more explicit field helper:
UserSchema = Alumna::Schema.new
.field("name", :str, min_length: 2, max_length: 100)
.field("email", :str, format: :email)
.field("age", :int)Supported constraints: required, required_on, min_length, max_length, format
Alumna fully supports validating nested JSON structures. The validation engine walks the data tree using a zero-allocation path tracer, ensuring deep validation remains incredibly fast.
OrganizationSchema = Alumna::Schema.new
.str("name")
.hash("billing") do |sub|
sub.str("plan", min_length: 1)
sub.str("card_last_four", min_length: 4, max_length: 4)
end
.array("tags", of: :str, min_length: 1, max_length: 10)
.array("members") do |sub|
sub.str("email", format: :email)
sub.str("role")
endIf a nested field fails validation, Alumna replies with explicit dot/bracket notation errors (e.g., {"billing.plan": "is required"}, or {"members[0].email": "must be a valid email address"}).
required_on lets a field be required only for specific operations, perfect for PATCH operations:
PostSchema = Alumna::Schema.new
.str("title", required_on: [:create, :update], min_length: 1)
.str("body", required_on: :create)Alumna ships with :email, :url, and :uuid backed by Crystal's stdlib. You can register your own formats once at application boot, which are directly compiled as Proc calls (no runtime hash lookups):
Alumna::Formats.register("hex_color", "must be a valid hex color") do |v|
v.matches?(/\A#(?:[0-9a-fA-F]{3}){1,2}\z/)
end
ProductSchema = Alumna::Schema.new
.str("color", format: :hex_color)A Rule is a single-responsibility pipeline hook. It is a Proc that takes a RuleContext. Returning nil continues the pipeline; returning a ServiceError halts it immediately.
Authenticate = Alumna::Rule.new do |ctx|
token = ctx.headers["authorization"]?
token == "Bearer my-secret" ? nil : Alumna::ServiceError.unauthorized
endFor production code – define a reusable constant, ideally in its own file:
# src/rules/authenticate.cr
Authenticate = Alumna::Rule.new do |ctx|
ctx.headers["authorization"]? ? nil : Alumna::ServiceError.unauthorized
endFor prototypes or one-liners – use the block form directly:
before on: :write do |ctx|
ctx.headers["authorization"]? ? nil : Alumna::ServiceError.unauthorized
endBoth compile to the same Proc. The block form runs once at boot with the service as its context.
Rules can be attached to the Application (global) or a specific Service. They are hooked into three phases:
before rule, on: :write # runs before the service method
after rule, on: :all # runs after a successful service method
error rule # runs if an error occurs anywherePipeline Execution Sequence:
app.beforerulesservice.beforerules- service method (
find,get, etc.) - skipped if a before-rule setsctx.result service.afterrulesapp.afterrules
If any rule or method returns a ServiceError, the pipeline jumps immediately to the error phase:
6. service.error rules
7. app.error rules
After-rules always run when there is no error, even if a before-rule short-circuited the service method. Error-rules always run when there is an error, even if it occurred in a before-rule. This makes logging, metrics, and response headers reliable for both success and failure paths.
Note: options HTTP calls (CORS preflights) are excluded from default :all scopes. To run a rule on an OPTIONS request, you must explicitly pass on: :options.
on: controls which service methods run the rule. It accepts:
- a
ServiceMethodenum:on: Alumna::ServiceMethod::Find - a symbol:
on: :create,on: :patch - an array:
on: [:find, :get] - a shorthand:
:read→find,get:write→create,update,patch,remove:all→ all methods exceptoptions
- omit
on:→ same as:all
options is excluded by design since it's reserved for CORS preflights. If you need a rule to run on preflights, be explicit:
before Alumna.cors(origins: ["*"]), on: :options| Field | Description |
|---|---|
ctx.app / ctx.service |
Read-only references to the App and Service |
ctx.method |
The current enum method (Find, Create, etc.) |
ctx.http_method |
The raw HTTP verb (GET, POST, etc.) |
ctx.remote_ip |
Client IP (supports trusted proxy chains) |
ctx.provider |
Request source – "rest" today, "websocket" in future |
ctx.params / ctx.headers |
Zero-allocation views of the request |
ctx.id / ctx.data |
URL ID and parsed request body |
ctx.result |
Response payload (set this to skip the service method) |
ctx.error |
Captured ServiceError, available in the error phase |
ctx.store |
A Hash scratch space to share data between rules |
ctx.http |
Object (HttpOverrides) to set status, headers, or location redirects |
ctx.headers and ctx.params are zero-allocation views. Writes go to an in-memory overlay so the original HTTP::Request is never mutated, but downstream rules instantly see the changes:
ctx.headers["x-request-id"] = Random::Secure.hex(8)
ctx.params["locale"] = "en" unless ctx.params["locale"]?- HeadersView – case-insensitive (
ctx.headers["authorization"]?works for any casing), implementsEnumerable({String, String}) - ParamsView – same API for query parameters, without case folding
The overlay is visible to all downstream rules and to the service, but it is not automatically reflected in the HTTP response – copy values to ctx.http.headers if you need to send them back.
Alumna ships with zero-dependency rules for common production needs:
before Alumna.validate(UserSchema), on: :write
# Or using the shorter helper inside a service:
before validate, on: :writeReturns a 422 Unprocessable Entity with per-field details when validation fails. It automatically respects required_on.
Alumna.validate(schema) is a zero-magic shortcut. It is equivalent to:
Alumna::Rule.new do |ctx|
errors = schema.validate(ctx.data, ctx.method)
next nil if errors.empty?
details = errors.to_h { |e| {e.field, e.message} }
Alumna::ServiceError.unprocessable("Validation failed", details)
endWhen you need custom messages or transformations, call the schema directly inside your own rule:
errors = UserSchema.validate(ctx.data, ctx.method)before Alumna.cors(origins: ["https://app.example.com"])
# for preflights – OPTIONS is opt-in by design
before Alumna.cors(origins: ["https://app.example.com"]), on: :options- Sets
Access-Control-Allow-Origin,Vary: Origin, and credentials when enabled. - Handles real preflights (
OPTIONS+Access-Control-Request-Method) with a 204. origins: ["*"]is allowed for public APIs, but using it withcredentials: trueraisesArgumentErrorat boot – per the Fetch spec, wildcard cannot be used with credentials.- Convention: global
beforerules do not run onOPTIONSunless you explicitly includeon: :options. This prevents authentication or validation from blocking CORS preflights.
before Alumna.logger
after Alumna.loggerLogs in combined format using a monotonic clock to measure request duration correctly:
5.5.5.5 "GET /users/123" 200 2.3ms
- Uses
ctx.remote_ip,ctx.http_method, andctx.storeto correlate before/after phases. - Works with any
IO– passFile.open("access.log", "a")for file logging.
before Alumna.rate_limit(limit: 100, window_seconds: 60)- In-memory fixed-window limiter per key (defaults to client IP; override with
key: ->(ctx) { ... }). - Uses a monotonic clock for expiry, so limits stay accurate across system clock changes.
- Memory-bounded store: entries expire after their window and are pruned by an amortized in-request cleanup – no background fiber.
- Sets
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset. - Returns
429 Too Many Requestswhen exceeded. - Skips
OPTIONSrequests automatically.
You boot your application by calling app.listen. By default, it binds to 127.0.0.1 on port 3000.
app.listen(
3000,
host: "0.0.0.0",
trusted_proxies: ["10.0.0.0/8"],
workers: 4,
shutdown_timeout: 10.seconds
)Alumna is thread-safe. By default, Crystal programs run on a single thread. To take advantage of modern multi-core processors, compile your application with the multithreading flags:
crystal build src/main.cr --release -Dpreview_mt -Dexecution_contextWhen compiled with these flags, Alumna will automatically configure the Fiber execution pool. You can explicitly set the number of threads via the workers: N argument in app.listen. If omitted, it gracefully defaults to your machine's logical CPU core count.
Alumna safely traps SIGINT (Ctrl+C) and SIGTERM. When a shutdown signal is received, the server immediately stops accepting new connections but allows active requests to finish processing.
You can configure the maximum wait time using shutdown_timeout (defaults to 10 seconds). Once the timeout is reached, the server force-quits to prevent hanging indefinitely.
When Alumna runs behind Nginx, HAProxy, Cloudflare, or a Load Balancer, ctx.remote_ip must be correctly derived from proxy headers (Forwarded, X-Forwarded-For, X-Real-IP).
trusted_proxies: nil(default) – Never trust proxy headers.trusted_proxies: true– Trust headers from any client (useful for local dev).trusted_proxies: ["10.0.0.0/8"]– Trust headers only when the immediate peer IP matches the CIDR arrays. Supports bit-level matching for IPv4 and IPv6.
require "alumna"
UserSchema = Alumna::Schema.new
.str("name", min_length: 2, max_length: 100)
.str("email", format: :email)
.int("age")
PostSchema = Alumna::Schema.new
.str("title", required_on: [:create, :update], min_length: 1, max_length: 200)
.str("body", required_on: [:create, :update], min_length: 1)
Authenticate = Alumna::Rule.new do |ctx|
token = ctx.headers["authorization"]?
token == "Bearer my-secret" ? nil : Alumna::ServiceError.unauthorized
end
app = Alumna::App.new
# Global app configurations
app.before Alumna.logger
app.after Alumna.logger
app.use "/users", Alumna.memory(UserSchema) {
before Authenticate
before validate, on: :write
}
app.use "/posts", Alumna.memory(PostSchema) {
before Authenticate
before validate, on: :write
}
app.listen(3000)Alumna supports JSON (default) and MessagePack out of the box. Format is negotiated dynamically per request using standard HTTP headers (Content-Type / Accept).
If you need a new serialization format (e.g. XML), simply implement Alumna::Http::Serializer and override the encode and decode methods.
Alumna includes a built-in testing toolkit (Alumna::Testing) designed to make unit and integration tests incredibly fast and boilerplate-free. It bypasses network sockets entirely while running through the exact same router and orchestrator logic used in production.
Test individual rules in isolation without spinning up mock services.
require "alumna/testing"
describe "Authenticate Rule" do
it "blocks unauthorized requests" do
result = Alumna::Testing.run_rule(Authenticate, headers: {"Authorization" => "wrong"})
result.error.try(&.status).should eq(401)
end
endUse AppClient to test full request lifecycles instantly in memory:
describe "User API" do
app = Alumna::App.new
app.use("/users", UserService.new)
client = Alumna::Testing::AppClient.new(app)
client.default_headers["Authorization"] = "Bearer my-secret"
it "creates a user" do
res = client.post("/users", body: %({"name": "Alice"}))
res.status.should eq(201)
res.json["name"].as_s.should eq("Alice")
end
endWriting a custom database adapter? Use Alumna::Testing::AdapterSuite.run("MyAdapter") { MyAdapter.new } to instantly run dozens of compliance specs against your implementation!
- JWT and session authentication helper rules.
- SQLite adapter (using
crystal-sqlite3). - MySQL and PostgreSQL adapters (using
crystal-db). - Adapters will read the service schema to introspect column names and types automatically.
- Emit service events automatically after successful mutations (
created,updated,patched,removed). - Allow clients to subscribe to specific service paths over a WebSocket connection.
- Redis adapter for caching.
- NATS integration for horizontal scaling. Stateless service instances will publish events to NATS subjects, enabling real-time WebSocket fan-out across multiple Alumna instances behind a load balancer.
Why rules instead of middleware?
Middleware in most frameworks is a general-purpose mechanism with implicit ordering and no declared intent. A rule has an explicit phase (before, after, or error), an explicit target (all methods or a named subset), and a contract that returns a typed result. The intent is visible directly from the registration site.
Why no resolvers?
FeathersJS resolvers automatically transform the result payload based on context. Alumna omits them in favour of explicit after rules that transform ctx.result directly. This is slightly more code in trivial cases but significantly easier to debug.
Why ServiceResult uses AnyData instead of JSON::Any?
Alumna defines its own recursive union:
alias AnyData = Nil | Bool | Int64 | Float64 | String | Array(AnyData) | Hash(String, AnyData)
alias ServiceResult = Hash(String, AnyData) | Array(Hash(String, AnyData)) | NilThis lets every layer – context, services, rules, and serializers – work with native Crystal values instead of a wrapper type. The responder can dispatch on the actual type, MessagePack serializes without unwrapping, and validation errors flow through as plain hashes. It removes the JSON::Any dependency from the core, makes the context format-agnostic, and gives the compiler full visibility into data shapes for better errors and zero-cost abstractions.
Why is ServiceError a struct instead of an Exception?
In many frameworks, returning a 404 Not Found or a 422 Unprocessable Entity involves raising an exception. In Crystal, instantiating an Exception allocates a call stack (backtrace), which adds measurable overhead under high load. By making ServiceError a lightweight struct returned directly by rules and service methods as a union type, Alumna achieves zero-allocation error paths. Expected API control flow never triggers the exception unwinding machinery, keeping throughput extremely high while remaining completely type-safe.
Why Crystal? Expressive syntax that lowers the barrier for developers coming from Ruby or TypeScript. Ahead-Of-Time (AOT) compilation and a single binary output eliminates runtime dependency management at deploy time. Performance that competes with Go and Rust without sacrificing readability.
100% coverage strategy Alumna enforces 100% code coverage by design. This delivers immediate feedback on regressions in pull requests and gives developers the confidence to refactor, optimize, and introduce new features without fear of breaking existing behavior.
As foundational infrastructure, Alumna treats complete behavioral correctness as non-negotiable.
Alumna is in early development and contributions are very welcome! Please open an issue before starting significant work so we can align on direction.
git clone https://github.com/alumna/backend
cd alumna
shards install
crystal specMIT