E/learn effect

Services & Layers

Learn what Services and Layers are, write code against a capability, and choose its implementation at the application boundary.

Keep this modelprogram asks · service describes · value implements · layer provides

If you come from regular TypeScript, think of a service as an interface for a dependency: it says which operations are available, but not whether they use PostgreSQL, an HTTP API, or an in-memory Map. A Layer is the wiring that constructs one implementation and makes it available to the program.

The distinction gives us an important freedom: we can write the business program before choosing any implementation. The program can say “I need a UserRepository” without knowing how one will be built. At the edge of the application we can provide a production Layer, while a test provides a small in-memory Layer. The business program stays unchanged.

In a familiar CRUD application, services might include a TaskRepository that saves tasks, a FileStorage that stores attachments, or a PaymentGateway that charges a card. These are useful boundaries because callers care about the capability, while the application decides which infrastructure supplies it.

The four pieces

The word “service” is sometimes used loosely, so keep these four pieces separate:

1 · Program

Business logic

Asks for a capability. It does not construct or select the implementation.

Effect<A, E, UserRepository>
2 · Service

Contract and identity

Describes the available operations and gives the capability a unique Context key.

UserRepository
3 · Service value

Concrete implementation

An object containing the actual methods. It may talk to a database, call an API, or keep data in memory.

UserRepository.of(...)
4 · Layer

Construction recipe

Builds a service value, supplies it to Context, and can manage its dependencies and lifetime.

Layer<UserRepository, E, R>

Define the capability

Start with the operations callers need. Do not choose SQL, in-memory storage, or a test fake yet. This is the Effect equivalent of depending on an interface rather than a concrete class.

user-repository.ts
import { Context, Data, Effect, Layer, Option } from "effect"

export type User = {
  readonly id: string
  readonly name: string
  readonly email: string
}

export class UserRepositoryError extends Data.TaggedError("UserRepositoryError")<{
  readonly cause: unknown
}> {}

interface UserRepositoryShape {
  readonly list: Effect.Effect<ReadonlyArray<User>, UserRepositoryError>
  readonly findById: (
    id: string
  ) => Effect.Effect<Option.Option<User>, UserRepositoryError>
}

export class UserRepository extends Context.Service<
  UserRepository,
  UserRepositoryShape
>()("app/UserRepository") {}

The method types are Effects because repository work can fail. Read the first one as: “listing users eventually produces a readonly array, may fail with UserRepositoryError, and has no additional requirements of its own.” The repository itself will appear as a requirement of programs that ask for it.

Write the program first

Now use the service even though no Layer exists yet:

get-user.ts
import { Effect } from "effect"
import { UserRepository } from "./user-repository"

const getUser = Effect.fn("getUser")(function*(id: string) {
  // Yielding the tag retrieves whichever UserRepository value was provided.
  const users = yield* UserRepository

  // Effect<Option<User>, UserRepositoryError>
  return yield* users.findById(id)
})

// Effect<Option<User>, UserRepositoryError, UserRepository>
const program = getUser("user-1")

Nothing is missing from this program. Its type honestly records that it still requires UserRepository. You can compose it with more Effects, add retries, or handle UserRepositoryError without deciding where users are stored.

This is the central workflow:

  1. Define the capability.
  2. Write business logic against that capability.
  3. Choose and provide an implementation only at the boundary that runs the program.

Build a production Layer

We now need a service value with real methods. The production implementation reads Database while it is being built, then exposes repository operations instead of leaking database or ORM details to callers.

This continues the same user-repository.ts module:

user-repository.ts
export class UserRepository extends Context.Service<
  UserRepository,
  UserRepositoryShape
>()("app/UserRepository") {
  // Layer<UserRepository, never, Database>
  //
  // This Layer provides UserRepository, but still requires Database.
  static readonly layer = Layer.effect(
    UserRepository,
    Effect.gen(function*() {
      // Just like business code can require UserRepository, this construction
      // recipe can require another service.
      const database = yield* Database

      // Effect<ReadonlyArray<User>, UserRepositoryError>
      const list = database.users.findMany.pipe(mapDatabaseError)

      const findById = Effect.fn("UserRepository.findById")(
        function*(id: string) {
          // Effect<Option<User>, UserRepositoryError>
          return yield* database.users.findFirst({ id }).pipe(mapDatabaseError)
        }
      )

      // .of() checks that this object implements UserRepositoryShape.
      // It creates a service value, not another Layer.
      return UserRepository.of({ list, findById })
    })
  )

  // Layer<UserRepository, DatabaseConnectionError, never>
  //
  // This is the ready-to-use production branch: DatabaseLayer satisfies the
  // Database requirement left by UserRepository.layer.
  static readonly layerLive = this.layer.pipe(
    Layer.provide(DatabaseLayer)
  )
}

