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
- ✅ Official SQLite adapter with native JSON dot-notation querying
- ✅ 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
- ✅ Seamless, zero-serialization inter-service communication via
ctx.call. - ✅
providerfield on context dynamically resolving"rest"(TCP),"local"(Unix sockets), and"internal"(Service-to-Service).
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.20.2 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 ctx.query object. It natively supports MongoDB/FeathersJS-style comparison operators and nested dot-notation.
Because URL parameters are inherently strings, Alumna provides a powerful typed_filters(schema) method. It reads your service's schema and automatically coerces the query values into native Crystal types (Int64, Float64, Bool, Time), returning an immediate 400 Bad Request if the client sends a malformed type.
# GET /users?age[$gte]=18&status[$in]=active,pending&billing.plan=pro&$limit=10&$sort=age:-1
# Raw string parsed from URL
ctx.query.filters["age"] # => [{op: Op::Gte, value: "18"}]
# Strictly typed against the UserSchema
filters = ctx.query.typed_filters(schema)
filters["age"] # => [{op: Op::Gte, value: 18_i64}]
filters["status"] # => [{op: Op::In, value: ["active", "pending"]}]
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.
Alumna ships with a built-in MemoryAdapter out of the box, perfect for prototyping and rapid testing.
For real persistence, use one of our official database adapters. They seamlessly translate Alumna Schemas and queries into optimized database operations.
- SQLite:
alumna/sqlite– The official SQLite adapter. Features zero-allocation JSON streaming for nested fields, native dot-notation querying for JSON columns, and built-in security against SQL injection.
To connect a real database manually, 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
# 1. Safely coerce URL strings into native types
filters = ctx.query.typed_filters(schema)
return filters if filters.is_a?(ServiceError)
# 2. Query @db using filters, ctx.query.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) : Nil | ServiceError
# delete record at ctx.id, return ServiceError.not_found if it didn't exist.
# Returning `nil` here automatically triggers a 204 No Content response
nil
end
endServices often need to interact with each other (e.g., a Post creation triggering an AuditLog creation). Instead of making expensive HTTP calls to yourself or duplicating business logic, Alumna provides ctx.call.
ctx.call dispatches a request to another mounted service completely in-memory. It bypasses the network and serialization stack, but still fully executes the target service's schema validations and rules.
CreateAuditLog = Alumna::Rule.new do |ctx|
# Safely trigger another service entirely in-memory!
ctx.call("/audit", :create, {
"action" => "User updated",
"user_id" => ctx.id
} of String => Alumna::AnyData)
nil
endWhen you invoke ctx.call:
ctx.provideris set to"internal".- The
ctx.storefrom the parent request is shallow-copied to the new context. This means if a global rule authenticated a user and stored them inctx.store["current_user"], the internal service will automatically inherit that authenticated user, bypassing the need to re-validate tokens or hit the database twice!
A 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, :time, :bytes, :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 where missing fields mean "do not update":
PostSchema = Alumna::Schema.new
.str("title", required_on: [:create, :update], min_length: 1)
.str("body", required_on: :create)(Note: If a field is read_only: true, Alumna is smart enough to never require it from the client during write operations, keeping your schema definitions clean.)
Alumna schemas are strict by default. If a client attempts to send extra fields that are not defined in the schema (e.g., a Mass Assignment attack), the validator will automatically reject the payload with an "is not allowed" error.
To opt out and allow unknown fields, initialize the schema with Alumna::Schema.new(strict: false). Strictness settings automatically cascade to all nested hashes and arrays.
For fields that belong to your data model but should never be manipulated directly by the client (such as id, created_at, or account_balance), use read_only: true:
AccountSchema = Alumna::Schema.new
.str("id", read_only: true)
.str("email", format: :email)
.time("created_at", read_only: true)
.time("updated_at", read_only: true)When a field is marked as read_only:
- If the client tries to send it during a write operation (
POST,PUT,PATCH), the validator will reject it with an"is read-only"error. - The validator automatically waives the presence check (
required) for these fields during write operations, so clients don't have to send them. - Because the block happens purely at the validation layer, your internal Rules and Database Adapters remain entirely free to safely compute and inject these values into
ctx.datadownstream!
Alumna includes a built-in timestamp rule to make handling dates completely effortless:
app.use "/accounts", Alumna.memory(AccountSchema) {
# 1. Reject any read-only fields if sent by the client
before validate, on: :write
# 2. Inject computed dates automatically
before Alumna.timestamp("created_at"), on: :create
before Alumna.timestamp("updated_at"), on: :write
}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:
optionsHTTP calls (CORS preflights) are excluded from default:allscopes. To run a rule on an OPTIONS request, you must explicitly passon: :options.
Strict Compilation: For maximum performance and thread-safety, Alumna compiles and strictly freezes all rule pipelines the moment you call
app.listen. Attempting to register a rule after the server boots will intentionally raise an Exception to prevent silent failures.
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 |
Read-Only The request source: "rest" (TCP/HTTP), "local" (Unix socket), or "internal" (via ctx.call). |
ctx.id |
Read-Only URL ID of the targeted resource. |
ctx.params / ctx.headers |
Zero-allocation views of the request |
ctx.data |
The 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 |
Security Lock: Foundational structural fields like
ctx.providerandctx.idare compiler-enforced read-only getters. They cannot be spoofed or accidentally mutated mid-flight by downstream rules.
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.
ctx.store is a per-request scratchpad used to pass data between rules and services (e.g., passing an authenticated User from an auth rule to your database adapter).
It natively accepts standard JSON-like primitives (Strings, Integers, Floats, Booleans, Times, Bytes, Hashes, Arrays). To store your own custom classes or structs, you must explicitly mark them by including Alumna::Storeable:
class User
include Alumna::Storeable
getter id : Int32
def initialize(@id); end
end
Authenticate = Alumna::Rule.new do |ctx|
# Store the custom object safely
ctx.store["user"] = User.new(42)
nil
endDownstream rules or services can then retrieve and cast it safely using standard Crystal semantics: user = ctx.store["user"].as(User).
Tip: While
Alumna::Storeableworks perfectly with bothclassandstruct, prefer usingclassfor very large data structures. Becausectx.storeuses a mixed union type under the hood, massive structs will artificially inflate the memory footprint of the hash buffer.
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.timestamp("created_at"), on: :create
before Alumna.timestamp("updated_at"), on: :write- Injects current
Time.utcinto specified field(s) ofctx.data. - Can be paired with
read_only: trueon the field during schema definition, securing it to be only manipulated by the backend, not the client. - Accepts multiple fields at once:
Alumna.timestamp("created_at", "updated_at").
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(
port: 3000,
host: "0.0.0.0",
unix_socket: nil,
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 can serve HTTP traffic over standard TCP ports and local Unix sockets—either simultaneously or exclusively.
# Listen on both TCP and a Unix socket
app.listen(3000, unix_socket: "/tmp/airsailer.sock")
# Listen exclusively on a Unix socket
app.listen(port: nil, unix_socket: "/tmp/airsailer.sock")When a request arrives via the Unix socket, Alumna automatically detects it and sets ctx.provider to "local". This makes it trivial to safely bypass strict authentication rules for background jobs or local CLI tools running as root on the same machine:
Authenticate = Alumna::Rule.new do |ctx|
# Bypass JWT checks for internal calls and local system CLI tools
next nil if ctx.provider == "local" || ctx.provider == "internal"
# ... normal JWT authentication logic ...
endAlumna 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).
Under the hood, Alumna uses custom-built, zero-allocation stream parsers (JSON::PullParser and MessagePack's unbuffered lexer). This bypasses heavy intermediate wrapper types like JSON::Any, streaming payloads directly into Alumna's strict AnyData memory layout. Time objects are natively encoded and decoded as ISO8601 strings in JSON, and Bytes flow safely through both formats.
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.
- 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 | Time | Bytes | 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.
Flat routing API decision Alumna enforces flat routing by design to maintain O(1) routing performance and a more efficient caching on both server-side and client-side. Nested relationships should be handled via query parameters (e.g., /posts?userId=123).
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