Chapter 8: Services & Dependency Injection
Effect's dependency injection system is the foundation of testable, modular architecture. This chapter teaches you to define services, implement them as layers, compose them, and integrate them with React.
The Problem DI Solves
Without dependency injection, code directly imports its dependencies:
// ❌ Hard-coded dependencies
import { prisma } from "@/lib/prisma";
async function getUser(id: string): Promise<User> {
const user = await prisma.user.findUnique({ where: { id } });
if (!user) throw new Error("Not found");
return user;
}
Problems:
- Testing requires a real database — you cannot substitute a mock
- Changing the database means modifying every file that imports Prisma
- Hidden dependencies — the function signature does not reveal it needs a database
- Tight coupling — business logic is coupled to infrastructure
Defining Services with Context.Tag
A service in Effect is defined in two parts:
- The interface — what the service can do (the contract)
- The implementation — how it does it (provided via a Layer)
Step 1: Define the Service Interface
// features/projects/domain/ports/project-repository.ts
import { Context, Effect } from "effect";
import type { Project } from "../entities/project";
import type { ProjectNotFoundError } from "../errors/project-errors";
// Define the service as a class extending Context.Tag
export class ProjectRepository extends Context.Tag("ProjectRepository")<
ProjectRepository,
{
readonly findById: (
id: string
) => Effect.Effect<Project, ProjectNotFoundError>;
readonly findByOrganization: (
orgId: string
) => Effect.Effect<ReadonlyArray<Project>>;
readonly create: (
data: CreateProjectData
) => Effect.Effect<Project>;
readonly update: (
id: string,
data: UpdateProjectData
) => Effect.Effect<Project, ProjectNotFoundError>;
readonly delete: (
id: string
) => Effect.Effect<void, ProjectNotFoundError>;
}
>() {}
Let's break this down:
Context.Tag("ProjectRepository")— creates a unique tag for the dependency injection system. The string identifier helps with debugging.- The first type parameter (
ProjectRepository) — refers to the class itself (for self-referencing) - The second type parameter — the interface of the service (what methods it provides)
Step 2: Use the Service
// features/projects/application/use-cases/get-project.ts
import { Effect } from "effect";
import { ProjectRepository } from "../../domain/ports/project-repository";
export const getProject = (projectId: string) =>
Effect.gen(function* () {
// yield* on a Tag retrieves the service implementation
const repo = yield* ProjectRepository;
// Now use the service — fully typed
const project = yield* repo.findById(projectId);
return project;
});
// Type: Effect<Project, ProjectNotFoundError, ProjectRepository>
// ↑
// Requirement is tracked in the type
The requirement ProjectRepository appears in the type signature. This means you cannot run this effect without providing a ProjectRepository implementation. The compiler enforces this.
Implementing Services with Layers
A Layer is a recipe for building a service. Layers can depend on other layers, forming a dependency graph.
Layer.succeed — Simple Implementation
For services with no dependencies of their own:
import { Layer } from "effect";
import { ProjectRepository } from "../../domain/ports/project-repository";
// In-memory implementation (useful for testing)
export const InMemoryProjectRepository = Layer.succeed(
ProjectRepository,
{
findById: (id) => {
const project = store.get(id);
return project
? Effect.succeed(project)
: Effect.fail(new ProjectNotFoundError({ projectId: id }));
},
findByOrganization: (orgId) =>
Effect.succeed(
Array.from(store.values()).filter((p) => p.organizationId === orgId)
),
create: (data) =>
Effect.sync(() => {
const project = { ...data, id: generateId(), createdAt: new Date(), updatedAt: new Date() };
store.set(project.id, project);
return project;
}),
update: (id, data) => /* ... */,
delete: (id) => /* ... */,
}
);
Layer.effect — Implementation with Dependencies
For services that need other services to work:
import { Effect, Layer } from "effect";
import { ProjectRepository } from "../../domain/ports/project-repository";
import { PrismaClient } from "@/shared/services/prisma-service";
export const PrismaProjectRepository = Layer.effect(
ProjectRepository,
Effect.gen(function* () {
// This layer depends on PrismaClient
const prisma = yield* PrismaClient;
return {
findById: (id) =>
Effect.tryPromise({
try: () => prisma.project.findUnique({ where: { id } }),
catch: (e) => new DatabaseError({ cause: e }),
}).pipe(
Effect.flatMap((project) =>
project
? Effect.succeed(project)
: Effect.fail(new ProjectNotFoundError({ projectId: id }))
)
),
findByOrganization: (orgId) =>
Effect.tryPromise({
try: () =>
prisma.project.findMany({
where: { organizationId: orgId },
orderBy: { createdAt: "desc" },
}),
catch: (e) => new DatabaseError({ cause: e }),
}),
create: (data) =>
Effect.tryPromise({
try: () => prisma.project.create({ data }),
catch: (e) => new DatabaseError({ cause: e }),
}),
update: (id, data) =>
Effect.tryPromise({
try: () =>
prisma.project.update({ where: { id }, data }),
catch: (e) => new DatabaseError({ cause: e }),
}).pipe(
Effect.catchIf(
(e) => e.cause instanceof Prisma.PrismaClientKnownRequestError &&
e.cause.code === "P2025",
() => Effect.fail(new ProjectNotFoundError({ projectId: id }))
)
),
delete: (id) =>
Effect.tryPromise({
try: () => prisma.project.delete({ where: { id } }),
catch: (e) => new DatabaseError({ cause: e }),
}).pipe(Effect.asVoid),
};
})
);
Layer.scoped — Resource Management
For services that need cleanup (database connections, file handles, WebSocket connections):
import { Effect, Layer, Scope } from "effect";
import { PrismaClient as PrismaORM } from "@prisma/client";
export class PrismaClient extends Context.Tag("PrismaClient")<
PrismaClient,
PrismaORM
>() {}
export const PrismaClientLive = Layer.scoped(
PrismaClient,
Effect.gen(function* () {
const prisma = new PrismaORM();
// Connect when the layer is created
yield* Effect.tryPromise({
try: () => prisma.$connect(),
catch: (e) => new DatabaseConnectionError({ cause: e }),
});
// Register cleanup — disconnect when the scope closes
yield* Effect.addFinalizer(() =>
Effect.tryPromise({
try: () => prisma.$disconnect(),
catch: () => void 0, // Best effort cleanup
}).pipe(Effect.orDie)
);
return prisma;
})
);
Defining Layers Inside the Service Class
A common pattern is to define the Live layer as a static property on the service class:
export class ProjectRepository extends Context.Tag("ProjectRepository")<
ProjectRepository,
{
readonly findById: (id: string) => Effect.Effect<Project, ProjectNotFoundError>;
readonly findByOrganization: (orgId: string) => Effect.Effect<ReadonlyArray<Project>>;
readonly create: (data: CreateProjectData) => Effect.Effect<Project>;
}
>() {
// Production implementation
static Live = Layer.effect(
this,
Effect.gen(function* () {
const prisma = yield* PrismaClient;
return {
findById: (id) => /* ... */,
findByOrganization: (orgId) => /* ... */,
create: (data) => /* ... */,
};
})
);
// Test implementation
static Test = Layer.succeed(this, {
findById: (id) => Effect.succeed(mockProject),
findByOrganization: () => Effect.succeed([mockProject]),
create: (data) => Effect.succeed({ ...mockProject, ...data }),
});
}
This keeps the interface and its implementations together, making it easy to discover how to provide a service.
Composing Layers
Layers compose to build up the full dependency graph of your application.
Layer.merge — Combine Independent Layers
import { Layer } from "effect";
// Combine layers that don't depend on each other
const RepositoryLayer = Layer.mergeAll(
ProjectRepository.Live,
TaskRepository.Live,
UserRepository.Live,
);
Layer.provide — Wire Dependencies
// ProjectRepository.Live needs PrismaClient
// PrismaClientLive is self-contained
// Wire them together:
const ProjectRepoWithDeps = ProjectRepository.Live.pipe(
Layer.provide(PrismaClientLive)
);
// Now ProjectRepoWithDeps has no unsatisfied dependencies
Building the Full Application Layer
// shared/lib/effect-runtime.ts
import { Layer, ManagedRuntime } from "effect";
// Infrastructure layers (no business dependencies)
const InfrastructureLayer = Layer.mergeAll(
PrismaClientLive,
HttpClientLive,
);
// Repository layers (depend on infrastructure)
const RepositoryLayer = Layer.mergeAll(
ProjectRepository.Live,
TaskRepository.Live,
UserRepository.Live,
).pipe(Layer.provide(InfrastructureLayer));
// Service layers (depend on repositories)
const ServiceLayer = Layer.mergeAll(
AuthorizationService.Live,
AuditService.Live,
NotificationService.Live,
).pipe(Layer.provide(RepositoryLayer));
// The complete application layer
const AppLayer = Layer.mergeAll(
RepositoryLayer,
ServiceLayer,
);
// Create a ManagedRuntime — this is what React uses
export const AppRuntime = ManagedRuntime.make(AppLayer);
Layer Composition Is Automatic
A powerful property of Effect's layer system: layers are shared by default. If ProjectRepository.Live and TaskRepository.Live both depend on PrismaClientLive, the Prisma client is created only once and shared between them. No manual singleton management needed.
Integrating with React
The ManagedRuntime Bridge
ManagedRuntime creates a runtime with all services pre-provided. React components use it to run effects:
// shared/lib/effect-runtime.ts
import { ManagedRuntime } from "effect";
export const AppRuntime = ManagedRuntime.make(AppLayer);
In TanStack Query Hooks
// features/projects/hooks/use-projects.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AppRuntime } from "@/shared/lib/effect-runtime";
import { ProjectRepository } from "../domain/ports/project-repository";
import { createProject } from "../application/use-cases/create-project";
// Query options factory (reusable in loaders and components)
export const projectsQueryOptions = (orgId: string) => ({
queryKey: ["projects", "list", orgId] as const,
queryFn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const repo = yield* ProjectRepository;
return yield* repo.findByOrganization(orgId);
})
),
staleTime: 30_000,
});
export const projectQueryOptions = (projectId: string) => ({
queryKey: ["projects", "detail", projectId] as const,
queryFn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const repo = yield* ProjectRepository;
return yield* repo.findById(projectId);
})
),
});
// Hooks
export function useProjects(orgId: string) {
return useQuery(projectsQueryOptions(orgId));
}
export function useProject(projectId: string) {
return useQuery(projectQueryOptions(projectId));
}
export function useCreateProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateProjectInput) =>
AppRuntime.runPromise(createProject(input, getCurrentUserId())),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
},
});
}
Optional: React Context for Runtime
If you need different runtimes for different parts of the app (e.g., testing):
// shared/lib/runtime-context.tsx
import { createContext, useContext } from "react";
import type { ManagedRuntime } from "effect";
const RuntimeContext = createContext<ManagedRuntime<AppServices> | null>(null);
export function RuntimeProvider({
runtime,
children,
}: {
runtime: ManagedRuntime<AppServices>;
children: React.ReactNode;
}) {
return (
<RuntimeContext.Provider value={runtime}>
{children}
</RuntimeContext.Provider>
);
}
export function useRuntime() {
const runtime = useContext(RuntimeContext);
if (!runtime) {
throw new Error("useRuntime must be used within a RuntimeProvider");
}
return runtime;
}
Testing with Alternative Layers
The real payoff of DI: test your business logic without a database, network, or any external system.
// features/projects/application/use-cases/__tests__/create-project.test.ts
import { describe, it, expect } from "vitest";
import { Effect, Layer } from "effect";
import { createProject } from "../create-project";
// Create test layers with controlled behavior
const TestProjectRepo = Layer.succeed(ProjectRepository, {
findByName: () => Effect.succeed(null), // No existing project
create: (data) =>
Effect.succeed({
id: "test-id",
...data,
createdAt: new Date(),
updatedAt: new Date(),
}),
});
const TestAuthService = Layer.succeed(AuthorizationService, {
requirePermission: () => Effect.void, // Always authorized
});
const TestAuditService = Layer.succeed(AuditService, {
record: () => Effect.void, // No-op
});
const TestLayer = Layer.mergeAll(
TestProjectRepo,
TestAuthService,
TestAuditService,
);
describe("createProject", () => {
it("creates a project successfully", async () => {
const result = await Effect.runPromise(
createProject(
{ name: "New Project", organizationId: "org-1" },
"user-1"
).pipe(Effect.provide(TestLayer))
);
expect(result.name).toBe("New Project");
expect(result.id).toBe("test-id");
});
it("fails when name is taken", async () => {
// Override just the findByName behavior
const RepoWithExisting = Layer.succeed(ProjectRepository, {
findByName: () => Effect.succeed({ id: "existing", name: "Taken" } as Project),
create: () => Effect.die("should not be called"),
});
const LayerWithExisting = Layer.mergeAll(
RepoWithExisting,
TestAuthService,
TestAuditService,
);
const exit = await Effect.runPromiseExit(
createProject(
{ name: "Taken", organizationId: "org-1" },
"user-1"
).pipe(Effect.provide(LayerWithExisting))
);
expect(Exit.isFailure(exit)).toBe(true);
});
});
No database setup. No network mocking. No test containers. Just layers that return the values you want.
Service Design Guidelines
1. Service Methods Should Have No Requirements
// ✅ Good: methods return Effect<A, E, never>
export class ProjectRepository extends Context.Tag("ProjectRepository")<
ProjectRepository,
{
readonly findById: (id: string) => Effect.Effect<Project, ProjectNotFoundError>;
// ↑ never (no R)
}
>() {}
// ❌ Bad: methods leak their own dependencies
export class ProjectRepository extends Context.Tag("ProjectRepository")<
ProjectRepository,
{
readonly findById: (id: string) => Effect.Effect<Project, ProjectNotFoundError, PrismaClient>;
// ↑ leaked dependency
}
>() {}
Dependencies should be resolved when the Layer is created, not when methods are called. The service interface is a contract — callers should not need to know about implementation details.
2. Use Readonly Properties
// ✅ Readonly prevents accidental reassignment
{
readonly findById: (id: string) => Effect.Effect<Project, ProjectNotFoundError>;
}
3. Keep Service Interfaces Small
Prefer multiple focused services over one god service:
// ✅ Focused services
class ProjectRepository { /* CRUD operations */ }
class ProjectAnalytics { /* reporting and stats */ }
class ProjectNotifications { /* notification logic */ }
// ❌ God service
class ProjectService { /* everything project-related */ }
Summary
- ✅ Define services with
Context.Tag— typed interfaces for dependency injection - ✅ Implement with
Layer.succeed,Layer.effect, orLayer.scoped - ✅ Compose layers with
Layer.mergeAllandLayer.provide - ✅ Layers are shared by default — no manual singleton management
- ✅ Use
ManagedRuntimeto bridge Effect into React via TanStack Query - ✅ Test with alternative layers — swap real implementations for test doubles
- ✅ Keep service methods free of Requirements (
R = never) - ✅ Define
LiveandTestlayers as static properties on the service class