Darna Docs

Backend

Hono on Cloudflare Workers, Effect HttpApi for the API surface, drizzle-orm via Hyperdrive for Postgres.

@darna/backend is a single Cloudflare Worker. The Worker handler runs Hono; Hono delegates /api/* to Effect's HttpApi, which is built from feature modules. Each feature owns its schema, errors, repository, service, layer, and HTTP wiring — no shared "controllers" folder.

Layout

apps/backend/src/
├── worker.ts                     Cloudflare entrypoint (instrumented)
├── server.ts                     Hono app, /openapi, /docs (Scalar)
├── api.ts                        HttpApi composition
├── features/
│   ├── todo/
│   │   ├── schema/               Effect Schema models + tagged errors
│   │   ├── repository/           interface + db / memory impls
│   │   ├── service/              Effect.Service (business logic + spans)
│   │   ├── layer/                wires service ↔ repository
│   │   └── http/                 HttpApiGroup + handlers
│   ├── project/                  same shape
│   └── admin/                    bearer-auth + /admin/me
├── lib/
│   ├── auth/workos.ts            jose + JWKS verifier
│   ├── db/client.ts              AsyncLocalStorage db proxy
│   ├── db/schema.ts              drizzle schema
│   └── effect/
│       ├── tracing.ts            TracingLayer (lazy global tracer)
│       └── storage.ts            tryDb — Effect wrapper over drizzle
└── scripts/migrate.ts            tsx-run drizzle migrator

HttpApi composition

The whole API is one declarative tree. Api lives in src/api.ts:

export class Api extends HttpApi.make("darna")
  .add(TodoApi)
  .add(ProjectApi)
  .add(AdminApi)
  .prefix("/api") {}

Each *Api is an HttpApiGroup, declaring endpoints with Effect Schema for path params, payloads, success types, and error types. The schema is the contract — OpenAPI is derived from it, and the typed web client consumes that OpenAPI.

// features/todo/http/todo.api.ts
export class TodoApi extends HttpApiGroup.make("todo")
  .add(HttpApiEndpoint.get("list", "/todos").addSuccess(Schema.Array(Todo)))
  .add(
    HttpApiEndpoint.get("get", "/todos/:id")
      .setPath(IdParam)
      .addSuccess(Todo)
      .addError(TodoNotFound),
  )
  .add(
    HttpApiEndpoint.post("create", "/todos")
      .setPayload(CreateTodo)
      .addSuccess(Todo, { status: 201 }),
  ) {
  // ...
}

Handlers live separately and provide implementations for the endpoints declared in the group:

// features/todo/http/todo.handlers.ts
const TodoHandlersLive = HttpApiBuilder.group(Api, "todo", (handlers) =>
  handlers
    .handle("list", () => Todos.list().pipe(Effect.map((arr) => [...arr])))
    .handle("get", ({ path }) => Todos.getById(path.id))
    .handle("create", ({ payload }) => Todos.create(payload))
    .handle("update", ({ path, payload }) => Todos.update(path.id, payload))
    .handle("remove", ({ path }) => Todos.remove(path.id)),
);

export const TodoHandlers = TodoHandlersLive.pipe(Layer.provide(TodosLive));

server.ts glues it together:

const ApiLive = HttpApiBuilder.api(Api).pipe(
  Layer.provide([TodoHandlers, ProjectHandlers, AdminHandlers]),
  Layer.provide(TracingLayer),
);

const { handler: apiHandler } = HttpApiBuilder.toWebHandler(
  Layer.mergeAll(ApiLive, HttpApiBuilder.Router.Live, HttpServer.layerContext),
);

export const app = new Hono()
  .use("*", cors(/* ... */))
  .get("/health", (c) => c.json({ ok: true }))
  .get("/openapi", (c) => c.json(spec as object))
  .get("/docs", Scalar({ url: "/openapi", pageTitle: "Darna Backend — API" }))
  .all("/api/*", (c) => apiHandler(c.req.raw));

Feature module shape

Every feature follows the same vertical slice. Walking through todo/:

FileRole
schema/todo.model.tsTodoId (branded UUID), Todo, CreateTodo, UpdateTodo — all Effect Schema
schema/todo.errors.tsTodoNotFound (Schema.TaggedError annotated with HTTP status: 404)
repository/todo.repository.tsTodoRepo interface + Context.Tag for it
repository/todo.repository.db.tsdrizzle-backed implementation
repository/todo.repository.memory.tsin-memory impl for tests
service/todo.service.tsEffect.Service wrapping the repo, adds spans, translates "row not found" → TodoNotFound
layer/todo.layer.tsTodosLive (db repo) and TodosMemory(seed) (test repo)
http/todo.api.tsHttpApiGroup declaring endpoints
http/todo.handlers.tsHttpApiBuilder.group(Api, "todo", …) — the implementation

The repository interface decouples the service from drizzle. Tests provide TodosMemory(seed) and exercise services without a database; production provides TodosLive and gets real persistence.

