#The Alepha Instance
The Alepha class is the central container of your application. It holds configuration, manages state, controls lifecycle, and wires dependencies together.
Unlike tools that rely on global side-effects, Alepha keeps everything contained within this instance. This makes your application portable, testable, and predictable.
#Creating the Instance
Use the static factory method to initialize your app:
1import { Alepha } from "alepha";2 3const alepha = Alepha.create();
create() automatically merges process.env with any custom configuration you provide. In test environments (Vitest with globals: true), it binds the app to the test hooks: beforeAll starts it, afterAll stops it, and an app created inside a test is also stopped by onTestFinished. afterEach is kept for the logger, which prints the logs of a failed test.
#Configuration
Pass a configuration object to create. The most important property is env:
1const alepha = Alepha.create({2 env: {3 APP_NAME: "My SaaS",4 // Secrets from process.env are merged automatically5 },6});7 8alepha.env.APP_NAME; // "My SaaS"
Typed Environment Variables
While
alepha.envgives raw access, use the$envprimitive inside services for type-safe, validated environment variables.
#The App Name
alepha.name is the app's namespace: the one identity modules key a shared resource with (a Redis, a KV namespace, a bucket), so two apps, or two environments of one app, on the same backend never read each other's keys.
1import { Alepha } from "alepha";2 3Alepha.create().name; // "app"4Alepha.create({ env: { APP_NAME: "hello" } }).name; // "hello"
The first hit wins:
APP_NAME: in code, thenprocess.env, then a Worker binding lifted byloadEnv().- The name
alepha buildandalepha devbake into the bundles: the slugified app directory, ormeta.nameinalepha.config.ts. The same string as/version'snameand the Worker name. "app", when no build produced the code (Vitest, atsxscript).
It is read on every call and never written into env, so env.APP_NAME keeps meaning "set explicitly". A deploy usually sets APP_NAME to <name>-<env>, which keeps staging and production apart on one Redis.
In the browser only the baked name exists, and it can differ from the server's
APP_NAME. Never usealepha.namefor anything the browser and the server must agree on.
#Dependency Injection
Alepha is built on a dependency injection container. You register classes and Alepha wires them together.
#Registering Services (.with)
Use .with() to add services, modules, or providers:
1import { Alepha, run } from "alepha";2import { AlephaServer } from "alepha/server";3 4const alepha = Alepha.create();5alepha.with(AlephaServer);6alepha.with(MyService);
Auto-Registration
You rarely need to register core modules manually. If you use a primitive like
$routeor$repositoryin your class, Alepha detects the dependency and registers the necessary modules for you.
#Injecting Services (.inject)
Access a service instance from outside a class (e.g., in a script or test):
1const myService = alepha.inject(MyService);2myService.doSomething();
.inject(MyService) is like new MyService() but with all dependencies automatically resolved.
#Swapping Implementations
Replace a service with a different implementation. This is the core of Alepha's testing and environment strategy:
1// In production: real emails via SMTP2// In development: kept in memory3alepha.with({4 provide: EmailProvider,5 use: alepha.isProduction() ? NodemailerEmailProvider : MemoryEmailProvider,6});
Any service that injects EmailProvider will receive the substituted implementation. Your business logic doesn't change.
A substitution must come before anything builds the original: once EmailProvider has been instantiated, alepha.with({ provide: EmailProvider, use: ... }) throws TooLateSubstitutionError, unless it says optional: true, which keeps the existing one silently. Once the app has started the container is locked: registering a new service, or injecting a singleton nobody built during boot, throws ContainerLockedError.
#Service Lifetimes
| Lifetime | Behavior |
|---|---|
singleton |
One instance per Alepha runtime (default) |
transient |
New instance every time |
scoped |
One instance per request context (AsyncLocalStorage) |
1alepha.inject(Logger, { lifetime: "transient" });
#Lifecycle
Alepha has a strict lifecycle to ensure resources are opened and closed correctly:
| Phase | What happens |
|---|---|
configure |
Services register configuration. Primitives read their schemas. |
start |
Providers connect to I/O (database, HTTP server). |
ready |
App is live. Background jobs and schedulers start. |
stop |
Graceful shutdown. Connections close, buffers flush. |
A few guarantees hold around it:
start()runs once. A second call on a started app returns at once, and concurrent calls share the same boot.- A failed boot unwinds. If a
startorreadyhook throws,stophooks run beforestart()rejects, so a database that already connected is closed. Aconfigurehook that throws stops the boot before anything opened. stop()aborts first. The app's AbortSignal (see Cancellation) fires before anystophook runs.- Every
stophook runs. One that throws does not skip the others;stop()then rejects naming the failed hooks. The whole stop is bounded to 8 seconds: past that, it rejects naming the hooks still pending.
#Running the App
Use the run helper. It starts the app on the next tick and, on Node, traps SIGTERM, SIGINT and SIGUSR2 for a graceful stop. An uncaught exception also stops the app, then exits with code 1; a boot that fails exits with code 1 too.
1import { run } from "alepha";2 3run(alepha);
run takes an Alepha instance, a service or module, or an array of them (it then creates the instance), and returns the instance. Its options:
| Option | Effect |
|---|---|
env |
Merged over process.env when run creates the instance |
configure |
(alepha) => ..., called before start() |
ready |
(alepha) => ..., called once the app is ready |
once |
Stop the app right after ready, for a one-shot script |
On serverless runtimes, under the Vite dev server and when the CLI imports the entry, run does not start anything: the host owns startup. The Workers and React Native builds of run accept only { env }, which there also applies to an existing instance. On Workers, run also stores the instance in globalThis.__alepha for the generated entry: that handshake is internal, so application code keeps its own reference. In the browser, a boot that fails on a blank page shows a "The app could not start." box with a Reload button instead of an empty screen.
#Manual Control (Tests)
In tests, you often want manual lifecycle control:
1describe("MyService", () => { 2 it("MyService", async () => { 3 const app = Alepha.create().with(MyService); 4 await app.start(); 5 6 const service = app.inject(MyService); 7 expect(service.isReady).toBe(true); 8 9 // await app.stop(); ← automatically called by Vitest hooks10 });11});
#Providers
Providers separate infrastructure logic (the "how") from business logic (the "what"). A provider wraps external systems - databases, email services, storage - behind a clean API.
1import { $env, $hook, z } from "alepha"; 2import { $logger } from "alepha/logger"; 3 4export class EmailProvider { 5 log = $logger(); 6 7 env = $env( 8 z.object({ 9 SMTP_HOST: z.text(),10 SMTP_USER: z.text(),11 SMTP_PASS: z.text(),12 }),13 );14 15 protected transporter = createTransport({16 host: this.env.SMTP_HOST,17 auth: { user: this.env.SMTP_USER, pass: this.env.SMTP_PASS },18 });19 20 onStart = $hook({21 on: "start",22 handler: async () => {23 await this.transporter.verify();24 this.log.info("Connected to SMTP");25 },26 });27 28 async send(to: string, subject: string) {29 return this.transporter.sendMail({ from: "me@app.com", to, subject });30 }31}
Injecting a provider is no different from injecting any other service:
1class UserService { 2 email = $inject(EmailProvider); 3 4 register = $action({ 5 method: "POST", 6 path: "/register", 7 handler: async ({ body }) => { 8 await this.email.send(body.email, "Welcome!"); 9 },10 });11}
#The Swap Strategy
Define an abstract provider, then swap implementations by environment:
1// Abstract contract 2export abstract class QueueProvider { 3 abstract push(job: object): Promise<void>; 4} 5 6// Production: Redis 7export class RedisQueueProvider extends QueueProvider { 8 async push(job: object) { 9 /* redis logic */10 }11}12 13// Dev/Test: in-memory14export class MemoryQueueProvider extends QueueProvider {15 queue: object[] = [];16 async push(job: object) {17 this.queue.push(job);18 }19}
Wire it up:
1alepha.with({2 provide: QueueProvider,3 use: alepha.isProduction() ? RedisQueueProvider : MemoryQueueProvider,4});
Alepha ships with standard providers for common needs: file storage (alepha/bucket), job queues (alepha/queue), logging (alepha/logger), caching (alepha/cache), and more. Each has memory implementations for testing.
#Writing Your Own Primitive with $context
A primitive is a plain function that reads the container it is being built in. $context() returns that container (alepha), the class declaring the property (service, the class itself, not the instance) and its $module (module, if any). $env, $mode and createMiddleware are built on it.
1import { $context } from "alepha"; 2 3const $flag = (name: string): boolean => { 4 const { alepha } = $context(); 5 return alepha.isEnvEnabled(name); 6}; 7 8class Checkout { 9 beta = $flag("CHECKOUT_BETA"); // true for CHECKOUT_BETA=1, false for CHECKOUT_BETA=false10}
$context() only answers while the container is instantiating a class, synchronously, which is exactly when a property initializer runs. Called anywhere else (in a method, after an await, at module top level) it throws MissingContextError.
Build custom primitives on $context(). The symbols and the mutable cursor exported by alepha/internal are implementation details with no semver guarantee.
#Portable dates and files
DateTimeProvider creates immutable DateTime and Duration values. Unit types
are Alepha-owned DateTimeUnit and DurationUnit; no date engine is part of the
public contract. Pass a string, timestamp, Date or DateTime to of() or
utc(). A provider owns its locale: setLocale("en") affects only that
container, and date.locale("en") returns a per-call override. Existing values
keep their locale. A paused clock applies the provider's current locale when
read. of(existingDateTime) preserves identity when its locale already matches.
FileSystemProvider.readFile() returns Uint8Array. Use readTextFile() for
text, or decode bytes with TextDecoder. MemoryFileSystemProvider works in a
browser without Buffer; its files map stores byte arrays. A FileLike and
readFileStream() return Web ReadableStream<Uint8Array>. Node integrations
convert native streams at the boundary with Readable.toWeb() and
Readable.fromWeb().
#Stable service IDs
Ordinary application code injects a class: alepha.inject(MyService). Tooling
that loads a separately bundled application can use a stable string ID instead.
Declare static readonly id: string = "myApp.MyService" on the service and call
alepha.inject<MyService>(MyService.id). Class names are never matched, so
minification and renaming do not change the contract. Subclasses inherit the ID,
and provide/use substitutions resolve through the same ID. An unknown ID
throws an error naming it.