StartEdit on GitHub
eezo
A Scala 3 web framework where your domain model is the source of truth.
Direct style on virtual threads. Deploy with one command.
eezo.io · Tutorials · Docs · API
What eezo is#
Declare your data model, and eezo derives the rest from that one declaration: its table in Postgres, its HTML form, and CRUD routes that serve it.
case class Post(
id: Id[Post],
author: Id[User],
title: String,
body: String,
published: Boolean
) derives Table, Form, ResourceA case class automatically derives its Postgres table and a type-safe query API. As you change your data model, Eezo detects schema changes. When you're ready, you can commit them to a fingerprinted SQL migration.
A case class also gets 7 CRUD routes: index, new, create, show, edit, update and destroy,
served in a browser over rows in Postgres, with no need for a controller, repository or template
written by hand. You can add your own routes as a plain def in a file, and the file path becomes the HTTP route path.
You can also override a derived route on the same path.
Handlers are ordinary functions from a request to a response. Eezo runs on virtual threads, so blocking on the database or on an external API is cheap.
UIs are server-side rendered, with minimal functional Scala. A Component[State] has init, update and render, and can return HTML with Scala functions.
For the frontend, the browser gets a thin JS client which sends events to the server, and the server renders DOM diffs that get patched on the UI.
No need for
- effect systems
- runtime reflection
- plugins
- JavaScript
- configurations
Deploying is done with eezo deploy on Fly.io.
Requirements#
- JDK 25 or newer. Eezo needs virtual threads to not be pinned on
synchronized. - Scala 3.8.4.
- sbt 1.5.8 or newer, or sbt 2.0.6 or newer. The plugin is published for both.
- Postgres, for an application with a database layer. The default connection is a local Postgres. One of the few Eezo configurations is for the database.
Install#
project/plugins.sbt:
addSbtPlugin("io.eezo" % "sbt-eezo" % "0.1.0")build.sbt:
scalaVersion := "3.8.4"
enablePlugins(EezoPlugin)
libraryDependencies += "io.eezo" %% "eezo" % "0.1.0"
scalacOptions ++= Seq("-release", "25", "-deprecation", "-feature", "-unchecked", "-no-indent")
// The application forks, so it runs on the JDK sbt was told about rather than the one sbt runs on.
run / fork := trueEvery artifact is published at the same version, under the io.eezo group:
| artifact | what it carries |
|---|---|
eezo | the umbrella and the default dependency |
eezo-http | the http layer: servers, requests and responses, routing, Form, Resource, Layout, HttpApp |
eezo-db | the database layer: the pool, the sql interpolator, transactions, Table, schema commands and migrations, DbApp |
eezo-auth | sessions, email and password sign in, CSRF, route guards and row ownership |
eezo-live | server-side rendered UI with the thin browser client: the diff and patch protocol, the client runtime, PubSub |
eezo-core | what the others build on: configuration, HTML tree and DSL, derivation, model keys |
sbt-eezo | the sbt plugin that generates the route table, for sbt 1 and sbt 2 |
An application has one or two layers, the http layer and the database layer. Use a layer by extending the appropriate trait in your app:
DbAppfromeezo-dbHttpAppfromeezo-httpEezoAppfromeezowhich mixes both
The Layers doc explains more.
Hello, eezo#
To start, you need two files. In src/main/scala/Main.scala:
import io.eezo.generated.Routes
import io.eezo.http.HttpApp
import io.eezo.http.RouteTable
object Main extends HttpApp {
override def routes: RouteTable = Routes.table()
}Then in src/main/scala/app/Hello.scala:
package app
import io.eezo.core.html.*
import io.eezo.http.Request
import io.eezo.http.Response
object Hello {
def hello(request: Request): Response =
Response.Ok(
Html.doctype ++ html(
head(title("hello, eezo")),
body(h1("hello, eezo"), p("served at ", code(request.path)))
)
)
}The Hello file is also the HTTP route. The plugin reads app/Hello.scala, mounts GET /hello calling
def hello, and writes the table to a generated io.eezo.generated.Routes, which Main contains as
its routes. A page is a value of Eezo's own HTML DSL. HttpApp brings main
with it:
sbt run # serves on http://localhost:8080
eezo dev # serves, restarting on every save, with the route listing
eezo routes # prints the tableYour first eezo application walks through this in about 20 minutes, and Routing says how a file name becomes a path.
From a model to an application#
Add the database layer by depending on either eezo or just eezo-db.
Make your case class derive Table, then add your tables (one line) to your schema:
import io.eezo.EezoApp
import io.eezo.db.Schema
import io.eezo.generated.Routes
import io.eezo.http.RouteTable
object Main extends EezoApp {
override def schema: Schema = AppSchema
override def routes: RouteTable = Routes.table()
}AppSchema registers the models that derive Table, and the schema commands run against it:
eezo status shows the drift between the case classes and the database, sync applies it in
development, freeze writes a migration, migrate runs the migrations in production. A model
that derives Table beside Form and Resource gets its seven routes backed by Postgres, and
the posts you create in the browser survive a restart.
Check out the the case class and the
schema and migrations pages for more details.
One line in the model's companion guards its routes:
object Post {
given Owned[Post, User] =
User.guard.required[Post].owning(_.author).except(Action.Index, Action.Show)
}Every route now needs a signed-in user, five of them reach only the author's own rows, and the login page travels into the route table with the declaration. Guards and ownership explains more.
The route table is a value, so Main can transform it before serving it. For example, Route.under("/admin")
moves a set of routes and every URL their pages emit under a path prefix. The derived pages link to each other
without ever naming the prefix. URLs and mounts explains
how it works.
A live component holds its state on the server and the browser receives patches over a websocket:
final class Board extends Component[List[Order]] {
def init(ctx: Init[List[Order]]): List[Order] = {
ctx.subscribe(Sales.paid)((sale, sales) => sale :: sales)
read(Table[Order].where(_.paid === true).orderBy(_.at.desc).list())
}
def handle(event: Event, sales: List[Order]): List[Order] = sales
def render(sales: List[Order]): Html = ul(sales.map(s => li(Key(s.id.show), s.name)))
}The live layer is the model and A live page builds one.
The command line#
eezo new <name> scaffold a new application in ./<name>
eezo dev serve, restarting on every save
eezo routes what is mounted
eezo status the drift between the models and the database
eezo sync apply that drift to the development database
eezo freeze write the drift as a migration
eezo migrate run the migrations
eezo build stage the jars, the migrations and a Dockerfile
eezo deploy build, deploy to Fly.io, migrate, wait for healthThe launcher is bin/eezo in this repository.
The eezo command line is the reference, and
Deploy explains how deploying works, and the possible deploy targets.
Examples#
| example | artifact | entry trait | what it shows |
|---|---|---|---|
hello | eezo-http | HttpApp | one handwritten route and no database |
reminders | eezo-db | DbApp | one Table and a nightly job over its rows, no server |
blog | eezo, eezo-auth | EezoApp | a model deriving all three type classes, guarded and mounted under /admin |
todo | eezo | EezoApp | a guided tour of the command line, every output captured from a real run |
shop | eezo, eezo-auth | EezoApp | a shop paying through Stripe, with a live sales board |
The examples build against the working tree rather than a release, so run them from a clone:
sbt publishLocalForExample # publishes eezo and sbt-eezo locally and records the version
cd examples
sbt hello/run # http://localhost:8080/hello
sbt reminders/run # runs the job once against the development Postgres
sbt "blog/run sync --apply" # creates the tables, then:
sbt blog/run # http://localhost:8080, posts under /admin/postsEach example has a README that explains how it works.
Documentation#
The documentation lives at eezo.io/docs and its sources are in
docs/. It is organised in four pillars:
- Tutorials, from your first application through the first model, sign in, a live page and deploying to Fly.
- How to guides, one recipe per task: create a CRUD app, evolve the schema, add authentication, test an application, and the rest.
- Explanation, the model behind each part of the framework: the server, the dev loop, sessions and CSRF, deployment.
- Reference: the derivations, routing, configuration, the sbt plugin, the live protocol.
The API reference, generated from the sources, is at eezo.io/api.
CONTEXT.md is the vocabulary the framework and its documentation use, and
docs/adr records the decisions behind its shape.
Contributing#
Issues and pull requests are welcome at github.com/Eezo-framework/eezo. The build is what CI runs:
sbt scalafmtCheckAll scalafmtSbtCheck # one formatter, one configuration
sbt +compile Test/compile # the plugin is built for sbt 1 and sbt 2
sbt +testThe db suite starts a real Postgres through testcontainers, so it needs a Docker daemon.
sbt check runs the regression half of that suite, and sbt backlog runs the tests that
describe open work and are expected to fail until it lands. The decisions behind the framework
are recorded in docs/adr.
Licence#
Eezo is released under the MIT License, copyright 2026 Riccardo Cardin and Daniel Ciocîrlan. An application that depends on Eezo carries no obligation beyond preserving the copyright notice, and Eezo takes on no dependency that would add one. Attribution notices for third party components are collected in NOTICE.