← Back to list

Awilixify: Making Middlewares End to End Type-Safe

Middleware is useful. Middleware is everywhere. But middleware is also where application flow often becomes implicit.

R Vanzhula · 2026-05-31 11:17 · 0 claps · 5.8 min read
#nodejs #typescript #architecture #backend-development #dependency-injection
Open on Medium ↗
Wiki topics: 🌐 · Web Development 🏛️ · Architecture

Awilixify: Making Middlewares End to End Type-Safe

Middleware is useful. Middleware is everywhere. But middleware is also where application flow often becomes implicit.

This article is about one Awilixify feature I care about a lot: type-safe pre-handlers. They let you move auth, tenant resolution, policy checks, and similar request-time logic out of framework middleware and into a typed application pipeline.

Middleware Makes Application Flow Implicit

Middleware is one of the most common patterns in Node.js applications, but it is also one of the easiest places to hide bugs.

Even before talking about type safety, middleware is fragile by nature. Order matters. One middleware mutates the request object. Another middleware assumes that mutation already happened. By the time the route handler runs, req contains fields that were added implicitly somewhere earlier in the middleware chain.

Business Logic Drifts Into Infrastructure

In many backend applications, HTTP middleware slowly becomes the place where too much application logic lives. Authentication goes there. Tenant resolution goes there. Permission checks go there. Sometimes even business validation starts there.

But these concepts are usually business concepts. Middleware is mainly an infrastructure/framework concept. When important checks live there, business logic becomes coupled to the HTTP framework and its request lifecycle.

Middleware Binds Logic To HTTP

But what happens when the same check is needed outside HTTP?

What if a queue consumer needs the same auth rules? What if a cron job needs the same tenant checks? What if a CLI command should reuse the same validation?

The more important logic you put into the HTTP layer, the harder it becomes to reuse that logic anywhere else. Tests also need to reproduce the same request setup, even when the behavior being tested is not really about HTTP.

TypeScript Does Not Know The Chain

And usually TypeScript does not help enough here. A middleware may attach req.user . Another middleware may attach req.tenant. The request handler sees only the final req object and assumes both fields exist.

It gets even trickier when execution is conditional. What if one route is public, another needs auth, and another needs auth plus tenant? You can wire different middleware chains per route, but it is still hard to make the final handler type reflect the exact chain that ran.

You can manually create a custom request type, but that only describes the shape you hope to receive. It does not prove which middleware ran, or whether it ran in the correct order.

All those problems Awilixify pre-handlers try to solve.

Why Build Another Middleware Layer?

With Awilixify, the idea is not to remove or don’t use HTTP middleware. Keep it for things that are truly HTTP or infrastracture concerns: rate limiting, CORS, compression, body parsing, low-level logging.

The idea is to have a separate, more controlled middleware chain for business logic.

The goal is:

  • more control over which pre-handlers run
  • type-safe context produced by those runs
  • business rules outside the HTTP framework, not bound to req
  • one entry point for a use case through the mediator
  • the same behaviour available from HTTP, queues, cron jobs, CLI scripts, or tests

Passing Request Data Without Passing Request

You may ask: if we stop passing req everywhere, where does request data go?

Awilixify has executionContext for that.

It is immutable runtime input passed to mediator execution. It can contain things like token, tenant header, request id, locale, or anything else extracted from the current transport.

class UsersController {
  constructor(private readonly queryMediator: Deps["queryMediator"]) {}

  @GET("/users")
  async getUser(req, res) {
    const result = await this.queryMediator.execute(
      "users/get", // name of usecase
      { id: req.params.id }, // payload of usecase
      {
        executionContext: {
          token: req.headers.authorization,
          tenantName: req.headers["x-tenant"],
        },
      },
    );

    return res.json(result);
  }
}

The important rule is simple: Only pre-handlers read executionContext

Creating an Auth Pre-Handler

Now we can build the first pre-handler. It reads runtime data from executionContext and returns typed handlercontext :

import { Result, type Middleware, type MiddlewareContract } from "awilixify";

class UnauthorizedError extends Error {
  readonly code = "unauthorized"
}

type ReturnType = Result<{ userId: string }, UnauthorizedError>;
type Contract = MiddlewareContract<typeof AuthMiddleware.key, ReturnType>;

class AuthMiddleware implements Middleware<Contract> {
  static key = "auth";

  declare readonly contract: Contract;

  async execute(
    _payload: unknown,
    _context: Contract["context"],
    executionContext: Contract["executionContext"],
  ): Promise<ReturnType> {
    if (!executionContext.token) {
      return Result.error(new UnauthorizedError());
    }
    // some usual jwt check can be here

    return Result.ok({ userId: "u-1"});
  }
}

The successful output of middleware is userId: string

Later will be shown how handler can use this data

Why Result Type?

You may ask: why return Result ? Why not just throw an error from a pre-handler?

Throwing works, but it hides important information from types.