Read Layer<UserRepository, never, Database> from left to right as:

  1. it provides UserRepository,
  2. building it cannot fail in this simplified example,
  3. it still requires Database.

After DatabaseLayer is provided, layerLive has no remaining requirements. A Layer may therefore be a small open recipe, such as layer, or a complete branch of the application graph, such as layerLive.

The names layer and layerLive are conventions local to this article, not special Effect APIs. Here layer leaves infrastructure visible as an input, while layerLive supplies the production infrastructure. A team can use different names, but each name should make remaining requirements clear.

A static property is initialized with the class and generally cannot be tree-shaken independently from it. A top-level export lets bundlers remove an unused live implementation, which is worth considering for library code. Choose whichever placement fits the module:

organization-only.ts
// Colocated with the service
class UserRepository extends Context.Service<
  UserRepository,
  UserRepositoryShape
>()("app/UserRepository") {
  static readonly layerLive = this.layer.pipe(
    Layer.provide(DatabaseLayer)
  )
}

// Or exported from the implementation module
export const UserRepositoryLive = UserRepository.layer.pipe(
  Layer.provide(DatabaseLayer)
)

Supply a different Layer without changing the program

A test does not need a database. It can provide the same service with deterministic data:

get-user.test.ts
const ada: User = {
  id: "user-1",
  name: "Ada",
  email: "ada@example.com"
}

// Layer<UserRepository>
const UserRepositoryTest = Layer.succeed(
  UserRepository,
  UserRepository.of({
    list: Effect.succeed([ada]),
    findById: (id) =>
      Effect.succeed(id === ada.id ? Option.some(ada) : Option.none())
  })
)

// The same getUser program now uses the test implementation.
const testProgram = getUser("user-1").pipe(
  Effect.provide(UserRepositoryTest)
)

The same idea applies beyond tests. You can define UserRepositoryPostgres, UserRepositoryHttp, or UserRepositoryInMemory and choose the appropriate Layer for a deployment. The service contract and its consumers do not change.

If a request should fall back to a second provider when the first one fails, make that policy part of the implementation:

user-repository-fallback.ts
const withFallback = (
  primary: UserRepository["Service"],
  backup: UserRepository["Service"]
): UserRepository["Service"] =>
  UserRepository.of({
    // Try the backup only if the primary list operation fails.
    list: primary.list.pipe(
      Effect.catchAll(() => backup.list)
    ),
    findById: (id) =>
      primary.findById(id).pipe(
        Effect.catchAll(() => backup.findById(id))
      )
  })

A Layer can construct the primary and backup values and return withFallback(primary, backup). The fallback itself belongs here because it happens each time a method runs, not only once when the Layer is built. In a real application, catch only errors that should trigger failover; validation and authorization errors usually should not.

Keep tag, value, and Layer separate

A · Tag

UserRepository

The identity used in an Effect requirement and Context lookup.

Effect<A, E, UserRepository>
B · Value

UserRepository.of(...)

A typed implementation object. .of() does not create a Layer or manage a lifetime.

UserRepository["Service"]
C · Layer

UserRepository.layerLive

A reusable recipe that contributes the value to Context and can own resources.

Layer<UserRepository, E, R>
Layer<ROut, E, RIn>
providescan fail withrequires

Understand the Layer graph

“Graph” is shorthand for dependency relationships encoded by composed Layer values. It is not a separate collection you create or mutate. Layer combinators encode which recipes must be built before other recipes can run.

