business-logic-entry-point-execution-context
Execution Context for Business Logic Entry Points
Goal
Every business-logic entry point must set up an execution context that makes request-scoped data implicitly accessible from any point in the execution chain.
The execution context replaces explicit parameter passing for cross-cutting, request-scoped data. Functions deeper in the call chain retrieve the context from the ambient store instead of receiving it as a parameter.
The entry point itself — not the infrastructure caller — is responsible for creating and populating the execution context. Whether or not execution context is used is a business-logic concern. Infrastructure code such as HTTP handlers, controllers, server actions (Next.js), message consumers, or scheduled jobs must not know about the execution context. They call the entry point normally.
In Node.js projects, use AsyncLocalStorage from the node:async_hooks module.
What Counts as In Scope
Apply this skill to code that does one or more of these things:
- defines a business-logic entry point that needs request-scoped data available throughout its execution chain
- defines the execution context type that holds request-scoped data
- reads the execution context from within business logic, persistence, or other inner functions
- manages the lifecycle of the execution context store
The Rule
-
The entry point sets up the execution context.
- The entry point — not the infrastructure caller — is responsible for setting up the context.
- The entry point wraps its own body with
runWithExecutionContextor its project-equivalent. runWithExecutionContextencapsulates the context lifecycle: it checks if a context already exists in the current execution chain and reuses it, or creates a new one if none exists. The entry point does not build or pass the context object explicitly.runWithExecutionContextaccepts an optional second parameter that controls whether a database transaction is created and stored in the execution context, and with which isolation level. When the parameter is omitted, no transaction is created.- Infrastructure callers (HTTP handlers, controllers, server actions, message consumers) call the entry point directly without knowing about the execution context.
-
Use
AsyncLocalStoragein Node.js projects.- Create a single
AsyncLocalStorageinstance for the execution context. - Use
AsyncLocalStorage.run()to bind the context to the execution chain. - The context is available through the entire asynchronous execution chain started by the callback.
- Create a single
-
Define a typed execution context.
- Declare an explicit type for the execution context that lists all fields the project needs.
- Fields that may not be available in all execution paths must be nullable.
- Do not use an untyped store such as
Map<string, unknown>orRecord<string, any>.
-
Expose a getter for the execution context.
- Provide a function such as
getExecutionContext()that retrieves the current context from the store. - The getter must return the context type or
undefinedwhen called outside an execution context scope. - Do not throw when the context is absent. Return
undefinedand let the caller decide how to handle it.
- Provide a function such as
-
The execution context holds cross-cutting, request-scoped data.
- Suitable data: requester identity, database transaction, request metadata, correlation IDs, application mode.
- The execution context is not a general-purpose dependency injection container. Only store data that is scoped to the current request or execution and that multiple layers need to access.
-
Inner functions access the context through the getter, not through parameters.
- Business logic, persistence functions, and other inner functions call the getter to obtain request-scoped data.
- Do not pass the execution context or its fields as explicit parameters through the call chain when the data is already available in the store.
-
Repository implementations must handle absent transactions gracefully.
- When a repository retrieves the transaction from the execution context and it is
undefinedornull, the repository must create a new standalone transaction for that operation. - This ensures repository methods work both inside a wrapping transaction (using the shared one from the context) and outside one (creating their own).
- When a repository retrieves the transaction from the execution context and it is
Detection Workflow
-
Find business-logic entry points.
- Identify command handlers, query handlers, use cases, or application services.
-
Check whether the entry point sets up an execution context.
- Verify that the entry point wraps its body with
runWithExecutionContextor its project equivalent.
- Verify that the entry point wraps its body with
-
Check that infrastructure callers do not set up the execution context.
- HTTP handlers, controllers, server actions, message consumers, and other callers must call the entry point directly.
- They must not import or reference the execution context module.
-
Check the execution context type.
- Verify that a typed execution context exists.
- Verify that nullable fields are explicitly marked.
-
Check inner functions for explicit parameter passing of context data.
- Look for request-scoped data such as requester identity or database transactions being passed explicitly through the call chain.
- These should instead be retrieved from the execution context getter.
Writing or Changing Code
-
Define the execution context type.
- List all request-scoped fields the project needs.
- Mark fields that may not be available as nullable.
-
Create the execution context store.
- In Node.js, create a single
AsyncLocalStorage<ExecutionContextType>instance. - Export the
runWithExecutionContextwrapper and thegetExecutionContextgetter from the same module.
- In Node.js, create a single
-
Implement
runWithExecutionContext.runWithExecutionContextchecks if a context already exists in the current execution chain. If it does, it reuses the existing context and runs the function directly. If it does not, it creates a new context and binds it to the execution chain.- The context creation logic is encapsulated inside
runWithExecutionContext. Entry points do not build or pass the context object. runWithExecutionContextaccepts an optional second parameter for transaction options. When provided, it opens a database transaction with the specified isolation level and stores it in the execution context before running the callback. When omitted, no transaction is created.
-
Wrap the entry point's body with
runWithExecutionContext.- The entry point calls
runWithExecutionContextwith its business logic as a callback. - The entry point does not pass a context object —
runWithExecutionContexthandles that internally. - When the entry point needs a database transaction, it passes the transaction options as the second parameter instead of using a separate
withTransactionwrapper.
- The entry point calls
-
Keep infrastructure callers simple.
- HTTP handlers, controllers, server actions, and other callers call the entry point directly.
- Do not import the execution context module from infrastructure code.
-
Retrieve context in inner functions through the getter.
- Replace explicit parameter passing of request-scoped data with calls to
getExecutionContext(). - Handle the
undefinedcase when the getter is called outside a context scope.
- Replace explicit parameter passing of request-scoped data with calls to
Examples
TypeScript with AsyncLocalStorage:
import { AsyncLocalStorage } from "node:async_hooks";
import { Prisma, PrismaClient } from "@prisma/client";
import { ResultAsync } from "neverthrow";
type TransactionOptions = {
isolationLevel: Prisma.TransactionIsolationLevel;
};
type ExecutionContext = {
requesterId: string | null;
transaction: Prisma.TransactionClient | null;
};
const executionContextStore = new AsyncLocalStorage<ExecutionContext>();
const prisma = new PrismaClient();
function runWithExecutionContext<Ok, Err>(
fn: () => ResultAsync<Ok, Err>,
options?: { transaction?: TransactionOptions },
): ResultAsync<Ok, Err> {
// Reuse existing context or build a new one
const context: ExecutionContext =
executionContextStore.getStore() ?? buildExecutionContext();
// If transaction options are provided, run fn inside a Prisma interactive transaction
if (options?.transaction) {
return ResultAsync.fromPromise(
prisma.$transaction(async (transaction) => {
context.transaction = transaction;
const result = await executionContextStore.run(context, fn).match(
(ok) => ({ ok }),
(err) => ({ err }),
);
if ("ok" in result) return result.ok;
// Throwing is necessary for the transaction to roll back
throw result.err;
}, options.transaction),
(error) => error as Err,
);
}
// No transaction requested — just bind the context and run
return executionContextStore.run(context, fn);
}
function getExecutionContext(): ExecutionContext | undefined {
return executionContextStore.getStore();
}
Command handler with a REPEATABLE READ transaction:
function createReservationCommandHandler(
command: CreateReservationCommand,
): ResultAsync<CreateReservationCommandHandlerSuccess, CreateReservationCommandHandlerError> {
return runWithExecutionContext(
() =>
ensureRequesterIsAuthenticated()
.andThen((requesterId) =>
ensureAvailableCars(command.carClass)
.andThen(() => persistReservation(reservation))
),
{ transaction: { isolationLevel: "REPEATABLE READ" } },
);
}
Query handler with the least blocking isolation level:
function findReservationByIdQueryHandler(
query: FindReservationByIdQuery,
): ResultAsync<FindReservationByIdQueryHandlerSuccess, FindReservationByIdQueryHandlerError> {
return runWithExecutionContext(
() =>
ensureRequesterIsAuthenticated()
.andThen((requesterId) =>
findReservationById(query.reservationId)
),
{ transaction: { isolationLevel: "READ UNCOMMITTED" } },
);
}
Entry point without a transaction:
function validateEmailCommandHandler(
command: ValidateEmailCommand,
): ResultAsync<void, ValidateEmailCommandHandlerError> {
return runWithExecutionContext(() =>
ensureRequesterIsAuthenticated()
.andThen((requesterId) =>
validateEmail(command.email)
)
);
}
Infrastructure callers are simple — they do not know about execution context:
// HTTP handler — just calls the entry point
app.post("/reservations", async (req, res) => {
const result = await createReservationCommandHandler({
carClass: req.body.carClass,
startDate: req.body.startDate,
});
res.json(result);
});
// Next.js server action — just calls the entry point
"use server";
async function createReservation(formData: FormData) {
return createReservationCommandHandler({
carClass: formData.get("carClass") as string,
startDate: formData.get("startDate") as string,
});
}
Retrieving the context from an inner function:
function ensureRequesterIsAuthenticated(): ResultAsync<
RequesterId,
RequesterIsNotAuthenticated
> {
const ctx = getExecutionContext();
const requesterId = ctx?.requesterId ?? null;
if (!requesterId) {
return errAsync(new RequesterIsNotAuthenticated());
}
return okAsync(requesterId as RequesterId);
}
Not this — the entry point builds and passes the context explicitly:
// Bad: the entry point should not build the context object
function createReservationCommandHandler(
command: CreateReservationCommand,
): ResultAsync<CreateReservationCommandHandlerSuccess, CreateReservationCommandHandlerError> {
const context: ExecutionContext = {
requesterId: command.requesterId,
transaction: null,
};
return runWithExecutionContext(context, () =>
ensureRequesterIsAuthenticated()
.andThen((requesterId) => { ... })
);
}
Not this — passing request-scoped data explicitly through the call chain when execution context is available:
// Bad: request-scoped data should come from execution context, not parameters
function createReservationCommandHandler(
command: CreateReservationCommand,
requesterId: RequesterId, // should come from execution context
transaction: DatabaseTransaction, // should come from execution context
): void { ... }
Review Questions
When reading or reviewing code, ask:
- Does this business-logic entry point set up an execution context?
- Does the entry point wrap its own body with
runWithExecutionContextor its project equivalent? - Is there a typed execution context with explicitly nullable fields?
- Does a
getExecutionContextgetter exist that returnsundefinedwhen called outside a context scope? - Are inner functions retrieving request-scoped data from the execution context instead of receiving it as explicit parameters?
- Is the execution context limited to request-scoped, cross-cutting data?
- Are infrastructure callers free from execution context concerns?
If the answer is yes, apply this skill.
Report the Outcome
When finishing the task:
- state which entry points were identified or changed
- state how the entry point sets up the execution context
- state which fields are defined in the execution context type
- state which inner functions were changed to use the execution context getter instead of explicit parameters
- state that infrastructure callers do not reference the execution context
More from code-sherpas/agent-skills
neverthrow-return-types
Require `neverthrow`-based return types in TypeScript and JavaScript code whenever the surrounding technology allows it. Use when creating, refactoring, reviewing, or extending standalone functions, exported module functions, class methods, object methods, service methods, repository methods, and similar APIs that should expose explicit success and failure result types in their signatures. Prefer `Result<T, E>` for synchronous code and `ResultAsync<T, E>` for asynchronous code. Only skip a `neverthrow` return type when a framework, library, runtime interface, or externally imposed contract is incompatible and requires a different return shape.
16neverthrow-wrap-exceptions
Capture exceptions and promise failures with `neverthrow` instead of hand-written `try/catch` in TypeScript and JavaScript code. Use when wrapping synchronous functions that may throw, promise-returning functions that may throw before returning, existing `PromiseLike` values that may reject, or third-party APIs such as parsers, database clients, HTTP clients, file-system helpers, serializers, and SDK calls. Prefer `Result.fromThrowable` for synchronous throwers, `ResultAsync.fromThrowable` for promise-returning functions that may throw or reject, and `ResultAsync.fromPromise` when you already have a `PromiseLike` value in hand. Only keep `try/catch` when the language construct, cleanup requirement, or framework boundary truly requires it.
11atomic-design
Create or update web UI components with a strict reuse-first workflow. Use when building, refactoring, restyling, or extending frontend or template components while minimizing raw DOM or HTML by reusing or generalizing existing components first.
10write-persistence-representations
Create or update persistence-layer data representations in any stack, including ORM entities, schema definitions, table mappings, document models, collection definitions, and similar database-facing code. Use when agents needs to add or change persisted fields, identifiers, relationships, indexes, timestamps, auditing fields, or storage mappings in frameworks, libraries, or ORMs such as Prisma, TypeORM, Sequelize, Drizzle, Mongoose, Hibernate/JPA, Doctrine, Ecto, Active Record, or equivalent persistence technologies.
7business-logic
Identify, interpret, review, or write business logic in code. Use when an agent needs to decide whether code expresses business rules, business algorithms, or business workflows, or when it must implement, preserve, or refactor code that creates, stores, or transforms data according to real business policies.
7immutable-domain-entities
Require the immutable design pattern for domain entities. Use when an agent needs to create, modify, review, or interpret domain entities and should preserve identity while expressing state changes through new immutable instances. Domain entities must be modeled as immutable classes, not as plain type aliases or interfaces paired with standalone functions.
7