Effect v4Module 4 of 8: Replaceable dependencies
Module 4 of 8Dependency modelsrc/course/examples/services.ts

The operation should not choose its own provider

Make the operation declare what it needs and let production or tests provide the implementation.

First principle

Code that names a dependency can be tested and moved. Code that constructs its dependency has already made the architectural decision for every caller.

04 / 08module position
01

The concrete friction

Hardcoded module imports couple domain logic to physical infrastructure

When a domain module directly imports a database client or external SDK, it binds business logic to physical infrastructure. Testing that logic requires global monkey-patching (like jest.mock or module hijacking), which breaks type checking and causes cross-test race conditions. Alternatively, passing dependencies through function arguments (parameter drilling) clutters every intermediate caller across the entire call stack.

First-principles consequence

A computation that constructs its own dependencies cannot be reused in another environment. The domain logic becomes hostage to the concrete database, network protocol, and authentication credentials chosen by the author.

baseline-friction.tsbaseline problem
type Issue = { readonly id: string; readonly title: string };
type IssueRepository = {
  search: (query: string) => Promise<readonly Issue[]>;
};

// The domain receives the dependency explicitly via parameter drilling.
async function searchIssueTitles(
  repository: IssueRepository,
  query: string
) {
  const issues = await repository.search(query);
  return issues.map((issue) => issue.title);
}

const repository: IssueRepository = {
  search: async (_query) => [
    { id: "ISSUE-101", title: "Add typed search errors" }
  ]
};

// Every caller must manually pass the repository down the call stack.
const titles = await searchIssueTitles(repository, "effect");
02

The mental model

The Requirements channel (R) inverts dependency management

Context.Service<IssueRepository>("IssueRepository") defines a unique service tag. The domain calls yield* IssueRepository, which registers the dependency in the R channel: Effect<readonly string[], never, IssueRepository>. The domain does not know or care whether the implementation uses PostgreSQL, an in-memory Map, or a mock. The execution boundary satisfies R with Effect.provideService.

searchIssueTitles: Effect<readonly string[], never, IssueRepository>
                             │
                             ▼  Effect.provideService(...)
 runnableProgram:       Effect<readonly string[], never, never>
                             │
                             ▼  Effect.runPromise(...)
 Output: titles: Add typed search errors
03

Minimal working example

Executable source

This is the checked source file used by the course. Read it before you run it.

src/course/examples/services.tsDependency model
import { Console, Context, Effect } from "effect"

export type Issue = {
  readonly id: string
  readonly title: string
}

export interface IssueRepository {
  readonly search: (query: string) => Effect.Effect<ReadonlyArray<Issue>>
}

export const IssueRepository =
  Context.Service<IssueRepository>("IssueRepository")

const IssueRepositoryTest: IssueRepository = {
  search: (_query) =>
    Effect.succeed([
      { id: "ISSUE-101", title: "Add typed search errors" }
    ])
}

export const searchIssueTitles = (
  query: string
): Effect.Effect<ReadonlyArray<string>, never, IssueRepository> =>
  Effect.gen(function* () {
    const repository = yield* IssueRepository
    const issues = yield* repository.search(query)
    return issues.map((issue) => issue.title)
  })

const program = Effect.provideService(
  searchIssueTitles("effect"),
  IssueRepository,
  IssueRepositoryTest
)

await Effect.runPromise(
  program.pipe(
    Effect.tap((titles) =>
      Console.log(`titles: ${titles.join(", ")}`)
    )
  )
)
04

Execute and verify

Predict the result, run the command, and compare the output with the model.

Run this command
pnpm exec tsx src/course/examples/services.ts
Before you run it

Where does searchIssueTitles declare that it needs IssueRepository, and what changes after Effect.provideService supplies the test implementation?

Reveal expected output
titles: Add typed search errors
05

Error anatomy and edge cases

Attempting to execute an Effect with unsatisfied requirements

Calling Effect.runPromise on searchIssueTitles("effect") before providing IssueRepository.

Compiler or runtime diagnostic

TypeScript reports: "TS2345: Argument of type 'Effect<readonly string[], never, IssueRepository>' is not assignable to parameter of type 'Effect<readonly string[], never, never>'. Type 'IssueRepository' is not assignable to type 'never'". At runtime, if executed anyway, the fiber fails immediately with: "Error: Service not found: IssueRepository".

Remedy

Provide the missing service using Effect.provideService(program, IssueRepository, implementation) or compose dependencies with Layer. Execution functions require R to be never.

06

Active lab exercise

src/exercises/exercise4/starter.ts

Exercise: provide the required repository

Fix the Context tag at the execution boundary so the domain receives the repository it declared.

ObjectiveFix starter.ts so the search program finds the IssueRepository service and returns the expected title.
pnpm exec tsx src/exercises/exercise4/exercise.test.ts starter

The harness should fail against the starter. Inspect the assertion, change the starter implementation, and run it again.

Inspect starter code
Broken starter implementation
src/exercises/exercise4/starter.tsstarter
import { Context, Effect } from "effect"

type Issue = {
  readonly id: string
  readonly title: string
}

interface IssueRepository {
  readonly search: (query: string) => Effect.Effect<ReadonlyArray<Issue>>
}

const IssueRepository = Context.Service<IssueRepository>("IssueRepository")

const testRepository: IssueRepository = {
  search: (_query) =>
    Effect.succeed([
      { id: "ISSUE-101", title: "Add typed search errors" }
    ])
}

const WrongIssueRepository = Context.Service<IssueRepository>(
  "IssueRepositoryForTests"
)

const searchIssueTitles = (
  query: string
): Effect.Effect<ReadonlyArray<string>, never, IssueRepository> =>
  Effect.gen(function* () {
    const repository = yield* IssueRepository
    const issues = yield* repository.search(query)
    return issues.map((issue) => issue.title)
  })

// TODO: provide testRepository through the IssueRepository tag.
export const program = Effect.provideService(
  searchIssueTitles("effect"),
  WrongIssueRepository,
  testRepository
)
Reveal reference solution
Target reference solution
solution.tsverified solution
import { Context, Effect } from "effect"

type Issue = {
  readonly id: string
  readonly title: string
}

interface IssueRepository {
  readonly search: (query: string) => Effect.Effect<ReadonlyArray<Issue>>
}

const IssueRepository = Context.Service<IssueRepository>("IssueRepository")

const testRepository: IssueRepository = {
  search: (_query) =>
    Effect.succeed([
      { id: "ISSUE-101", title: "Add typed search errors" }
    ])
}

const searchIssueTitles = (
  query: string
): Effect.Effect<ReadonlyArray<string>, never, IssueRepository> =>
  Effect.gen(function* () {
    const repository = yield* IssueRepository
    const issues = yield* repository.search(query)
    return issues.map((issue) => issue.title)
  })

export const program = Effect.provideService(
  searchIssueTitles("effect"),
  IssueRepository,
  testRepository
)
Why this solution works

Context tags are runtime identities. Effect.provideService must use the same IssueRepository tag that the domain retrieves with yield* IssueRepository.