application serviceUserService.layerLayer<UserService, never, UserRepository | AuditLog | EmailService>
branch AUserRepository.layerLayer<UserRepository, never, Database>
branch BAuditLog.layerLayer<AuditLog, never, Database>
branch CEmailService.layerLayer<EmailService, never, HttpClient | EmailConfig>
shared dependencyDatabaseLayerLayer<Database, DatabaseConnectionError, never>
dependencyHttpClientLayerLayer<HttpClient, never, never>
dependencyEmailConfigLayerLayer<EmailConfig, ConfigError, never>
Each card shows Layer<ROut, E, RIn>, so construction requirements stay visible in the third parameter. Arrows mean “requires in order to build”. Pan or zoom the graph, or open it fullscreen.
app-layer.ts
const RepositoryAndAudit = Layer.merge(
  UserRepository.layer,
  AuditLog.layer
).pipe(Layer.provide(DatabaseLayer))

const EmailLayer = EmailService.layer.pipe(
  Layer.provide(Layer.merge(HttpClientLayer, EmailConfigLayer))
)

const UserServiceLayer = UserService.layer.pipe(
  Layer.provide(Layer.merge(RepositoryAndAudit, EmailLayer))
)

Choose a constructor

The constructors describe when and how an implementation becomes available. They are not interchangeable style preferences.

Layer.succeed: the value already exists

No factory runs. Use it for constants, adapters already created elsewhere, and deterministic test implementations. The value is created before the Layer is built.

const UserRepositoryTest = Layer.succeed(
  UserRepository,
  UserRepository.of({
    list: Effect.succeed([]),
    findById: () => Effect.succeed(Option.none())
  })
)

Compared with Layer.sync: both produce one service and cannot fail while being built. Use succeed when you already have the value; use sync when the value should be created lazily each time the Layer is built.

Layer.sync: lazy synchronous construction

The factory runs when the Layer is built and returns the service value itself, not another Layer. It has no Effect dependencies or typed failure. Keep the factory total; thrown exceptions become defects.

const UserRepositoryInMemory = Layer.sync(UserRepository, () => {
  const users = new Map<string, User>()

  return UserRepository.of({
    list: Effect.sync(() => Array.from(users.values())),
    findById: (id) => Effect.sync(() => Option.fromUndefinedOr(users.get(id)))
  })
})

Here the mutable Map is created during Layer construction. With Layer.succeed, it would have to be created outside the Layer and could accidentally be shared between otherwise separate builds.

Layer.effect: effectful construction

Use it when construction reads another service or Config, can fail, or acquires a scoped resource. This is the normal choice for live infrastructure.

// Layer<UserRepository, never, Database>
const UserRepositoryLayer = Layer.effect(
  UserRepository,
  Effect.gen(function*() {
    const database = yield* Database
    return makeUserRepository(database)
  })
)

Effect v4 uses the same constructor for scoped resources:

const DatabaseLayer = Layer.effect(
  Database,
  Effect.acquireRelease(openDatabase, (database) => database.close)
)

Compared with Layer.unwrap: both receive an Effect. Look at what that Effect produces. If it produces one service value, use Layer.effect(Service, effect). If it produces an entire Layer, use Layer.unwrap(effect).

Layer.effectDiscard: run construction work without providing a service

Some work belongs to application startup but does not create a capability for business code to use. Layer.effectDiscard runs that work while the Layer graph is built and exposes no service afterward.

Database migrations are a typical example:

run-migrations-on-build.ts
// Layer.Layer<never, MigrationError, Database>
const RunMigrations = Layer.effectDiscard(
  Effect.gen(function*() {
    const database = yield* Database
    yield* database.runMigrations
  })
)

The type tells the whole story: RunMigrations provides never, may fail with MigrationError, and requires Database. “Discard” applies only to the success value. It does not discard errors or requirements.

RunMigrations is still only a recipe. To execute it, provide its Database dependency and use the resulting Layer in a program:

main.ts
// Build DatabaseLayer, run migrations with that Database, and retain Database
// so the application can use the same service afterward.
const DatabaseReady = RunMigrations.pipe(
  Layer.provideMerge(DatabaseLayer)
)

const program = Effect.gen(function*() {
  const database = yield* Database
  return yield* database.users.count
})

Effect.runPromise(
  program.pipe(Effect.provide(DatabaseReady))
)

