Skip to content

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:

  1. Raw Promises: Simple but error handling is implicit, no DI
  2. fp-ts: Good types but verbose, smaller ecosystem
  3. 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 ends
const 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 release
const ClusterLayer = Cluster.managedLayer({ clusterName: 'my-app' });

Effect Runtime Utilities

We provide utilities that clean up Effect internals from stack traces:

src/Runtime.ts
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

  1. Typed errors: Error types are in the signature Effect<A, E>, not hidden
  2. Composability: Effects compose with pipe, map, flatMap, gen
  3. Built-in logging: Effect.logDebug, Effect.logInfo, etc.
  4. Resource safety: Effect.acquireRelease and Layer.scoped for automatic cleanup
  5. Scoped layers: Client.layer() and Cluster.managedLayer() auto-manage lifecycle
  6. Concurrency: Effect.all, Effect.race, Effect.forEach with concurrency control
  7. Dependency injection: Context.Tag and Layer for testability
  8. Interruption: Effects can be cancelled cleanly

Tradeoffs

  1. Learning curve: Effect.ts has concepts to learn (Effect, Layer, Context)
  2. Bundle size: Effect.ts adds to the bundle (~50KB gzipped)
  3. Stack traces: Effect adds frames (mitigated by our cleanup utilities)
  4. Debugging: Requires understanding Effect execution model

Why Not Just Promises?

FeaturePromisesEffect.ts
Error typesPromise<T> (errors untyped)Effect<T, E> (errors typed)
Composition.then() chainspipe, gen, map, flatMap
LoggingManualBuilt-in Effect.log*
DINoneContext.Tag + Layer
CancellationComplexBuilt-in interruption
Resource cleanuptry/finallyacquireRelease

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.