4. Dependency injection: the R channel
Dependency injection is Effect's: services are Context keys, implementations are Layers, and Effect.provide supplies them. Effeck removes the ceremony and adds compile-time checks of the dependency graph.
Declaring a service
class Users extends Context.Service<Users, {
find(id: string): Effect.Effect<User, UserNotFound>
rename(id: string, name: string): Effect.Effect<void, UserNotFound>
}>()("app/Users") {}service Users {
find(id: string): User!UserNotFound
rename(id: string, name: string): void!UserNotFound
}service Users { ... } declares a service, and it does the job of the class on the left. Its members have no bodies (D128, D223). It is short for abstract class Users { ... } in which every member without a body is abstract, and every value member is also readonly; static members, such as a layer, stay as written (D128). The older spelling service class Users { ... } still compiles, with the warning EK2120. An abstract class whose instance members are all abstract is a service whichever way it is written. The rules, each checked by the compiler:
- What makes a class a service. In an
.effeckfile, a class is a service when it isabstract, has noextendsclause and no type parameters, and every instance member is abstract: methods with full contracts, andabstract readonlyvalues. Static members are allowed and stay on the class. An abstract class with a concrete instance member is an ordinary class. - Identity. Effect identifies a service by its key string, and two services with the same string silently share a slot. The compiler derives the key from the package, module path, and class name (
@acme/app/src/users#Users) and rejects a duplicate anywhere in the program (EK3001). - Each service is its own type. TypeScript compares classes by shape, so two abstract classes with the same members would be one type: providing one would type-check as providing the other, and the program would die at run time with
Service not found. The compiler gives every service class its own identity, as Effect'sContext.Serviceclasses have. - Contracts are written out. A service has no body to infer from, so each method states its full
Effect<A, E, R>(EK2101, Chapter 14). - A service is a key, not a base class.
extends Users(EK3002),new Users()(EK3003),instanceof Users(EK3004), and a generic service class (EK3005) are errors. Implement a service with an object literal or with a class thatimplements Users.
error EK3002: a class cannot extend a service.
users-memory.effeck:1:28 class UsersInMemory extends Users {
Implement it instead: class UsersInMemory implements Users { … }What the class compiles to:
export interface Users {
find(id: string): Effect.Effect<User, UserNotFound>
rename(id: string, name: string): Effect.Effect<void, UserNotFound>
readonly "~effeck/service"?: "@acme/app/src/users#Users"
}
export class Users extends serviceKey<Users>("@acme/app/src/users#Users") {}TypeScript sees Users two ways. As a type, it is the interface, so implements Users and a parameter typed db: Users mean the methods. As a value, it is a Context.Key, so Layer.succeed(Users, …), Effect.provideService, and yield* Users work as they do with a Context.Service class. The optional brand keeps two services with the same methods distinct in R. Effect's own class form cannot be the output: it makes the class's instance type the service identifier, so implements Users would fail with TS2720 (prototype/esc/scratch/probe2.ts).
The syntax takes nothing away from TypeScript. Calling an abstract member through its class (Users.find(id)) is error TS2339 today, and calling a class without new (Users()) is TS2348, so Effeck gives both a meaning. Fully abstract classes are also rare in ordinary code: Smithers has none in 4,461 files, and opencode has five in 3,235, whose only fully abstract one extends another class.
A Context.Service written in TypeScript, as a class or as a value like Effect's own FileSystem, is a service too, and Effeck uses it the same way.
Using a service
Call its methods. Using a service adds it to R.
const users = yield* Users
const user = yield* users.find(id)const user = Users.find(id)Calling the service itself with no arguments, Users(), gives you its implementation object, the equivalent of yield* Users. You need it inside providers, and to read a service's value members:
service Settings {
baseUrl: string
scale(): number!!
}
function apiUrl(path: string) {
return `${Settings().baseUrl}/${path}`
}
// string!!SettingsCalling any member through the service looks the service up, so the service joins R whatever the member returns. Path.join(a, b), from Effect's Path service, returns a string and still adds Path. A member that is itself an Effect value runs when called: Terminal.readLine(). A member the service declares wins over the key's own members, such as pipe, use, and key, so Rpc.call(…) reaches the service even though the key, a class, has a call of its own. A static member with the same name as a service member is EK3006.
Writing a provider
A provider is a Layer. Build one with Effect's own Layer.effect, passing a function that runs when the layer is built and returns the implementation. In Effeck the function is a block after the call, through block params (D188): Layer.effect(Users) { ... } runs its statements once, when the layer is built, and returns the implementation, an object whose members the service types (D221). Without setup, write Layer.succeed(Users, { ... }). The older layer UsersLive: Users { ... } declaration (D129) still compiles, with the warning EK2121 and a quick fix that rewrites it.
const UsersLive = Layer.effect(Users, Effect.gen(function* () {
const db = yield* Database
return Users.of({
find: Effect.fn("Users.find")(function* (id) {
const rows = yield* db.query("select * from users where id = $1", [id]).pipe(Effect.orDie)
if (rows.length === 0) return yield* new UserNotFound({ id })
return toUser(rows[0])
}),
rename: Effect.fn("Users.rename")(function* (id, name) {
yield* db.execute("update users set name = $1 where id = $2", [name, id]).pipe(Effect.orDie)
})
})
}))
// Layer<Users, never, Database>const UsersLive = Layer.effect(Users) {
const db = Database() // runs once, when the layer is built
return {
find(id) {
const rows = Effect.orDie(db.query("select * from users where id = $1", [id]))
if (rows.length === 0) throw new UserNotFound({ id })
return toUser(rows[0])
},
rename(id, name) {
Effect.orDie(db.execute("update users set name = $1 where id = $2", [name, id]))
}
}
}
// Layer<Users, never, Database>Database() runs when the layer is built and captures the implementation in db. Database is therefore a requirement of building UsersLive, not of calling Users.find. Code that calls Users.find needs only Users.
The compiler checks each method against the service's contract. Without the Effect.orDie, the database's DbError would escape:
error EK2001: DbError can escape UsersLive.find, but Users.find's contract allows only UserNotFound.
users-live.effeck:5:20 const rows = db.query("select * from users where id = $1", [id])
^^^^^^^^ can fail with DbError
Handle it, add DbError to Users.find's contract, or make it a defect with Effect.orDie.Effect 4 code often keeps the default Layer on the service class as static readonly layer. A service class can do the same, because static members stay on the class:
service Users {
find(id: string): User!UserNotFound
rename(id: string, name: string): void!UserNotFound
static readonly layer = Layer.effect(Users) {
const db = Database()
return {
find(id) { … },
rename(id, name) { … }
}
}
}An implementation can also be a class that implements the service. Its methods are Effeck methods, checked against the service's contracts:
class UsersInMemory implements Users {
readonly #rows = new Map<string, User>()
find(id: string) {
const user = this.#rows.get(id)
if (user === undefined) throw new UserNotFound({ id })
return user
}
rename(id: string, name: string) {
this.#rows.set(id, { ...this.find(id), name })
}
}
const UsersInMemoryLive = Layer.sync(Users, () => new UsersInMemory())
// Layer<Users, never, never>Constructors and defaults
Effect 4's make option becomes a static method, and a service that should work without any provider declares a default:
service Sessions {
get(token: string): Session | undefined!!
set(token: string, session: Session): void!!
static make() {
const entries = new Map<string, Session>()
return {
get: (token: string) => entries.get(token),
set: (token: string, session: Session) => { entries.set(token, session) }
}
}
static readonly layer = Layer.effect(Sessions, Sessions.make())
}
service FeatureFlags {
newCheckout: boolean
static readonly default: FeatureFlags = { newCheckout: false }
}
function checkoutPage(cartId: string) {
return FeatureFlags().newCheckout ? newCheckout(cartId) : classicCheckout(cartId)
}
// FeatureFlags is not in R: it always has a valueA service with static readonly default compiles to Effect's Context.Reference. Code that uses it never needs a provider, and Effect.provideService(() => …, FeatureFlags, { newCheckout: true }) overrides it for a region. Effect's own Context.Reference("app/Flag", { defaultValue: () => false }) works unchanged too, because a call that returns a service key is data (Chapter 13).
Providers that hold resources
A provider can acquire a resource that lives as long as the layer, such as a connection pool. Acquire it without using:
const DatabaseLive = Layer.effect(Database, Effect.gen(function* () {
const config = yield* DatabaseConfig
const pool = yield* Postgres.connect(config.url)
return Database.of({
query: (sql, params) => pool.query(sql, params),
execute: (sql, params) => pool.execute(sql, params)
})
}))
// Layer<Database, ConnectError, DatabaseConfig>const DatabaseLive = Layer.effect(Database) {
const pool = Postgres.connect(DatabaseConfig().url) // no `using`: the pool lives as long as the layer
return {
query(sql, params) { return pool.query(sql, params) },
execute(sql, params) { return pool.execute(sql, params) }
}
}
// Layer<Database, ConnectError, DatabaseConfig>Postgres.connect returns Pool!ConnectError!Scope. Without using, the pool belongs to whoever supplies the Scope (Chapter 6), and Layer.effect supplies the layer's own Scope, as it does in Effect today. The pool therefore closes when the program that provided DatabaseLive finishes. Writing using pool = … would close it as soon as the provider function returned, before any query ran.
Requirements that would leak
Sometimes a method looks up another service each time it is called, instead of capturing it when the layer is built. That lookup happens in the caller's context, so its requirement belongs to the caller, and the service contract has to say so. The compiler catches the mismatch:
const UsersAudited = Layer.effect(Users) {
const db = Database()
return {
find(id) {
Audit.record(`users.find ${id}`) // looked up on every call
return findIn(db, id)
},
rename(id, name) { renameIn(db, id, name) }
}
}error EK3101: UsersAudited.find looks up Audit each time it is called, but
Users.find(id: string): Effect<User, UserNotFound, never>
allows no call-time requirements.
users-audited.effeck:5:7 Audit.record(`users.find ${id}`)
Capture it once: const audit = Audit() … audit.record(…)
or declare it: abstract find(id: string): Effect<User, UserNotFound, Audit>Either fix is honest. Capturing makes Audit a requirement of building the layer. Declaring it makes every caller of Users.find provide Audit.
Composing an application
const AppLive = Layer.mergeAll(UsersLive, MailerLive).pipe(
Layer.provideMerge(DatabaseLive),
Layer.provideMerge(DatabaseConfigLive)
)const AppLive = wire(UsersLive, MailerLive, DatabaseLive, DatabaseConfigLive)
// Layer<Users | Mailer | Database | DatabaseConfig, ConfigError | ConnectError, never>wire reads each provider's inputs and outputs from its Layer<Out, E, In> type, orders them, and generates the composition on the left. Its value is the errors. Today Effect reports a missing provider only as a leftover requirement where you run the program, silently picks one of two providers for the same service, and hangs on some cycles. wire reports all three at compile time:
error EK3201: no provider for Smtp.
welcome → Mailer.send → MailerLive needs Smtp
error EK3202: two providers for Users: UsersLive (src/users.effeck:12) and UsersTest (test/fakes.effeck:3). Pass one.
error EK3203: providers form a cycle: SessionsLive → Database → DatabaseLive → Sessions → SessionsLiveThe contract is bounded by what Layer types say:
- Inputs that no argument provides stay in the result's
In, as withLayer.provide, so you can wire part of a graph. EK3201 is reported where the program's requirements must be empty: at a runner, or at a contract whose R excludes them. - An argument whose type does not name its outputs, such as a value typed
Layer<any>, is EK3204: compose it withLayer.provide. A Layer chosen at run time withLayer.unwraphas a proper type and wires normally. Layer.provide,Layer.merge, and the rest keep working;wireis optional.
Running with providers
function main() {
const name = Effect.provide(() => welcome("42"), AppLive)
log(`welcomed ${name}`)
}
// void!UserNotFound | MailError | ConfigError | ConnectError!main's contract shows what providing costs: ConfigError and ConnectError come from building the layers. The layers are built when Effect.provide runs, shared by everything inside it, and released in reverse order when it finishes. If building a layer fails, the program fails with that error, and anything already acquired is released.
provide AppLive { ... } provides a layer to a block (D130, D172); its keyword matches Effect.provide. It is short for Effect.provide(() => { ... }, AppLive), except that its statements are code of the enclosing function: a return returns from the function, and break and continue leave its loops. Its errors are the block's and the layer's, and its requirements are the block's that the layer does not provide plus the layer's own. Like Effect.provide, it accepts a layer, an array of layers, or a Context. A with block at a module's top level would run a program there, so it is EK1001.
function main() {
provide AppLive {
log(`welcomed ${welcome("42")}`)
}
}A provider written Key = value provides one service without a layer, and providers separated by commas share one block (D173). provide AppLive, Mailer = fakeMailer { ... } is Effect.provide with [AppLive, Layer.succeed(Mailer, fakeMailer)], so the block's requirements drop Mailer as well as what AppLive provides. An effect module whose export of the same name is a service key stands for that key: provide Clock = testClock provides Clock.Clock, and provide Scheduler = fifo installs a scheduler (D174). provide(AppLive, Mailer = fakeMailer) { ... } means the same, as fork(x) means fork x. The analyzer reads a provider list element by element, because the list's own type merges its layers into one union, which for a layer that provides nothing collapses to Layer<never> (D175).
const fakeMailer = { send: (to: string, text: string) => log(`to ${to}: ${text}`) }
function preview() {
provide AppLive, Mailer = fakeMailer {
log(`welcomed ${welcome("42")}`)
}
}
// void!UserNotFound | MailError | ConfigError | ConnectError!Each Effect.provide builds its layers once, so two sibling provide calls build them twice:
function counts() {
const users = Effect.provide(() => countUsers(), AppLive) // opens a database pool
const orders = Effect.provide(() => countOrders(), AppLive) // opens a second pool
return users + orders
}Provide the application layer once, at main. A program with several entry points, such as a web server whose framework calls each handler, builds one ManagedRuntime from the layer and runs every entry point through it (Chapter 12).
Testing with fake services
const UsersTest = Layer.succeed(Users, Users.of({
find: (id) => Effect.succeed({ id, name: "Ada", email: "ada@example.com" }),
rename: () => Effect.void
}))
it.effect("welcome returns the user's name", () =>
Effect.gen(function* () {
const name = yield* welcome("42").pipe(Effect.provide(Layer.mergeAll(UsersTest, MailerTest)))
assert.strictEqual(name, "Ada")
}))const UsersTest = Layer.succeed(Users, {
find(id) { return ({ id, name: "Ada", email: "ada@example.com" }) },
rename() {}
})
it.effect("welcome returns the user's name") {
const name = Effect.provide(() => welcome("42"), wire(UsersTest, MailerTest))
assert.strictEqual(name, "Ada")
}To replace one service for part of a program, provide it around that part. The override applies only inside:
function welcomeQuietly(id: string) {
return Effect.provideService(welcome(id), Mailer, { send: () => {} })
}What a function sees, and when
- A service call uses the providers in effect where the call runs. Code inside
welcomeQuietlysees the quietMailer; code outside does not. - After a pause (
Effect.sleep, a network call), a function sees the same providers it saw before. - A forked fiber starts with the providers of the code that forked it (Chapter 7).
- An implementation you captured,
const db = Database(), stays that implementation even ifDatabaseis overridden later.