Layer.provideMerge(DatabaseLayer) does two things here: it satisfies the Database requirement of RunMigrations and retains Database as an output for program. The program starts only after the Layer has been built, so migrations complete before database.users.count runs. If migration fails, Layer construction fails and the program never starts.

Within one Layer build, normal Layer memoization prevents a shared RunMigrations value from running repeatedly. A separate application start or separate Layer build runs it again.

What if we used Layer.effect instead?

Layer.effect must put a service value into Context. Because migrations produce no useful service, forcing them into that constructor requires inventing one:

migrations-as-a-service-antipattern.ts
// BAD: this service exists only to satisfy Layer.effect.
class MigrationStatus extends Context.Service<
  MigrationStatus,
  { readonly completed: true }
>()("app/MigrationStatus") {}

// Layer<MigrationStatus, MigrationError, Database>
const RunMigrations = Layer.effect(
  MigrationStatus,
  Effect.gen(function*() {
    const database = yield* Database
    yield* database.runMigrations

    // No caller needs this value.
    return MigrationStatus.of({ completed: true })
  })
)

Another use: a fail-fast startup check

When an application must not start without an external provider, a startup check can fail the Layer build without adding another service:

verify-email-provider.ts
// Layer<never, EmailProviderUnavailable, EmailService>
const VerifyEmailProvider = Layer.effectDiscard(
  Effect.gen(function*() {
    const email = yield* EmailService

    // Effect<void, EmailProviderUnavailable>
    yield* email.checkHealth
  })
)

If checkHealth fails, the application graph fails to build. If it succeeds, startup continues. There is no new Context value because EmailService already represents the capability; the check only verifies that it is ready.

Compared with Layer.effect: both run an Effect during construction and can have requirements or typed errors. Use effect when the Effect produces a service that should enter Context; use effectDiscard when only running the construction work matters.

Layer.suspend: lazily return a whole Layer

The thunk runs when the Layer is built and returns another Layer. This is useful for deferring a synchronous choice or for defining recursive Layer graphs.

deferred-layer-selection.ts
type MessageStoreMode = "memory" | "remote"

const makeMessageStoreLayer = (mode: MessageStoreMode) =>
  Layer.suspend(() =>
    // This synchronous choice is deferred until the Layer is built.
    mode === "memory" ? MessageStoreMemory : MessageStoreRemote
  )

// The application boundary has already parsed and validated this value.
const MessageStoreLayer = makeMessageStoreLayer("remote")

If mode is already known and delaying the choice has no value, use the conditional expression directly and skip Layer.suspend. Its purpose is laziness, not replacing ordinary TypeScript control flow. If the mode must be read from Effect Config, the choice is effectful and belongs in Layer.unwrap, as shown below.

Compared with Layer.sync: both accept a synchronous thunk, but their thunks return different things. Layer.sync(Service, thunk) expects a service value. Layer.suspend(thunk) expects a whole Layer.

Compared with Layer.unwrap: both produce a whole Layer. Use suspend when choosing it is synchronous; use unwrap when choosing it requires an Effect.

Layer.unwrap: effectfully return a whole Layer

Use it when deciding which Layer to use requires Config, another service, asynchronous work, or a typed failure. The outer Effect chooses the recipe; Effect then builds the Layer returned by that recipe.

effectful-selection.ts
const MessageStoreLayer = Layer.unwrap(
  Effect.gen(function*() {
    const useMemory = yield* Config.boolean("USE_MEMORY").pipe(
      Config.withDefault(false)
    )

    return useMemory ? MessageStoreMemory : MessageStoreRemote
  })
)

Compared with Layer.effect: Layer.effect(MessageStore, effect) means the Effect returns a MessageStore value. Layer.unwrap(effect) means the Effect returns something like MessageStoreMemory or MessageStoreRemote, which are already Layers.

effect-versus-unwrap.ts
// Effect<MessageStore["Service"], E, R> -> one service value
Layer.effect(MessageStore, makeMessageStore)

// Effect<Layer<MessageStore, E2, R2>, E1, R1> -> a whole Layer
Layer.unwrap(selectMessageStoreLayer)

Layer.empty: provide nothing and do nothing

Layer.empty is a neutral Layer: it has no service output, no error, no requirements, and no construction work. It is useful when a branch of a larger Layer graph is intentionally optional.

