backend v0.5.2
Alumna
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.
Backend can be simple
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
Table of Contents
- 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
Philosophy
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.
Status
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.
Installation
Add Alumna to your shard.yml:
dependencies:
alumna:
github: alumna/backend
Then run shards install. Require it in your project:
require "alumna"
Crystal 1.19.1 or later is required.
Core Architecture
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.
1. Services
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
end
If 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.new
HTTP Routing Mapping
When 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 |
Querying
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.
Writing a Custom Adapter
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
# 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
end
2. Schemas
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 default
Supported 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
Nested Fields (Objects and Arrays)
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")
end
If 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"}).
Conditional Requirements
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.)
Strict Validation and Read-Only Fields
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
}
Pluggable Formats
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)
3. Rules
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
end
Defining Rules
For 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
end
For prototypes or one-liners – use the block form directly:
before on: :write do |ctx|
ctx.headers["authorization"]? ? nil : Alumna::ServiceError.unauthorized
end
Both compile to the same Proc. The block form runs once at boot with the service as its context.
Execution Order & Hooks
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 anywhere
Pipeline 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.
Targeting Methods with on:
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
The Rule Context
| 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 |
Headers, Params, and Views
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.
Sharing State with ctx.store
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
end
Downstream 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.
4. Built-in Rules
Alumna ships with zero-dependency rules for common production needs:
Validation
before Alumna.validate(UserSchema), on: :write
# Or using the shorter helper inside a service:
before validate, on: :write
Returns 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)
end
When you need custom messages or transformations, call the schema directly inside your own rule:
errors = UserSchema.validate(ctx.data, ctx.method)
Timestamp
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").
CORS
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.
Logger
before Alumna.logger
after Alumna.logger
Logs 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.
Rate Limiter
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.
5. Server Configuration & Multi-threading
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
)
Multi-threading and Workers
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_context
When 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.
Graceful Shutdown
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.
Trusted Proxies
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.
Full Example
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)
Serialization
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.
Testing
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.
Testing Rules
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
end
Testing Applications
Use 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
end
Writing a custom database adapter? Use Alumna::Testing::AdapterSuite.run("MyAdapter") { MyAdapter.new } to instantly run dozens of compliance specs against your implementation!
Roadmap
v0.6 - Security and authentication
- JWT and session authentication helper rules.
v0.7 - v0.8 Real Database Adapters
- 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.
v0.9 - Real-time events via WebSocket
- Emit service events automatically after successful mutations (
created,updated,patched,removed). - Allow clients to subscribe to specific service paths over a WebSocket connection.
v0.10 - v0.11 Horizontal Scaling & Cache
- 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.
Design Decisions and Trade-offs
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)) | Nil
This 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.
Contributing
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 spec
License
MIT
backend
- 2
- 0
- 0
- 0
- 1
- 8 days ago
- April 11, 2026
MIT License
Fri, 22 May 2026 20:54:37 GMT