Todo API Walkthrough
See every primitive working together in the executable example.
The project includes an executable Bun API example. It combines Services, Layers, Effects, Runtime, Scope, better-result, Bun SQL and the ITI adapter. Use it as a reference for organizing a real application; in your own project, the example files can live wherever your application structure calls for them.
Dependency graph
AuthService ──────────────┐
├── UserRepository ─────┤
├── SessionRepository ──┤── Database (Layer.scoped)
└── PasswordHasher │
TodoService ──────────────┘
└── TodoRepository ─────┘The Services contain behavior and yield the dependencies they need. The Layer file is the composition root:
const DatabaseLive = Layer.scoped(
Database,
async () => {
const database = new Database(new SQL(':memory:', { adapter: 'sqlite' }))
await database.initialize()
return database
},
(database) => database.close()
)
const RepositoriesLive = Layer.merge(
Layer.make(UserRepository),
Layer.make(SessionRepository),
Layer.make(TodoRepository)
)
const ServicesLive = Layer.merge(
Layer.make(PasswordHasher),
Layer.make(AuthService),
Layer.make(TodoService)
)
export const AppLive = Layer.merge(DatabaseLive, RepositoriesLive, ServicesLive)Request lifecycle
Each HTTP handler runs a program in the shared Runtime:
const handleRequest = Effect.fn(async function* () {
const { userId } = yield* Result.await(requireUser(request))
const todos = yield* TodoService
const items = yield* Result.await(todos.list(userId))
return Result.ok(items)
})
const result = await runtime.run(handleRequest)The request gets an execution child Scope. The SQLite client belongs to the
root Scope and remains available across requests. A request-local file, lease
or stream can use Effect.acquireRelease and be cleaned up as soon as that
handler settles.
Run a project based on this example
bun run src/index.tsThe server listens on http://localhost:3333 and seeds
demo@example.com / demo1234 in the reference implementation. In a new
application, use your own entry point and configuration. The important part is
that the entry point passes the actual AppLive Layer and typed main Program
to NodeRuntime.runMain. That one boundary owns the application Runtime,
passes its resolver context to request handlers, stops the server when
CurrentAbortSignal aborts, and disposes the Layer resources.
Why this shape scales
- The HTTP layer knows only the Runtime and domain Results.
- Services express behavior and dependencies without ITI imports.
- Layers make production and test environments explicit.
- Runtime owns graceful shutdown.
better-resultkeeps error unions typed from database to HTTP response.