optional-observability.ts
const ObservabilityLayer = observabilityEnabled
  ? OpenTelemetryLayer
  : Layer.empty

Unlike effectDiscard, Layer.empty does not run an Effect. It represents the absence of a Layer branch rather than lifecycle work whose result is discarded.

Provide the implementation

Providing a Layer removes the services it supplies from an Effect's requirements. It does not change the program's business logic.

provide-removes-requirements.ts
// Effect.Effect<Option.Option<User>, UserRepositoryError, UserRepository>
const program = getUser("user-1")

// UserRepository.layerLive:
// Layer.Layer<UserRepository, DatabaseConnectionError, never>

// Effect.Effect<
//   Option.Option<User>,
//   UserRepositoryError | DatabaseConnectionError,
//   never
// >
const runnable = getUser("user-1").pipe(
  Effect.provide(UserRepository.layerLive)
)

Effect.runPromise(program) // Type error: UserRepository is still required
Effect.runPromise(runnable)
Stable application graph

Compose first, provide at the edge

Build a named AppLayer for production services shared by most requests, then provide it at the boundary that owns its lifetime.

Local or short-lived value

Provide close to the operation

Use local provision for tests, intentional overrides, and request values. A concrete request value normally uses Effect.provideService.

There is no rule that an application must contain exactly one provide. The important question is which boundary owns the resulting lifetime.

Service or plain function?

Most functions should stay functions. Introduce a service when the caller depends on a capability whose implementation, failure, or lifetime should be supplied from outside.

Explicit inputs are enough

Keep it a plain function

The result is determined entirely by arguments. Context and lifecycle management add no useful boundary.

calculateTotal · formatName · validateRange
A capability must be supplied

Define a service and Layer

The code needs replaceable I/O, state, configuration, time, randomness, or infrastructure.

UserRepository · EmailService · PaymentGateway

A service that should be a function

This is a common over-engineering mistake. The following service adds Context lookup and a Layer even though the calculation is completely determined by its arguments:

unnecessary-service.ts
type CartItem = {
  readonly price: number
  readonly quantity: number
}

class CartCalculations extends Context.Service<
  CartCalculations,
  { readonly subtotal: (items: ReadonlyArray<CartItem>) => number }
>()("app/CartCalculations") {}

const CartCalculationsLive = Layer.succeed(
  CartCalculations,
  CartCalculations.of({
    subtotal: (items) =>
      items.reduce((total, item) => total + item.price * item.quantity, 0)
  })
)

Nothing needs to be acquired, replaced, or configured. A plain function keeps the dependency visible and is simpler to call and test:

plain-cart-calculation.ts
const calculateSubtotal = (items: ReadonlyArray<CartItem>): number =>
  items.reduce((total, item) => total + item.price * item.quantity, 0)

A function that should depend on a service

The opposite mistake is hiding an external dependency inside an ordinary function:

hidden-email-dependency.ts
const sendOrderConfirmation = (order: Order) =>
  Effect.tryPromise({
    try: () =>
      fetch(`${process.env.EMAIL_API_URL}/messages`, {
        method: "POST",
        body: JSON.stringify({ to: order.email, orderId: order.id })
      }),
    catch: (cause) => new EmailError({ cause })
  })

The function chooses fetch, reads configuration globally, and makes tests perform I/O unless they patch globals. Make the capability a service so production and test implementations can be supplied from outside:

explicit-email-service.ts
class EmailService extends Context.Service<
  EmailService,
  {
    readonly sendOrderConfirmation: (
      order: Order
    ) => Effect.Effect<void, EmailError>
  }
>()("app/EmailService") {}

const confirmOrder = Effect.fn("confirmOrder")(function*(order: Order) {
  const email = yield* EmailService
  yield* email.sendOrderConfirmation(order)
})

Check Effect's built-in capabilities such as Clock, Random, and Config before defining another service.

Knowledge check

Test the model before moving on

Six short tasks reinforce service boundaries, Layer constructors, dependency graphs, type channels, and lifecycle before the next lesson.

Coming soonContinue to next lesson
Knowledge check

Services and Layers

Progress is kept when this dialog is closed. Escape and backdrop clicks are disabled.

On this page