Skip to content
Warlock.js v4

Use-cases

A use-case is a named, observable, optionally benchmarked unit of business logic. You define one with the useCase() factory; you get back a typed async function you call from a controller, a queue worker, a CLI command, or another use-case.

The reason use-cases exist as a primitive: business work has structure. There’s the authorization check, the input validation, the data transform, the actual work, the side-effects after success. If you write all of those into one service function, every controller that uses it has to wire the same lifecycle by hand. The use-case factory bakes the lifecycle in and lets you describe each piece declaratively.

This page covers the shape, the pipeline order, the context object, and how it slots into a controller. Retries, benchmarks, lifecycle event subscriptions, and history caching live in Use-cases — deep dive.

src/app/orders/use-cases/place-order.usecase.ts
import { useCase } from "@warlock.js/core";
import { placeOrderSchema, type PlaceOrderInput } from "../validation/place-order.schema";
import { authGuard } from "app/shared/guards/auth.guard";
type OrderOutput = {
orderId: string;
total: number;
};
export const placeOrderUseCase = useCase<OrderOutput, PlaceOrderInput>({
name: "orders.place",
schema: placeOrderSchema,
guards: [authGuard],
handler: async (data, ctx) => {
const order = await orderService.create(data, ctx.currentUser);
return { orderId: order.id, total: order.total };
},
});

Call it like any async function:

const result = await placeOrderUseCase(input, {
ctx: { token: request.accessToken },
});

Two type parameters and one config object — that’s the whole API surface.

Use-cases live under the module’s use-cases/ folder, file named <action>.usecase.ts:

src/app/orders/use-cases/
place-order.usecase.ts
cancel-order.usecase.ts
refund-order.usecase.ts

Naming mirrors controllers: placeOrderUseCase, cancelOrderUseCase. The export name signals what it does; the file name matches the export.

Every use-case runs the same fixed pipeline:

