ADR-003: Effect as Core Runtime
Status
Accepted
Context
Building a blockchain SDK involves complex challenges:
- Async operations: Wallet sync, network calls, proof generation
- Error handling: Many failure modes (network, wallet, contract, ZK proofs)
- Resource management: Connections, caches, providers
- Composability: Operations need to be combined in various ways
- Dependency injection: For testing and modularity
We evaluated several approaches:
- Raw Promises: Simple but error handling is implicit, no DI
- fp-ts: Good types but verbose, smaller ecosystem
- Effect.ts: Comprehensive, typed errors, DI, growing ecosystem
Decision
We use Effect.ts as the core runtime for all SDK operations.
Core Effect Patterns
Effect.gen for Sequential Composition
function createEffect(config: ClientConfig): Effect.Effect<MiddayClient, ClientError> { return Effect.gen(function* () { yield* Effect.logDebug('Initializing wallet...');
const walletContext = yield* Wallet.effect.init(walletSeed, networkConfig).pipe( Effect.mapError((e) => new ClientError({ cause: e, message: `Failed to initialize wallet: ${e.message}`, })), );
yield* Wallet.effect.waitForSync(walletContext).pipe( Effect.mapError((e) => new ClientError({ cause: e, message: `Failed to sync wallet: ${e.message}`, })), );
yield* Effect.logDebug('Wallet synced');
const providers = client.providers;
return { wallet: walletContext, networkConfig, providers, logging }; });}Effect.tryPromise for External Calls
const txData = yield* Effect.tryPromise({ try: () => callTx[action](...args), catch: (cause) => new ContractError({ cause, message: `Failed to call ${action}: ${cause instanceof Error ? cause.message : String(cause)}`, }),});Context.Tag for Dependency Injection
export class ClientService extends Context.Tag('ClientService')< ClientService, ClientServiceImpl>() {}
export const ClientLive: Layer.Layer<ClientService> = Layer.succeed(ClientService, { create: createEffect, fromWallet: fromWalletEffect, loadContract: loadContractEffect, waitForTx: waitForTxEffect,});Layer for Pre-configured Dependencies
export function layer(config: ClientConfig): Layer.Layer<MiddayClientService, ClientError, Scope.Scope> { return Layer.scoped(MiddayClientService, createScoped(config));}
// Usage — client is automatically closed when the scope endsconst clientLayer = Midday.Client.layer(config);const program = Effect.gen(function* () { const client = yield* Midday.Client.MiddayClientService; // client is automatically available});await Effect.runPromise(program.pipe(Effect.provide(clientLayer)));Managed Layer for Devnet Cluster
export function managedLayer(config?: ClusterConfig): Layer.Layer<ClusterService, ClusterError> { return Layer.scoped(ClusterService, effect.makeScoped(config));}
// Usage — cluster auto-starts on creation, auto-removes on releaseconst ClusterLayer = Cluster.managedLayer({ clusterName: 'my-app' });Effect Runtime Utilities
We provide utilities that clean up Effect internals from stack traces:
export async function runEffectPromise<A, E>(effect: Effect.Effect<A, E>): Promise<A> { const exit = await Effect.runPromiseExit(effect); if (Exit.isFailure(exit)) { const error = Cause.squash(exit.cause); const cleanedError = cleanErrorChain(error); // Removes Effect internals throw cleanedError; } return exit.value;}
export async function runEffectWithLogging<A, E>( effect: Effect.Effect<A, E>, logging: boolean,): Promise<A> { const loggerLayer = logging ? Layer.merge(Logger.pretty, Logger.minimumLogLevel(LogLevel.Debug)) : Logger.pretty; return runEffectPromise(Effect.provide(effect, loggerLayer));}Consequences
Benefits
- Typed errors: Error types are in the signature
Effect<A, E>, not hidden - Composability: Effects compose with
pipe,map,flatMap,gen - Built-in logging:
Effect.logDebug,Effect.logInfo, etc. - Resource safety:
Effect.acquireReleaseandLayer.scopedfor automatic cleanup - Scoped layers:
Client.layer()andCluster.managedLayer()auto-manage lifecycle - Concurrency:
Effect.all,Effect.race,Effect.forEachwith concurrency control - Dependency injection: Context.Tag and Layer for testability
- Interruption: Effects can be cancelled cleanly
Tradeoffs
- Learning curve: Effect.ts has concepts to learn (Effect, Layer, Context)
- Bundle size: Effect.ts adds to the bundle (~50KB gzipped)
- Stack traces: Effect adds frames (mitigated by our cleanup utilities)
- Debugging: Requires understanding Effect execution model
Why Not Just Promises?
| Feature | Promises | Effect.ts |
|---|---|---|
| Error types | Promise<T> (errors untyped) | Effect<T, E> (errors typed) |
| Composition | .then() chains | pipe, gen, map, flatMap |
| Logging | Manual | Built-in Effect.log* |
| DI | None | Context.Tag + Layer |
| Cancellation | Complex | Built-in interruption |
| Resource cleanup | try/finally | acquireRelease |
Integration with Promise API
Effect is an implementation detail for Promise API users:
// User writes:const client = await Midday.Client.create(config);
// Under the hood:export async function create(config: ClientConfig): Promise<MiddayClient> { return runEffectWithLogging(createEffect(config), config.logging ?? true);}Promise API users never see Effect types unless they choose the Effect API.