better-effect
Reference

Patterns and Recipes

Practical designs for applications built with better-effect.

Composition roots

Keep one AppLive per process boundary:

const DatabaseLive = Layer.scoped(Database, connect, close)
const AppLive = Layer.merge(DatabaseLive, ServicesLive)

The entry point chooses the backend and owns Runtime disposal. Business modules should export Services and provider Layers, not create global containers.

Service dependency chains

Let dependencies appear where they are used:

class AuthService extends Service<AuthService>()('AuthService') {
  login(input: LoginInput) {
    return Effect.gen(async function* () {
      const users = yield* UserRepository
      const passwords = yield* PasswordHasher
      const user = yield* Result.await(users.findByEmail(input.email))
      const valid = yield* Result.await(passwords.verify(input.password, user.passwordHash))

      return valid
        ? Result.ok(user)
        : Result.err(new InvalidCredentials({ message: 'Invalid credentials' }))
    })
  }
}

No manual requires: [UserRepository, PasswordHasher] list can drift from the implementation.

Typed environment configuration

Use the one-call descriptor when a program owns its source. Any Zod, Valibot, or other Standard Schema-compatible schema can be supplied without adding a validator dependency to better-effect:

import { Result } from 'better-result'
import { Effect } from 'better-effect'
import { Config } from 'better-effect/standard-services'

const EnvSchema = {
  '~standard': {
    version: 1,
    vendor: 'application',
    types: {} as {
      input: Record<string, string | undefined>
      output: { databaseUrl: string; poolSize: number }
    },
    validate: (raw: unknown) => {
      const env = raw as Record<string, string | undefined>
      const poolSize = Number(env.POOL_SIZE)

      return env.DATABASE_URL && Number.isInteger(poolSize) && poolSize > 0
        ? { value: { databaseUrl: env.DATABASE_URL, poolSize } }
        : { issues: [{ message: 'DATABASE_URL and POOL_SIZE are required' }] }
    }
  }
}

const AppConfig = Config.fromEnv({
  schema: EnvSchema,
  dotEnvPath: '.env',
  envSource: process.env
})

const start = Effect.fn(async function* () {
  const config = yield* AppConfig
  return Result.ok(config)
})

AppConfig creates a fresh source read and validation on every execution. Validation issues and dotenv read failures stay in the Result error channel; raw environment values are not attached to those errors.

When several descriptors should share one replaceable source, use the advanced provider form:

import { Effect, Layer, pipe } from 'better-effect'
import { Config } from 'better-effect/standard-services'

const ConfigLive = Config.layerFromEnv({ dotEnvPath: '.env' })
const AppConfig = Config.schema(EnvSchema)
const TestConfig = Layer.override(
  ConfigLive,
  Config.layer({
    DATABASE_URL: 'memory://test',
    POOL_SIZE: '1'
  })
)

const ComposedConfig = pipe(AppConfig, Config.withEnv({ envSource: { POOL_SIZE: '2' } }))

No Config provider is installed by importing the module. Use Config.layer for in-memory sources and Layer.override for an explicit test replacement.

Then use Layer.scopedGen to construct a dependent resource:

const DatabaseLive = Layer.scopedGen(
  Database,
  async function* () {
    const config = yield* Config
    return await Database.connect(config.databaseUrl)
  },
  (database) => database.close()
)

Request-local ownership

Acquire a per-request resource inside the program:

const handleRequest = Effect.fn(async function* () {
  const transaction = yield* Effect.acquireRelease(
    () => database.beginTransaction(),
    (tx, outcome) => tx.rollbackIfNeeded(outcome)
  )

  const value = yield* Result.await(transaction.run())
  return Result.ok(value)
})

The Runtime closes the execution Scope after the request, regardless of whether the program returns an Err or throws.

Test doubles without constructors

Use Service.of and Layer.succeed for small structural fakes:

const MailerTest = Layer.succeed(
  Mailer,
  Mailer.of({
    send: async () => Result.ok(undefined)
  })
)

This is usually clearer than mocking a global module or reaching into a container registry.

Multi-tenant or per-request environments

Keep a long-lived base Runtime for shared infrastructure. For request context, transactions or tenant-specific providers, add a Layer to that execution:

const result = await runtime.runWith(
  Layer.succeed(Tenant, Tenant.of({ id: tenantId })),
  handleRequest
)

The request Layer is isolated from concurrent executions and closes its scoped providers when the request finishes. Use an explicit Layer override or a separate Runtime only when the whole application environment must differ.

Observability

Use onCleanupFailure for release and shutdown telemetry, and keep program errors as the primary diagnostic:

const options = {
  onCleanupFailure: ({ error }: RuntimeShutdownDiagnostic) => {
    logger.error({ error }, 'runtime cleanup failed')
  }
}

On this page