Dependency Injection

Context.Tag + Layer — NestJS-style modularity with functional composition

The Pattern

Gello's dependency injection takes inspiration from NestJS's service-oriented architecture, but implements it using Effect's functional primitives. Instead of decorators and a runtime container, you get Context.Tag to define service interfaces and Layer to provide implementations — all type-safe and composable.

Defining Services

import { Context, Effect, Layer } from "effect"

// 1) Define the service interface with Context.Tag
class UserRepo extends Context.Tag("UserRepo")<
  UserRepo,
  {
    findById: (id: string) => Effect.Effect<User | null>
    create: (data: CreateUser) => Effect.Effect<User>
    list: (opts: ListOpts) => Effect.Effect<User[]>
  }
>() {}

// The tag IS the type — no separate interface needed

Implementing Layers

// Layer.effect — sync/async implementation
const UserRepoLive = Layer.effect(
  UserRepo,
  Effect.gen(function* () {
    // Pull in dependencies
    const db = yield* Db
    const redis = yield* Redis

    return {
      findById: (id) =>
        Effect.tryPromise(async () => {
          // Check cache first
          const cached = await redis.get(`user:${id}`)
          if (cached) return JSON.parse(cached) as User

          // Fall back to DB
          const [row] = await db.select().from(users).where(eq(users.id, id))
          if (row) await redis.set(`user:${id}`, JSON.stringify(row), { EX: 60 })
          return row ?? null
        }),

      create: (data) =>
        Effect.tryPromise(async () => {
          const [row] = await db.insert(users).values(data).returning()
          await redis.set(`user:${row.id}`, JSON.stringify(row), { EX: 60 })
          return row
        }),

      list: ({ page, limit }) =>
        Effect.tryPromise(() =>
          db.select().from(users).limit(limit).offset((page - 1) * limit)
        )
    }
  })
)

// Declare dependencies
const UserRepoWithDeps = UserRepoLive.pipe(
  Layer.provide(DbLive),
  Layer.provide(RedisLive)
)

Scoped Resources

import { Pool } from "pg"

// Layer.scoped — for resources that need cleanup
class PgPool extends Context.Tag("PgPool")<PgPool, Pool>() {}

const PgPoolLive = Layer.scoped(
  PgPool,
  Effect.acquireRelease(
    // Acquire: create the pool
    Effect.gen(function* () {
      const cfg = yield* Config
      const pool = new Pool({ connectionString: cfg.DATABASE_URL })
      yield* Effect.log("PgPool connected")
      return pool
    }),
    // Release: close on shutdown
    (pool) =>
      Effect.tryPromise(() => pool.end()).pipe(
        Effect.tap(() => Effect.log("PgPool closed")),
        Effect.orDie
      )
  )
).pipe(Layer.provide(ConfigLive))

Drizzle on Top of Pool

import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"

class Db extends Context.Tag("Db")<Db, NodePgDatabase>() {}

// Layer.effect — drizzle() is sync, pool lifecycle is handled by PgPoolLive
const DbLive = Layer.effect(
  Db,
  Effect.gen(function* () {
    const pool = yield* PgPool
    return drizzle(pool)
  })
).pipe(Layer.provide(PgPoolLive))

Using in Handlers

HttpRouter.get("/users/:id", Effect.gen(function* () {
  // Just yield* the tag — Effect tracks what's needed
  const repo = yield* UserRepo

  const { id } = yield* HttpRouter.schemaPathParams(S.Struct({ id: S.String }))
  const user = yield* repo.findById(id)

  if (!user) return HttpServerResponse.empty({ status: 404 })
  return yield* HttpServerResponse.schemaJson(User)(user)
}))

Composing at the Edge

// All layers merge at one point
const AppLayer = Layer.mergeAll(
  ConfigLive,
  PgPoolLive,
  DbLive,
  RedisLive,
  UserRepoLive
)

// Provide to your server
const MainLayer = pipe(
  HttpServer.serve(HttpApp),
  Layer.provide(AppLayer),
  Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 }))
)

// Launch — resources acquired, then released on shutdown
Layer.launch(MainLayer).pipe(NodeRuntime.runMain)

Testing with Mocks

// Create a mock layer
const UserRepoTest = Layer.succeed(UserRepo, {
  findById: (id) => Effect.succeed({ id, name: "Test User", email: "test@test.com" }),
  create: (data) => Effect.succeed({ id: "123", ...data }),
  list: () => Effect.succeed([])
})

// Swap in tests
const testEffect = Effect.gen(function* () {
  const repo = yield* UserRepo
  const user = yield* repo.findById("1")
  expect(user?.name).toBe("Test User")
})

await Effect.runPromise(
  testEffect.pipe(Effect.provide(UserRepoTest))
)

Why No Modules?

A "Module" abstraction would add indirection without benefit. With plain Layers:

  • Dependency graph is explicit in the type system
  • No runtime container — just function composition
  • Testing is trivial — swap any Layer
  • Resource lifecycle is guaranteed by Effect