// features/todo/service/todo.service.ts
export class Todos extends Effect.Service<Todos>()("Todos", {
  accessors: true,
  effect: Effect.gen(function* () {
    const repo = yield* TodoRepository;

    const list = (): Effect.Effect<readonly Todo[]> =>
      repo.list().pipe(Effect.withSpan("Todos.list"));

    const getById = (id: TodoId): Effect.Effect<Todo, TodoNotFound> =>
      Effect.gen(function* () {
        const todo = yield* repo.findById(id);
        if (!todo) return yield* Effect.fail(new TodoNotFound({ id }));
        return todo;
      }).pipe(Effect.withSpan("Todos.getById", { attributes: { "todo.id": id } }));

    // ...
    return { list, getById /* ... */ } as const;
  }),
}) {}

Database access

apps/backend/src/lib/db/client.ts exports a db proxy that resolves at property-access time from an AsyncLocalStorage slot:

const dbStore = new AsyncLocalStorage<Db>();

export const runWithDb = <T>(db: Db, fn: () => T): T => dbStore.run(db, fn);

export const db = new Proxy({} as Db, {
  get: (_t, prop, receiver) => {
    const d = dbStore.getStore();
    if (!d) throw new Error("db accessed outside of a request scope");
    return Reflect.get(d, prop, receiver);
  },
});

The Worker handler establishes the request-scoped drizzle instance once per request:

// worker.ts
async fetch(request, env, ctx) {
  const client = new Client({ connectionString: env.HYPERDRIVE.connectionString });
  await client.connect();
  const db = drizzle(client, { schema });
  try {
    return await runWithDb(db, () => app.fetch(request, env, ctx));
  } finally {
    ctx.waitUntil(client.end());
  }
}

Every repository in features/*/repository/*.repository.db.ts imports the proxy and uses it normally — drizzle queries don't need to know about AsyncLocalStorage. Hyperdrive's connection string changes each request (it points at a pooled connection), so the per-request Client is intentional, not optional.

tryDb(name, run) in lib/effect/storage.ts wraps a drizzle promise in an Effect, adds an OTel span, and converts thrown errors into a StorageError defect (so they fail loud, not as recoverable typed errors):

list: () =>
  tryDb("pg.todos.list", () => db.select().from(todos)).pipe(
    Effect.map((rows) => rows.map(rowToTodo)),
  );

Worker entrypoint

worker.ts is wrapped with @microlabs/otel-cf-workers so every request gets a trace, and so OpenTelemetry's global API is plugged into a real TracerProvider for the duration of the request:

const handler = {
  async fetch(request, env, ctx) {
    /* runWithDb + app.fetch as above */
  },
} satisfies ExportedHandler<Env>;

const config: ResolveConfigFn<Env> = (env) => {
  const exporter = new OTLPExporter({
    url: `${env.OTEL_EXPORTER_OTLP_ENDPOINT.replace(/\/$/, "")}/v1/traces`,
    headers: { Authorization: env.GRAFANA_OTEL_AUTH_HEADER },
  });
  return {
    spanProcessors: [makePropagateRouteProcessor(), new BatchTraceSpanProcessor(exporter)],
    service: {
      name: "darna-backend",
      namespace: env.OTEL_DEPLOYMENT_ENV ?? "production",
      version: "0.0.0",
    },
  };
};

export default instrument(handler, config);

makePropagateRouteProcessor copies http.route from Effect's http.server child span up to the @microlabs root, so trace lists show GET /todos instead of fetchHandler GET. See Tracing.

Routes

MethodPathAuthNotes
GET/healthLiveness check (Hono direct)
GET/openapiOpenAPI 3.1 spec from OpenApi.fromApi(Api)
GET/docsScalar API reference UI
GET/api/todosList
GET/api/todos/:idReturns 404 TodoNotFound
POST/api/todos201
PATCH/api/todos/:id
DELETE/api/todos/:id204
GET/api/projectsSame shape as todos
GET/api/admin/meBearerWorkOS-verified

Adding a feature

Copy features/todo/ into features/<thing>/, then:

  1. Edit schema/<thing>.model.ts — define your domain types as Effect Schema.
  2. Edit schema/<thing>.errors.ts — declare typed errors with HTTP status annotations.
  3. Edit repository/<thing>.repository.ts — declare the repo interface and Context.Tag.
  4. Edit repository/<thing>.repository.db.ts — implement against drizzle using tryDb(...) for span coverage.
  5. Edit service/<thing>.service.tsEffect.Service with accessors: true. Wrap each method with Effect.withSpan(...).
  6. Edit layer/<thing>.layer.tsLive and Memory(seed) layers.
  7. Edit http/<thing>.api.tsHttpApiGroup with endpoints.
  8. Edit http/<thing>.handlers.tsHttpApiBuilder.group(Api, "<thing>", …).
  9. Add to src/api.ts: .add(<Thing>Api).
  10. Add the handlers to server.ts: Layer.provide([… ThingHandlers …]).

If your feature talks to the database, add migrations next to lib/db/schema.ts and run pnpm db:generate && pnpm db:migrate.

What runs at module load vs per-request

  • Module load (cold start)Api, ApiLive, apiHandler, app, the instrument(...) wrapper, the JWKS object (lazily, on first verification).
  • Per-request — drizzle Client + connection, runWithDb scope, every Effect span, JWT verification.

This matters mainly for db and trace.getActiveSpan() — both only work inside a request, never at module load.

On this page