If AuthMiddleware throws UnauthorizedError , TypeScript does not know that executing users/get may fail with UnauthorizedError . The error path exists, but it is invisible in the function signature.

Result makes that path explicit:

Result<{ userId: string }, UnauthorizedError>;

This says two things:

  • success adds { userId: string } to the handler context
  • failure returns UnauthorizedError

Awilixify can use both parts. The success type becomes handler context. The error type becomes part of the final mediator result.

It also keeps business logic transport-agnostic. UnauthorizedError is an application error. It can later be mapped to HTTP 401 in a controller, to a queue failure reason, or to a CLI message in a script.

The Handler Gets Clean Context

The handler does not need to know where the token came from. It does not need to know about Express request objects. It only receives the context produced by successful pre-handlers.

import { type Handler, type QueryContract, Result } from "awilixify";

class UserNotFoundError extends Error {
  readonly code = 'user.not_found'
}

type Response = Result<{ id: string; role: string }, UserNotFoundError>;

class GetUserHandler implements Handler<GetUserHandler["contract"]> {
  static readonly key = "users/get";

  declare readonly contract: QueryContract<
    typeof GetUserHandler.key,
    { id: string },
    Response
  >;

  async executor(
    payload: this["contract"]["payload"],
    context: this["contract"]["context"],
  ): Promise<Response> {
    // typed context: { userId: string }
    const { userId } = context;

    if (!payload.id) {
      return Result.error(new UserNotFoundError());
    }

    return Result.ok({ id: payload.id, role: "admin" });
  }
}

This is the E2E type-safe flow I wanted.

If AuthMiddleware is active for handler and the handler itself returns Result<Success, UserNotFoundError> , the final mediator execute result in controller is inferred as:

Result<Success, UnauthorizedError | UserNotFoundError>

Registering Pre-Handlers in a Module

Pre-handlers are declared in a module:

// users.module.ts
import { createModule, type ModuleDef } from "awilixify";

type UsersModuleDef = ModuleDef<{
  queryHandlers: [typeof GetUserHandler];
  queryPreHandlers: {
    auth: AuthMiddleware;
  };
}>;

export const UsersModule = createModule<UsersModuleDef>({
  name: "UsersModule",
  queryHandlers: [GetUserHandler],
  queryPreHandlers: {
    auth: AuthMiddleware,
  },
});

In this form, pre-handlers are module-scoped. They belong to UsersModule and participate in the mediator pipeline for query handlers in that module.

If a pre-handler is a shared application behavior, it can be exported from one module and imported or registered globally where other modules need it.

Scenarios: Explicit Per-Call Control

Sometimes you do not want the full pipeline.

Maybe one query requires auth. Another is public. Another needs additional policy checks.

In many frameworks this becomes decorators on controller methods:

@UseGuards(AuthGuard, PolicyGuard)

Awilixify takes a more explicit approach at the mediator call level.

Handlers can declare scenarios:

declare readonly contract: QueryContract<
  typeof GetUserHandler.key,
  { id: string },
  Response,
  // all available for module pre-handlers to include
  | { name: "default" } 
  // exclude "auth" pre-handler
  | { name: "public"; exclulePreHandlerKeys: ["auth"] }
>;

Then the caller chooses:

// NotAuthorizedError will not be part of return type
// and auth pre-handler won't be executed
const result = await queryMediator.execute(
  "users/get",
  { id: req.params.id },
  {
    scenario: "public",
    excludePreHandlerKeys: ["auth"],
    executionContext: {
      token: req.headers.authorization,
    },
  },
);

The important part is that context and return type are recalculated for the scenario. That makes execution rules visible in code and checked by TypeScript.

To Sum Up

Awilixify pre-handlers give you a way to move important request-time logic out of framework middleware and into a type-safe application pipeline.

The controller passes raw runtime data through executionContext . Pre-handlers validate it, enrich it, and return typed context. Handlers receive that typed context without depending on an HTTP request object.

With Result and scenarios, the whole middleware flow becomes something you can trust: success context, failure types, and per-call pipeline changes are all visible to TypeScipt.

For me, that is the real value: the chain is no longer just runtime wiring. You can see what enters it, what each pre-handler adds, and what result controller recieves at the end.

Links

If this approach looks useful for you project, you can check Awilixify here:

And if the article was helpful, please leave a clap. It helps more people find the project.

Thanks for reading!


메타데이터
post_id
b3f4dbfb6b42
slug
awilixify-making-middlewares-end-to-end-type-safe-b3f4dbfb6b42
url
https://medium.com/@r.vanzhula/awilixify-making-middlewares-end-to-end-type-safe-b3f4dbfb6b42
canonical_url
https://medium.com/@r.vanzhula/awilixify-making-middlewares-end-to-end-type-safe-b3f4dbfb6b42
author_url
https://medium.com/@r.vanzhula
status
ok
fetched_at
2026-06-09 15:37:30