flowchart TD
    start([useCase&#40;data, runtime&#41;])
    exec[onExecuting fires]
    guards[guards run<br/><i>Readonly data, sequential</i>]
    schema[schema validation<br/><i>seal validates input</i>]
    before[before middleware<br/><i>sequential, can transform data</i>]
    handler[handler runs<br/><i>the actual work</i>]
    after[after middleware<br/><i>fire-and-forget, errors logged</i>]
    completed[onCompleted fires]

    start --> exec
    exec --> guards
    guards --> schema
    schema --> before
    before --> handler
    handler --> after
    after --> completed

    guards -. throws .-> err[onError fires<br/>error re-thrown]
    schema -. invalid .-> err
    before -. throws .-> err
    handler -. throws .-> err

Six phases in order. Each phase reads and writes a shared ctx object that all later phases can see. The fixed order is the value — you don’t need to think about whether to authorize before or after validation, because the framework already chose: authorize first.

Guards are authorization and precondition checks. They run before schema validation, because there’s no point validating data if the caller isn’t allowed to call this use-case in the first place.

const authGuard: UseCaseGuard<PlaceOrderInput> = async (data, ctx) => {
const user = await loadUserFromToken(ctx.token);
if (!user) {
throw new UnAuthorizedError("auth.invalidToken");
}
ctx.currentUser = user;
};

Three rules for guards:

  1. data is Readonly — you can read input fields but you can’t mutate them. Mutation happens in before middleware.
  2. Throwing aborts the pipeline — the use-case throws and onError fires. Use HttpError subclasses (UnAuthorizedError, ForbiddenError, …) so the calling controller’s catch can map to a status code, or let the framework’s catch-all do it.
  3. Enrich ctx — typically you set ctx.currentUser, ctx.permissions, etc. so the handler and after-middleware can read them.

Guards run sequentially in array order. Auth guard first, role guard second — the framework respects what you wrote.

If you pass a schema, the framework runs it after guards succeed:

schema: placeOrderSchema,

On failure, it throws BadSchemaUseCaseError (an HttpError with status 400). The controller catches it via the framework’s catch-all and sends a 400 with the validation errors.

The validated, parsed data becomes the input to the next phase. Schema-level transforms (.transform(...) in seal) take effect here.

Now you can transform data. Each before middleware receives the current data and returns the (optionally transformed) data:

const normalizeAddress: UseCaseBeforeMiddleware<PlaceOrderInput> = async (data, ctx) => {
return {
...data,
address: {
...data.address,
country: data.address.country.toUpperCase(),
},
};
};
const calculateTax: UseCaseBeforeMiddleware<PlaceOrderInput> = async (data, ctx) => {
const tax = await taxService.compute(data.items, data.address);
ctx.tax = tax;
return data;
};

Two rules:

  1. Return the data — even if you didn’t transform it. The chain feeds output of one middleware into input of the next.
  2. Mutate ctx freelytax, pricing, anything the handler will need.

Throwing aborts the pipeline the same way a guard does.

The handler is the actual work. It receives the validated + transformed data plus the enriched ctx:

handler: async (data, ctx) => {
const order = await orderService.create({
...data,
user_id: ctx.currentUser.id,
tax: ctx.tax,
});
return { orderId: order.id, total: order.total };
};

Throwing aborts the pipeline. The return value is the use-case’s output.

After middleware runs on success only, fire-and-forget. Errors inside an after middleware are caught and logged but never re-thrown — they cannot affect the use-case’s return value.

const sendConfirmationEmail: UseCaseAfterMiddleware<OrderOutput> = async (output, ctx) => {
await mailer.send({
to: ctx.currentUser.email,
template: "order-confirmation",
data: { orderId: output.orderId },
});
};

This is the right home for analytics, webhooks, cache invalidation, and notifications — anything that shouldn’t fail the user’s request if the side-effect itself fails.

Three callbacks fire at well-known moments:

  • onExecuting(ctx) — at the start, before guards run. Use for tracing, logging the input.
  • onCompleted(result) — on success, after after-middleware. Use for cross-cutting analytics or call-count tracking.
  • onError(ctx) — on failure, with the thrown error. Use for centralized error logging.

You can register them on the use-case definition, or override them at the call site via the runtime options second argument. There’s also a global subscription pattern for “every use-case in the app” — see Use-cases — deep dive.

ctx is the shared dictionary every phase reads and writes. The framework seeds it with the schema and execution id; guards typically add the current user; before-middleware adds derived data; the handler reads everything.

guards: [authGuard], // sets ctx.currentUser
before: [calculateTax], // sets ctx.tax
handler: async (data, ctx) => {
// both ctx.currentUser and ctx.tax are populated
};

It’s typed as UseCaseContext = { schema?: ObjectValidator } & Record<string, any> — pragmatic, not strict. Add fields with descriptive names so the next reader knows where they came from.

You can also pass starter context from the call site:

await placeOrderUseCase(data, {
ctx: { token: request.accessToken, requestId: request.id },
});

The use-case definition reads what it needs, ignores the rest.

The actual login use-case from the reference codebase — sixteen lines, no decorators, no DI:

src/app/auth/use-cases/login.usecase.ts
import type { DeviceInfo, LoginResult } from "@warlock.js/auth";
import { ForbiddenError, useCase } from "@warlock.js/core";
import { type User } from "app/users/models/user";
import { type LoginCredentials, loginService } from "../services/auth.service";
type LoginUseCase = LoginResult<User> & {
organization: OrganizationEntity;
};
type LoginUseCaseInput = {
data: LoginCredentials;
deviceInfo?: DeviceInfo;
};
export const loginUseCase = useCase<LoginUseCase, LoginUseCaseInput>({
name: "auth.login",
async handler({ data, deviceInfo }) {
const result = await loginService(data, deviceInfo);
if (!result) {
throw new ForbiddenError("Invalid credentials");
}
await result.user.load("organization");
return {
...result,
organization: result.user.organization?.toJSON() as OrganizationEntity,
};
},
});

Read it:

  1. Two type parameters: the output (login result + organization) and the input (credentials + device info).
  2. A name for registry, logging, history.
  3. A handler that delegates to the auth service, throws ForbiddenError on bad credentials, loads the related organization, and returns the result.

No guards, schema, before, or after this time — the controller already validated, and the service handles the auth check. Use-cases are flexible; you only declare the phases you need.

The controller calls it like this:

const result = await loginUseCase({
data: request.validated(),
deviceInfo: {
userAgent: request.userAgent,
ip: request.ip,
},
});

One async function call. The pipeline runs transparently.

The second argument is UseCaseRuntimeOptions — optional overrides for this single call:

await placeOrderUseCase(input, {
id: "order-from-cli",
ctx: { token: cliAuthToken, source: "cli" },
onCompleted: (result) => console.log("Done:", result.output),
});
FieldUse
idOverride the auto-generated execution id
ctxPre-populated context object
onExecutingInvocation-level lifecycle callback
onCompletedInvocation-level lifecycle callback
onErrorInvocation-level lifecycle callback

Invocation-level callbacks fire first, before the use-case-level callback, before any global subscribers. The order is: invocation → use-case definition → global.

Use-cases don’t import request or response. They take plain data and return plain data. That’s deliberate — the same use-case can be called from:

  • A controller (HTTP)
  • A queue worker (Bull / Redis-backed jobs)
  • A CLI command (yarn warlock <command>)
  • A cron-scheduled task
  • A test (no mocking framework needed)
  • Another use-case

If your handler reaches for request.something, you’ve coupled to HTTP — and now the queue worker has to fake a request. Keep the handler clean; do all the request-reading in the controller and pass the data in.

When to reach for a use-case (vs a service)

Section titled “When to reach for a use-case (vs a service)”

A service is a stateless function: input in, output out.

A use-case is a service plus the structured pipeline around it. The cost is a tiny amount of declarative wiring; the value is consistent error handling, observability, retries, and lifecycle events without bespoke code per call site.

Reach for a use-case when:

  • The work has authorization checks that should run before validation.
  • You want centralized retries on transient failures.
  • You want benchmark + history tracking (debugging slow endpoints).
  • You want a single name that shows up in logs and the use-case registry.
  • The work has fire-and-forget side-effects (email, webhook, cache invalidation) that you don’t want failing the user’s response.

Stick with a plain service when:

  • The work is a single-call delegation (Faq.find(id)).
  • There’s no authorization beyond the route’s middleware.
  • You don’t need retries, benchmarks, or history.

The list-faqs.service.ts from the reference codebase is the textbook plain-service case — it just calls faqsRepository.listCached(filters). No pipeline needed.

  • Guards see Readonly<Input> — mutating data inside a guard is a TypeScript error. Transform in before middleware instead.
  • After-middleware errors are silent. They’re logged via @warlock.js/logger (log.error), but they don’t escalate. If a webhook must succeed, put it in before (or its own controller call), not after.
  • Schema runs after guards, not before. This is the opposite of some other frameworks — Warlock authorizes first, then validates. Don’t rely on schema validation to gate the guard’s data access; the guard runs first.
  • benchmark: false disables benchmarking for that one use-case even if the app config enables it globally. (The option is benchmark, not benchmarkOptions.)
  • name should be unique across the app — it’s the cache key and the registry key. The framework warns on dev-mode duplicates.

Continue to Repositories to see the data-access layer the handler reaches for.