better-effect

Patterns and Recipes

Practical designs for applications built with better-effect.

Composition roots

Keep one AppLive per process boundary:

const ConfigLive = Layer.succeed(Config, config)
const DatabaseLive = Layer.scoped(Database, connect, close)
const AppLive = Layer.merge(ConfigLive, 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.

Configuration as a Service

Make configuration explicit and testable:

class Config extends Service<Config>()('Config') {
  constructor(readonly databaseUrl: string) {
    super()
  }
}

const ConfigLive = Layer.succeed(Config, new Config(process.env.DATABASE_URL!))

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.gen(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 a truly different environment, build an explicit Layer override or a separate Runtime; do not mutate a shared backend while requests are in flight.

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