Effect-style Layers in awaitly
Short answer: no. Layers are an abstraction over something simpler: passing a typed object as a parameter. awaitly uses that primitive directly — this page shows how.
Effect gives you Layer-based dependency injection: services are declared as requirements in the type (Effect<A, E, R>), provided via Layers at the top level, and accessed from context inside the program. You might wonder: does awaitly need something similar (Tags, Layers, a Runtime)? The sections below spell out what Effect’s model does and how awaitly matches those outcomes with the one rule above.
What Effect’s Layers Give You
Section titled “What Effect’s Layers Give You”Effect’s model has a few pieces:
- Requirements in the type: A program’s type says “I need
UserService | EmailService” (theRinEffect<A, E, R>). - Provision at the top: You build a Layer (or merge several) and provide it when you run the effect. The runtime fills in context.
- Access via context: Inside the program you don’t receive deps as an argument; you get services from context (e.g.
yield* UserService). - Composition: Layers can depend on other layers; the runtime builds the context in the right order.
The outcomes are: different implementations in different environments (prod vs test), a single “wire everything here” place (composition root), and type safety that “I provided everything this program needs.”
awaitly’s Equivalent
Section titled “awaitly’s Equivalent”In awaitly there are no Tags, Layers, or Runtime. You have:
createWorkflow(name, deps, options): You pass an object of functions that returnAsyncResult(your “services”).- The workflow callback receives
{ step, deps, ctx };depsis exactly that object.
So: pass the typed object you want the workflow to use (at creation and/or at run time). TypeScript enforces that it matches the interface the workflow expects. You can override deps per run via workflow.run(fn, { deps: partialDeps }) — run-time merges over creation-time. Same outcomes as Effect’s layers:
- Different implementations: Pass real deps in prod, mock deps in tests. No need for a workflow factory in tests: use one workflow instance and pass mocks in
run(fn, { deps: { fetchUser: mockFetchUser } }). - Composition root: Build one big deps object (or a factory) and pass it when you create the workflow.
- Type safety: Type the deps (or the workflow’s usage); the compiler checks.
- Automatic error inference: awaitly infers the workflow’s error union from the deps type you pass.
awaitly keeps DI explicit and object-based: “deps in the hand” (a parameter) instead of context (implicit in the type).
Example 1: Composition Root
Section titled “Example 1: Composition Root”You want one place that builds all services and workflows use them.
Effect-style (conceptually): Build a Layer, provide it when you run.
awaitly: Build a deps object (or a function that returns it) and pass it when you create the workflow.
import { createWorkflow } from 'awaitly/workflow';import { ok } from 'awaitly';
// Services (could live in separate modules)async function fetchUser(id: string) { return ok({ id, name: 'Alice', email: 'alice@example.com' });}async function sendEmail(to: string, body: string) { return ok(undefined);}
// Composition root: build the deps object onceconst deps = { fetchUser, sendEmail,};
const workflow = createWorkflow('notify-user', deps, {});
// Run somewhere else; workflow already has depsconst result = await workflow.run(async ({ step, deps }) => { const user = await step('get-user', () => deps.fetchUser('1')); await step('send', () => deps.sendEmail(user.email, `Hello ${user.name}`)); return user;});If you prefer “create workflow at the edge and pass deps from the root,” you can do that too: have a function that receives deps and returns the workflow, or create the workflow inside your app bootstrap with the deps you built there.
Example 2: Different Deps per Environment (and Testing)
Section titled “Example 2: Different Deps per Environment (and Testing)”You want the same workflow logic to run with different implementations (prod vs test, or per-request).
Effect-style: The program requires R; you provide a different Layer when you run (e.g. test Layer vs live Layer).
awaitly: Two options. (A) Workflow factory — build the workflow with the deps you want each time (prod vs test). (B) Override at run time — one workflow instance, pass mocks in the run call: workflow.run(fn, { deps: { ... } }). Creation-time deps are merged with run-time deps (run-time wins); partial override is fine. So tests can override only the dep they care about without creating a new workflow per test.
type CheckoutDeps = { chargeCard: (amount: number) => AsyncResult<Receipt, 'DECLINED'>; sendReceipt: (email: string, r: Receipt) => AsyncResult<void, 'SEND_FAILED'>;};
async function checkoutHandler({ step, deps }) { const receipt = await step('charge', () => deps.chargeCard(99)); await step('notify', () => deps.sendReceipt('user@example.com', receipt)); return receipt;}
// Option A: factory — different workflow per environmentfunction makeCheckoutWorkflow(deps: CheckoutDeps) { return createWorkflow('checkout', deps, {});}const result = await makeCheckoutWorkflow(liveDeps).run(checkoutHandler);const testResult = await makeCheckoutWorkflow(mockDeps).run(checkoutHandler);
// Option B: one workflow, override deps in the run (ideal for tests)const workflow = createWorkflow('checkout', { chargeCard: realChargeCard, sendReceipt: realSendReceipt }, {});await workflow.run(checkoutHandler); // prodawait workflow.run(checkoutHandler, { deps: { chargeCard: async () => ok({ id: 'mock-receipt' }) } }); // testThe workflow doesn’t know whether it got live or mock deps. The test suite includes “deps override at run time” tests that prove the merge semantics.
Workflow factory with args
Section titled “Workflow factory with args”When you have runtime inputs (e.g. amount, userId), pass them into a function that returns the workflow run:
type PaymentDeps = { chargeCard: (amount: number) => AsyncResult<Receipt, 'DECLINED'>; savePayment: (r: Receipt) => AsyncResult<void, 'DB_ERROR'>;};
function makePaymentWorkflow(deps: PaymentDeps) { const workflow = createWorkflow('payment', deps, {}); return (amount: number) => workflow.run(async ({ step, deps }) => { const receipt = await step('charge', () => deps.chargeCard(amount)); await step('save', () => deps.savePayment(receipt)); return receipt; });}
// Prod: makePaymentWorkflow(liveDeps); tests: makePaymentWorkflow(mockDeps)const runPayment = makePaymentWorkflow(liveDeps);const result = await runPayment(99);This “workflow factory” pattern is the awaitly equivalent of providing a Layer at the top level.
Example 3: Per-Request or Per-Run Context
Section titled “Example 3: Per-Request or Per-Run Context”Each request has its own DB connection, user, or tenant. You want to “provide deps at run time” for that request.
Effect-style: Provide a Layer (or context) per request when you run the effect.
awaitly: Build a deps object per request (or per run) and pass it when you construct and execute the workflow. Creation can happen inside the request handler.
// In your HTTP handler or queue consumerapp.post('/checkout', async (req, res) => { const db = await getDbForRequest(req); // per-request connection const userId = req.user.id;
const deps = { fetchUser: (id: string) => fetchUserFromDb(db, id), chargeCard: (amount: number) => chargeCard(amount, req.user), sendReceipt: (email: string, r: Receipt) => sendReceipt(email, r), };
const workflow = createWorkflow('checkout', deps, {}); const result = await workflow.run(async ({ step, deps }) => { const user = await step('user', () => deps.fetchUser(userId)); const receipt = await step('charge', () => deps.chargeCard(req.body.amount)); await step('notify', () => deps.sendReceipt(user.email, receipt)); return receipt; });});Provide deps at run time by passing them when you construct and execute the workflow.
Example 4: Composing Services (Layer-Like)
Section titled “Example 4: Composing Services (Layer-Like)”Effect’s Layers can depend on other layers; the runtime builds context in order. In awaitly you build the deps object yourself: one service receives another when you construct it, then you spread or merge into the object you pass to createWorkflow.
async function sendEmail(to: string, body: string) { return ok(undefined); }function createEmailService(fetchUser: (id: string) => AsyncResult<User, 'NOT_FOUND'>) { return { sendWelcome: async (userId: string) => { const user = await fetchUser(userId); if (!user.ok) return user; return sendEmail(user.value.email, `Welcome, ${user.value.name}`); }, };}const fetchUser = async (id: string) => ok({ id, name: 'Alice', email: 'a@b.com' });const deps = { fetchUser, ...createEmailService(fetchUser) };const workflow = createWorkflow('welcome-flow', deps, {});await workflow.run(async ({ step, deps }) => { await step('welcome', () => deps.sendWelcome('1')); });Build order is explicit in your code; no first-class Layer type, just objects and functions.
Example 5: Type Safety and Error Inference
Section titled “Example 5: Type Safety and Error Inference”You want the compiler to ensure you’ve provided everything the workflow needs, and you want the workflow’s error type inferred from its dependencies.
Effect: The effect type carries R; the Layer (or Runtime) type carries what’s provided; you get a type error if you run without providing R.
awaitly: Type the deps; the workflow callback expects that shape. Missing a key → type error. Error inference is automatic from the deps type you pass to createWorkflow.
type CheckoutDeps = { fetchUser: (id: string) => AsyncResult<User, 'NOT_FOUND'>; chargeCard: (amount: number) => AsyncResult<Receipt, 'DECLINED'>; sendReceipt: (email: string, r: Receipt) => AsyncResult<void, 'SEND_FAILED'>;};
function makeCheckoutRun(deps: CheckoutDeps) { const workflow = createWorkflow('checkout', deps, {}); return () => workflow.run(async ({ step, deps }) => { const user = await step('user', () => deps.fetchUser('1')); const receipt = await step('charge', () => deps.chargeCard(100)); await step('notify', () => deps.sendReceipt(user.email, receipt)); return receipt; });}
// ✅ OK: provided all required depsmakeCheckoutRun({ fetchUser: async (id) => ok({ id, name: 'A', email: 'a@b.com' }), chargeCard: async (n) => ok({ id: 'r1' }), sendReceipt: async () => ok(undefined),});
// ❌ Type error: missing required sendReceiptmakeCheckoutRun({ fetchUser: async (id) => ok({ id, name: 'A', email: 'a@b.com' }), chargeCard: async (n) => ok({ id: 'r1' }),});
// Result type includes all errors from deps: 'NOT_FOUND' | 'DECLINED' | 'SEND_FAILED' | UnexpectedErrorconst result = await makeCheckoutRun(liveDeps)();So you get “did I provide everything?” and “what errors can this workflow produce?” without a separate Layer or Runtime type.
Summary: What You Get Without Layers
Section titled “Summary: What You Get Without Layers”| Effect (Layers) | awaitly (deps object) |
|---|---|
Requirements encoded in R | Requirements encoded in the deps type |
| Provide via Layer at execution | Pass deps when constructing and/or override per run via run(fn, { deps }) |
Access from context (yield* Service) | Callback receives deps explicitly |
| Layer composition / build order | Compose objects and factories in code |
| Type check “provided ⊇ required” | Type check deps shape |
| Automatic error inference | Automatic from deps type (ErrorsOfDeps<Deps>) |
You get different implementations per environment, composition root, per-request context, and type safety with that one rule. Small surface, simple mental model.
Philosophy in one line: Effect optimizes for encoding capabilities and resources in the type system and runtime. awaitly optimizes for the same outcomes while staying close to normal JavaScript and minimal abstraction.
Day-to-day JavaScript ergonomics
Section titled “Day-to-day JavaScript ergonomics”In most JavaScript and TypeScript codebases, dependency injection is done explicitly: construct your services in one place and pass them into functions, handlers, or factories. awaitly follows that common pattern. If your team is already comfortable with async/await, plain objects, and explicit parameters, awaitly’s dependency model will feel familiar. Effect’s Layer-based approach is powerful and expressive, but it introduces a runtime and effect system that may be a bigger conceptual shift for teams used to standard JS patterns.
When You Might Still Prefer Effect’s Form
Section titled “When You Might Still Prefer Effect’s Form”Some teams prefer the form of Effect’s model:
- Requirements as
Rin the type: The program doesn’t take deps as an argument; it “requires” them and you satisfy at the top. awaitly uses “deps in the hand” instead. - First-class Layer: A single abstraction for “this service, these dependencies, this build order.” In awaitly you build objects and functions yourself.
- Context-based access:
yield* UserServiceinstead ofdeps.fetchUser. Stylistic; both are testable and flexible.
If that form is important to you (e.g. you’re coming from Effect or want a strict “require R, provide at root” style), Effect is the right fit. If you value encoding architectural constraints directly into the type system, Effect’s approach may feel safer; awaitly prioritizes simplicity and familiarity over modeling capability constraints at the type level. If you want the outcomes (testability, composition root, per-request deps, type safety) with minimal concepts and native async/await, awaitly’s approach is enough.
What about resource lifecycle and scoping?
Section titled “What about resource lifecycle and scoping?”Effect gives you scoped resources, structured concurrency, automatic acquire/release, interruption safety, and memoized layer graphs in the type system and runtime. That is where Effect has a structural advantage.
awaitly offers withScope and createResource (Resource Management): RAII-style scopes, LIFO cleanup on exit (including on error or throw), and nested scopes. So you get automatic cleanup without a separate Layer type. But awaitly does not attempt to model structured resource lifecycles in the type system. If your application depends heavily on scoped resource safety and fine-grained interruption semantics, Effect provides stronger guarantees at the cost of additional abstraction. We’re not pretending equivalence there — it’s a deliberate tradeoff: awaitly prioritizes simplicity and explicit scopes over encoding those constraints in the type system.
Common pushbacks
Section titled “Common pushbacks”“But Effect’s R is tracked through composition!”
In awaitly, you track through object composition. The type of deps after merging is the union of their capabilities. TypeScript still enforces the required shape; error inference (ErrorsOfDeps<Deps>) follows from that type. Composition is tracked via merged object types instead of an R type parameter.
“But Layers can depend on other layers automatically!”
In awaitly, you explicitly compose. You build the deps object (or a factory that takes other deps and returns more) in code. There’s no runtime that “figures out” build order; you write it. Effect’s runtime can build a dependency graph for you; awaitly keeps that graph as ordinary code — you see exactly what depends on what and in what order.
“But what about cyclic dependencies?”
Same problem, same solution. If service A needs B and B needs A, you hit that in Effect (layers) or in awaitly (object composition). Fix it the same way: lazy initialization (e.g. a getter or a function that returns the other service) or refactoring so the cycle goes away (extract shared logic, invert the dependency).
The dependency graph is normal JavaScript object composition.
One way to put it: Effect encodes architecture in types; awaitly keeps it in values. Both get you testability and a composition root — the mechanism differs.
See Also
Section titled “See Also”- awaitly vs Effect: full comparison of philosophy, APIs, and features.
- Workflows: how
createWorkflowand the step engine work. - Foundations: Workflows and steps: declaring dependencies and using
step,deps, andctx. - Resource Management: scoped resources with
withScopeandcreateResource(RAII-style cleanup).