EffectTalk
Back to Tour
resource-managementSenior

Manually Manage Lifecycles with `Scope`

Use `Scope` directly to manage complex resource lifecycles or when building custom layers.

Manually Manage Lifecycles with Scope

Guideline

For complex scenarios where a resource's lifecycle doesn't fit a simple acquireRelease pattern, use Effect.scope to create a boundary for finalizers. Inside this boundary, you can access the Scope service and manually register cleanup actions using Scope.addFinalizer.

Rationale

While Effect.acquireRelease and Layer.scoped are sufficient for most use cases, sometimes you need more control. This pattern is essential when:

  1. A single logical operation acquires multiple resources that need independent cleanup.
  2. You are building a custom, complex Layer that orchestrates several dependent resources.
  3. You need to understand the fundamental mechanism that powers all of Effect's resource management.

By interacting with Scope directly, you gain precise, imperative-style control over resource cleanup within Effect's declarative, functional framework. Finalizers added to a scope are guaranteed to run in Last-In-First-Out (LIFO) order when the scope is closed.

Good Example

import { Effect, Console } from "effect";

// Mocking a complex file operation
const openFile = (path: string) =>
  Effect.succeed({ path, handle: Math.random() }).pipe(
    Effect.tap((f) => Effect.log(`Opened ${f.path}`))
  );
const createTempFile = (path: string) =>
  Effect.succeed({ path: `${path}.tmp`, handle: Math.random() }).pipe(
    Effect.tap((f) => Effect.log(`Created temp file ${f.path}`))
  );
const closeFile = (file: { path: string }) =>
  Effect.sync(() => Effect.log(`Closed ${file.path}`));
const deleteFile = (file: { path: string }) =>
  Effect.sync(() => Effect.log(`Deleted ${file.path}`));

// This program acquires two resources (a file and a temp file)
// and ensures both are cleaned up correctly using acquireRelease.
const program = Effect.gen(function* () {
  const file = yield* Effect.acquireRelease(openFile("data.csv"), (f) =>
    closeFile(f)
  );

  const tempFile = yield* Effect.acquireRelease(
    createTempFile("data.csv"),
    (f) => deleteFile(f)
  );

  yield* Effect.log("...writing data from temp file to main file...");
});

// Run the program with a scope
Effect.runPromise(Effect.scoped(program));

/*
Output (note the LIFO cleanup order):
Opened data.csv
Created temp file data.csv.tmp
...writing data from temp file to main file...
Deleted data.csv.tmp
Closed data.csv
*/

Explanation: Effect.scope creates a new Scope and provides it to the program. Inside program, we access this Scope and use addFinalizer to register cleanup actions immediately after acquiring each resource. When Effect.scope finishes executing program, it closes the scope, which in turn executes all registered finalizers in the reverse order of their addition.

Anti-Pattern

Attempting to manage multiple, interdependent resource cleanups using nested try...finally blocks. This leads to a "pyramid of doom," is difficult to read, and remains unsafe in the face of interruptions.

// ANTI-PATTERN: Nested, unsafe, and hard to read
async function complexOperation() {
  const file = await openFilePromise(); // acquire 1
  try {
    const tempFile = await createTempFilePromise(); // acquire 2
    try {
      await doWorkPromise(file, tempFile); // use
    } finally {
      // This block may not run on interruption!
      await deleteFilePromise(tempFile); // release 2
    }
  } finally {
    // This block may also not run on interruption!
    await closeFilePromise(file); // release 1
  }
}
Manually Manage Lifecycles with `Scope` | EffectTalk | EffectTalk