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')
}
}