Services & Layers
Learn what Services and Layers are, write code against a capability, and choose its implementation at the application boundary.
program asks · service describes · value implements · layer providesIf 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:
Business logic
Asks for a capability. It does not construct or select the implementation.
Effect<A, E, UserRepository>Contract and identity
Describes the available operations and gives the capability a unique Context key.
UserRepositoryConcrete implementation
An object containing the actual methods. It may talk to a database, call an API, or keep data in memory.
UserRepository.of(...)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.
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:
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:
- Define the capability.
- Write business logic against that capability.
- 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:
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:
- it provides
UserRepository, - building it cannot fail in this simplified example,
- 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:
// 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:
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:
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
UserRepository
The identity used in an Effect requirement and Context lookup.
Effect<A, E, UserRepository>UserRepository.of(...)
A typed implementation object. .of() does not create a Layer or manage a lifetime.
UserRepository["Service"]UserRepository.layerLive
A reusable recipe that contributes the value to Context and can own resources.
Layer<UserRepository, E, R>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.
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.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:
// 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:
// 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:
// 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:
// 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.
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.
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<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.
const ObservabilityLayer = observabilityEnabled
? OpenTelemetryLayer
: Layer.emptyUnlike 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.
// 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)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.
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.
Keep it a plain function
The result is determined entirely by arguments. Context and lifecycle management add no useful boundary.
Define a service and Layer
The code needs replaceable I/O, state, configuration, time, randomness, or infrastructure.
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:
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:
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:
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:
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.