Implementing type-safe dependency injection in a Hono backend with Effect-TS's Layer system
When working with NestJS long enough, decorator fatigue sets in — @Injectable(), @Module(), reflect-metadata. The DI container itself is a great idea, but the fact that dependency errors only surface at runtime always felt unsettling. It wasn't until I hit Nest can't resolve dependencies in production that I truly appreciated the value of compile-time validation.
Effect-TS's Layer system solves this problem at the type system level. If the dependency graph is composed incorrectly, tsc blocks the build. Error types are declared in function signatures, eliminating dark zones like catch (e: unknown). Combined with a lightweight framework like Hono, you can build a testable, structured backend architecture without any framework magic.
This article walks through the three type parameters of Effect<A, E, R>, the Service · Layer · Runtime pattern, using ManagedRuntime in Hono route handlers, and testing without mocks by swapping layers.
Core Concepts
Effect's Three Type Parameters — One Line Says It All
Effect<A, E, R>
// │ │ └── Requirements: dependencies needed for execution
// │ └───── Error: expected domain errors
// └──────── Success: return value on successThe R parameter is the key that lifts dependency injection to the type level. If R = Database | Cache, the compiler knows that both Database and Cache must be provided to run this Effect. Without them, even runPromise won't compile.
E encodes expected domain errors. They're expressed as union types like UserNotFoundError | DatabaseError, and leaving them unhandled causes a type error. Unexpected faults (defects) are separated into a distinct Cause type, allowing domain errors and infrastructure failures to be handled independently.
Defining Services with Context.Tag
Where NestJS's @Injectable() relies on reflect-metadata, Effect's Context.Tag works through pure type inference.
First, define the domain types and error types used throughout the examples.
import { Context, Effect } from "effect"
// Domain types
interface User {
id: string
name: string
email: string
}
interface UserProfile extends User {
displayName: string
}
// Domain error types — a discriminated union via _tag
class UserNotFoundError {
readonly _tag = "UserNotFoundError"
constructor(readonly userId: string) {}
}
class DatabaseError {
readonly _tag = "DatabaseError"
constructor(readonly cause: unknown) {}
}
type AppError = UserNotFoundError | DatabaseErrorService interfaces are defined using a class pattern that extends Context.Tag.
// UserRepository service — the Tag itself is the identifier
class UserRepository extends Context.Tag("UserRepository")<
UserRepository,
{
readonly findById: (id: string) => Effect.Effect<User, UserNotFoundError | DatabaseError>
readonly findAll: () => Effect.Effect<User[], DatabaseError>
readonly save: (user: User) => Effect.Effect<void, DatabaseError>
}
>() {}
// Business logic that uses the service
const getUserProfile = (
userId: string
): Effect.Effect<UserProfile, UserNotFoundError | DatabaseError, UserRepository> =>
Effect.gen(function* () {
const repo = yield* UserRepository
const user = yield* repo.findById(userId)
return { ...user, displayName: `${user.name} (${user.email})` }
})The yield* syntax in Effect.gen may look unfamiliar at first. Think of it as the functional equivalent of async/await — it clicks quickly. yield* UserRepository is a request to "pull out the UserRepository implementation from the runtime," and its type is automatically reflected in the R parameter of the function signature.
Layer — A Recipe for Composing Services
Layer<ROut, E, RIn> is a recipe that says "to build this service (ROut), you need this (RIn), and this error (E) may occur." The key insight is that it describes how to construct a service, not the implementation itself.
Before composing layers, define the Database and AppConfig services as well.
import { Context, Effect, Layer, Config } from "effect"
// AppConfig service definition
class AppConfig extends Context.Tag("AppConfig")<
AppConfig,
{
readonly databaseUrl: string
readonly jwtSecret: string
}
>() {}
// Database service definition
class Database extends Context.Tag("Database")<
Database,
{
readonly query: (query: string, params: unknown[]) => Effect.Effect<unknown[], DatabaseError>
}
>() {}Now define the layer (implementation) for each service.
import postgres from "postgres"
// Config layer — reads environment variables in a type-safe way
const ConfigLive = Layer.effect(
AppConfig,
Effect.gen(function* () {
const databaseUrl = yield* Config.string("DATABASE_URL")
const jwtSecret = yield* Config.string("JWT_SECRET")
return { databaseUrl, jwtSecret }
})
)
// Database layer — depends on AppConfig
const DatabaseLive = Layer.effect(
Database,
Effect.gen(function* () {
const config = yield* AppConfig
const sql = postgres(config.databaseUrl)
return {
query: (query: string, params: unknown[]) =>
Effect.tryPromise({
try: () => sql.unsafe(query, params as postgres.ParameterOrFragment<never>[]),
catch: (e) => new DatabaseError(e),
}),
}
})
)
// UserRepository layer — depends on Database
const UserRepositoryLive = Layer.effect(
UserRepository,
Effect.gen(function* () {
const db = yield* Database
return {
findById: (id: string) =>
Effect.gen(function* () {
const rows = yield* db.query("SELECT * FROM users WHERE id = $1", [id])
if (rows.length === 0) return yield* Effect.fail(new UserNotFoundError(id))
return rows[0] as User
}),
findAll: () =>
db.query("SELECT * FROM users", []).pipe(
Effect.map((rows) => rows as User[])
),
save: (user: User) =>
db
.query("INSERT INTO users VALUES ($1, $2, $3)", [user.id, user.name, user.email])
.pipe(Effect.map(() => undefined)),
}
})
)At the type level, Layer.provide(that) satisfies the portion of the current layer's RIn requirements that that provides. Through the chain, RIn is progressively eliminated until the final layer's RIn approaches never. Any type mismatch is caught immediately by the compiler.
// Declaratively compose the dependency graph
const AppLayer = UserRepositoryLive.pipe(
Layer.provide(DatabaseLive), // satisfies UserRepository's Database requirement
Layer.provide(ConfigLive) // satisfies Database's AppConfig requirement
)In the diagram below, arrows indicate the "provide" direction. ConfigLive provides AppConfig to DatabaseLive, and DatabaseLive provides Database to UserRepositoryLive.
ManagedRuntime — Making a Layer Executable
The "Managed" in ManagedRuntime means resource acquisition and release are managed as a lifecycle. When the layer initializes, a DB connection is opened; when dispose() is called, the connection is cleaned up. This is why AppRuntime.dispose() makes sense.
A Layer is just a recipe — it's ManagedRuntime's job to actually instantiate services and run Effects. The standard pattern is to create it once at app startup and reuse it for all requests.
import { ManagedRuntime } from "effect"
// Created once at app startup — reused for all subsequent requests
const AppRuntime = ManagedRuntime.make(AppLayer)
// Clean up resources on shutdown
process.on("SIGTERM", () => AppRuntime.dispose())Practical Application
Integrating with Hono
Each Hono route handler is an async function, so it connects naturally with runPromiseExit. AppRuntime is declared in module scope and referenced via closure.
First, create a helper that maps errors to HTTP responses. Using Exit.match and Cause.failureOption is the idiomatic Effect pattern. Fixing the error parameter to the concrete type AppError makes the switch statement exhaustive, so the compiler catches any unhandled error cases.
import { Hono, type Context as HonoContext } from "hono"
import { Cause, Effect, Exit, ManagedRuntime, Option } from "effect"
// AppRuntime — created once in module scope
const AppRuntime = ManagedRuntime.make(AppLayer)
// Error parameter fixed to concrete type — guarantees exhaustive switch
const handleEffect = async <A>(
c: HonoContext,
effect: Effect.Effect<A, AppError>
) => {
const exit = await AppRuntime.runPromiseExit(effect)
return Exit.match(exit, {
onSuccess: (value) => c.json(value),
onFailure: (cause) => {
const maybeError = Cause.failureOption(cause)
if (Option.isNone(maybeError)) {
return c.json({ error: "Internal Server Error" }, 500)
}
const error = maybeError.value
// AppError = UserNotFoundError | DatabaseError
// Both cases are covered, so no default branch is needed
switch (error._tag) {
case "UserNotFoundError":
return c.json({ error: `User ${error.userId} not found` }, 404)
case "DatabaseError":
return c.json({ error: "Database error" }, 500)
}
},
})
}
const app = new Hono()
app.get("/users/:id", async (c) => {
const userId = c.req.param("id")
return handleEffect(c, getUserProfile(userId))
})
app.get("/users", async (c) => {
const effect = Effect.gen(function* () {
const repo = yield* UserRepository
return yield* repo.findAll()
})
return handleEffect(c, effect)
})
app.post("/users", async (c) => {
// In real implementations, validate the body with @hono/effect-validator or zod first.
const body = await c.req.json<{ name: string; email: string }>()
const effect = Effect.gen(function* () {
const repo = yield* UserRepository
const user: User = { id: crypto.randomUUID(), name: body.name, email: body.email }
yield* repo.save(user)
return user
})
return handleEffect(c, effect)
})
export default appBelow is the full flow of a GET /users/:id request.
Test Isolation via Layer Swapping — No Mocking Required
Creating a test layer eliminates the need for any mocking library. The same business logic can run without a real database.
import { it, expect } from "@effect/vitest"
import { Effect, Layer } from "effect"
// In-memory implementation — same interface, different implementation
const UserRepositoryTest = Layer.succeed(UserRepository, {
findById: (id: string) =>
id === "existing-user"
? Effect.succeed({ id, name: "Test User", email: "test@example.com" })
: Effect.fail(new UserNotFoundError(id)),
findAll: () =>
Effect.succeed([
{ id: "1", name: "Alice", email: "alice@example.com" },
{ id: "2", name: "Bob", email: "bob@example.com" },
]),
save: (_user: User) => Effect.succeed(undefined),
})
// Just as you compose multiple services in the real app, combine test layers with Layer.mergeAll.
// Here we only have UserRepository, but EmailService, CacheService, etc. can be added the same way.
const TestLayer = Layer.mergeAll(UserRepositoryTest)
it.effect("returns a profile for an existing user", () =>
Effect.gen(function* () {
const profile = yield* getUserProfile("existing-user")
expect(profile.displayName).toBe("Test User (test@example.com)")
}).pipe(Effect.provide(TestLayer))
)
it.effect("returns UserNotFoundError for a non-existent user", () =>
Effect.gen(function* () {
// Effect.either wraps errors as Left and successes as Right, treating them as values.
// Similar to Rust's Result or Go's (value, error) tuple.
const result = yield* getUserProfile("unknown-user").pipe(Effect.either)
expect(result._tag).toBe("Left")
if (result._tag === "Left") {
expect(result.left._tag).toBe("UserNotFoundError")
expect(result.left.userId).toBe("unknown-user")
}
}).pipe(Effect.provide(TestLayer))
)Integrating with Drizzle ORM — An Alternative Implementation
In production, raw SQL is often replaced with Drizzle ORM. @effect/sql-drizzle lets you use Drizzle's type-safe query builder on top of an Effect connection pool. This section is an alternative implementation of UserRepositoryLive. The rest — ConfigLive and the layer composition approach — remains the same.
import { PgClient } from "@effect/sql-pg"
import { DrizzlePgClient } from "@effect/sql-drizzle/Pg"
import { eq } from "drizzle-orm"
import { pgTable, text, varchar } from "drizzle-orm/pg-core"
import { Config, Effect, Layer } from "effect"
const usersTable = pgTable("users", {
id: varchar("id", { length: 36 }).primaryKey(),
name: text("name").notNull(),
email: text("email").notNull(),
})
const PgLive = PgClient.layerConfig({
url: Config.string("DATABASE_URL"),
})
const DrizzleLive = DrizzlePgClient.layer.pipe(Layer.provide(PgLive))
// Drizzle implementation of UserRepository — an alternative to UserRepositoryLive
const UserRepositoryDrizzleLive = Layer.effect(
UserRepository,
Effect.gen(function* () {
const db = yield* DrizzlePgClient
return {
findById: (id: string) =>
Effect.gen(function* () {
const rows = yield* db.select().from(usersTable).where(eq(usersTable.id, id))
if (rows.length === 0) return yield* Effect.fail(new UserNotFoundError(id))
return rows[0]
}),
findAll: () => db.select().from(usersTable),
save: (user: User) =>
db.insert(usersTable).values(user).pipe(Effect.map(() => undefined)),
}
})
)
// Final Drizzle-based layer — an alternative to AppLayer (the raw SQL version)
const AppLayerDrizzle = UserRepositoryDrizzleLive.pipe(Layer.provide(DrizzleLive))Trade-off Analysis
When Is Effect-TS Worth Considering
| Area | Pros | Cons |
|---|---|---|
| Dependency validation | Missing dependencies caught at compile time | Layer composition has a learning curve |
| Error typing | Domain errors are explicit in function signatures | You must design error types yourself |
| Testability | Layer swapping eliminates the need for mocks | Initial cost of writing test layers |
| Concurrency | Fiber-based structured concurrency | Stack traces can be awkward to debug |
| Bundle size | @effect/micro available for edge environments |
The core effect package itself has size |
| Learning curve | Consistent functional API, patterns repeat | Fiber, Layer, Cause, Schedule all at once |
Common Mistakes in Practice
1. Creating ManagedRuntime on Every Request
// Wrong pattern — a new DB connection pool is created per request
app.get("/users", async (c) => {
const runtime = ManagedRuntime.make(AppLayer) // ❌
return c.json(await runtime.runPromise(getAllUsers))
})
// Correct pattern — created once in module scope
const AppRuntime = ManagedRuntime.make(AppLayer) // ✅
app.get("/users", async (c) => {
return c.json(await AppRuntime.runPromise(getAllUsers))
})2. Handling Promises Directly Inside an Effect Chain
The callback in Effect.gen is a plain function*, not an async function*. This means await is a syntax error inside it. Promises must be wrapped with Effect.tryPromise (with error handling) or Effect.promise (when no errors are guaranteed), and the result must be received with yield*.
// Compile error — await is a syntax error inside function*
const fetchUser = (id: string) =>
Effect.gen(function* () {
const user = await fetch(`/api/users/${id}`).then((r) => r.json()) // ❌ SyntaxError
return user
})
// Correct — lift the Promise into an Effect, then run it with yield*
const fetchUser = (id: string) =>
Effect.tryPromise({
try: () => fetch(`/api/users/${id}`).then((r) => r.json()),
catch: (e) => new FetchError(e),
})Note that Effect.tryPromise is for async work that returns a Promise, while Effect.try is for synchronous work that may throw. They are separate APIs.
3. Overusing runSync When Partially Adopting Effect in an Existing Codebase
Effect has an "infectious" quality similar to async/await. Just as calling an async function requires the caller to also be async, calling a function that returns an Effect naturally requires the caller to return an Effect as well. Forcing a break in this propagation with runSync will throw an exception when encountering an async Effect.
// Using runSync on an async Effect throws at runtime
const result = Effect.runSync(someAsyncEffect) // ❌
// Progressively convert the call stack to Effect, or define a clear boundary and escape with runPromise
const result = await Effect.runPromise(someAsyncEffect) // ✅Closing Thoughts
Effect-TS's Layer system realizes the principle — "dependency errors at compile time, error handling enforced by the type system" — within TypeScript. Without NestJS's IoC container, you can build a structured, testable backend on a lightweight framework like Hono.
Key takeaways:
Effect<A, E, R>: Encodes success value, error, and dependencies in a single typeContext.Tag: Identifies services in pure TypeScript without decoratorsLayer: A recipe for composing services;Layer.provideprogressively eliminatesRInto assemble the dependency graphManagedRuntime: An execution context that manages resource lifecycles — created once at the app level and reused- Layer-swap testing: Run the same logic in two environments without any mocking library
When you first encounter Effect, yield* and Layer.provide chains feel unfamiliar. Once the patterns click, they feel far more intuitive than the @Module and @Inject dance in NestJS.
If you're starting today, this is the recommended order:
- Start with
Effect.genandContext.Tag: Rewrite one piece of existing business logic in Effect to get comfortable withyield*syntax and the three type parameters. - Write a test layer with
Layer.succeed: Running the same logic without a real database immediately demonstrates the value of Layers. - Wire it into Hono with
ManagedRuntime: Build a helper that maps errors to HTTP status codes usingrunPromiseExit, and the architecture is complete.
The Effect ecosystem is maturing rapidly, and the official Discord community responds quickly to technically serious questions. If you're feeling the fatigue of catch (e: unknown) in a long-lived production backend, the Layer system may be a meaningful alternative worth exploring.
References
- Effect Official Docs — Managing Services
- Effect Official Docs — Managing Layers
- Effect Official Docs — Runtime
- Effect Official Docs — Fibers
- ManagedRuntime API Reference
- Effect-TS GitHub Repository
- Managed runtime — Effect Beginner's Complete Getting Started (typeonce.dev)
- Intro To Effect, Part 3: Managing Dependencies (ybogomolov.me)
- Building a backend with Effect-TS: 6 patterns from Inkpipe
- Why use Effect? 5 compelling reasons (tobyhobson.com)
- Why We Love Functional Programming but Don't Use Effect-TS (Harbor Blog)
- Effect-TS in 2026 — DEV Community
- Integrating Effect with Hono: Best Practices and Tips (answeroverflow.com)
- Drizzle ORM — Effect PostgreSQL Getting Started Guide
- @hono/effect-validator (npm)
- Building an Effect Runtime: Fibers and Structured Concurrency — DEV Community