Production-Ready Astro Middleware: Dependency Injection, Testing, and Performance

Master production-ready Astro middleware with dependency injection, testing strategies, and caching for enterprise applications.


Production-Ready Astro Middleware: Dependency Injection, Testing, and Performance

Building enterprise applications requires middleware that can handle authentication, data enrichment, and performance optimization while remaining testable and maintainable. Most middleware tutorials show simple examples, but production applications demand sophisticated patterns that can handle complex dependencies, caching strategies, and comprehensive testing.

After building an Astro-powered app at work, I figured out middleware architecture that solves several problems: making middleware fully testable despite complex dependencies, implementing effective caching strategies, and maintaining clean separation of concerns. The solution involves dependency injection patterns typically seen in backend frameworks, adapted for Astro’s SSR environment.

While the examples here use Astro, the pattern is widely applicable. The same dual-file, dependency-injected middleware maps cleanly to other Node.js frameworks like NestJS (on Fastify or Express) and to other backends such as Go, where handlers, interfaces, and adapters follow the same separation of concerns. You should be able to transfer these ideas with minimal translation.

The Challenge: Testing Middleware with Complex Dependencies

The fundamental problem with middleware testing emerges when your middleware depends on services that use Node.js specific APIs. In our case, we needed middleware that enriches user data by fetching user information from a PostgreSQL database using Drizzle ORM. The database client uses import.meta.env for configuration, which creates an immediate conflict with Jest testing.

// This breaks Jest testing
import { usersService } from "../lib/services/users.service";
// ↳ usersService imports db-client.ts
//   ↳ db-client.ts uses import.meta.env.DATABASE_URL
//     ↳ Jest fails: "Cannot use 'import.meta' outside a module"

Traditional solutions involve complex Jest configuration or mocking entire modules, but these approaches create brittle tests that break when dependencies change. While modern Jest (v28+) can handle ESM with proper configuration (e.g., testEnvironment: 'node' and transformers), dependency injection provides a cleaner, more maintainable solution that works across all testing environments.

The Solution: Dual-File Architecture with Hexagonal Design

The architecture splits middleware into two files that work together to solve the testing problem while maintaining clean production code. It follows hexagonal architecture (ports and adapters): the core contains business rules exposed through interfaces (ports), while adapters integrate external systems like databases and auth providers. This keeps the core completely testable and independent of infrastructure, and the adapter file wires the core to real services.

Core Logic with Dependency Injection

The main middleware file defines interfaces for all external dependencies and implements the business logic through dependency injection:

// src/middleware/user-enrichment.middleware.ts
type UserData = { userId: string; email?: string; roles?: string[] };

export interface UsersService {
  getUser(userId: string): Promise<UserData | null>;
}

export interface RedirectService {
  getRedirectUrl(context: any): string;
}

export interface Logger {
  log(message: string): void;
  error(message: string, error?: any): void;
}

export class UserEnrichmentHandler {
  constructor(
    private usersService: UsersService,
    private redirectService: RedirectService,
    private logger: Logger = console
  ) {}

  async handleUserEnrichment(
    userId: string
  ): Promise<
    { success: true; data: UserData } | { success: false; error: string }
  > {
    try {
      const userData = await this.usersService.getUser(userId);

      if (!userData) {
        return { success: false, error: "No user data found" };
      }

      return { success: true, data: userData };
    } catch (error) {
      this.logger.error("Error fetching user data:", error);
      return {
        success: false,
        error: error instanceof Error ? error.message : "Unknown error",
      };
    }
  }
}

The factory function creates middleware that uses the handler:

export function createUserEnrichmentMiddleware(
  usersService: UsersService,
  redirectService: RedirectService,
  logger?: Logger
) {
  const handler = new UserEnrichmentHandler(
    usersService,
    redirectService,
    logger
  );

  return async (context: any, next: any) => {
    if (!context.locals.userId) {
      return next();
    }

    const result = await handler.handleUserEnrichment(context.locals.userId);

    if (!result.success) {
      const redirectUrl = redirectService.getRedirectUrl(context);
      return Response.redirect(redirectUrl);
    }

    context.locals.user = result.data;
    return next();
  };
}

Concrete Implementation (Adapter)

The adapter file handles the concrete implementation of connecting to actual services. This file imports the real database connections and services, creates adapter classes that implement the interfaces, and exports the configured middleware:

// src/middleware/user-enrichment.adapter.ts
import { usersService } from "../lib/services/users.service";
import { getRedirectUrl } from "../lib/utils/middleware-utils";

class UsersServiceAdapter implements UsersService {
  constructor(
    private service: { getUser(userId: string): Promise<UserData | null> }
  ) {}

  async getUser(userId: string): Promise<UserData | null> {
    try {
      return await this.service.getUser(userId);
    } catch (error) {
      console.error("Error fetching user data:", error);
      return null;
    }
  }
}

class RedirectServiceAdapter implements RedirectService {
  getRedirectUrl(context: any): string {
    return getRedirectUrl(context);
  }
}

export const onRequest = createUserEnrichmentMiddleware(
  new UsersServiceAdapter(usersService),
  new RedirectServiceAdapter()
);

The adapter pattern provides a clean boundary between the middleware logic and the actual service implementations. This separation allows the adapter file to handle error scenarios specific to the real services while keeping the core logic focused on business rules. When services fail, the middleware logs errors but continues processing requests, preventing database connectivity issues from making the entire application unavailable. Users might lose some functionality, but the core application remains operational.

Comprehensive Testing Strategy

The dependency injection architecture enables thorough testing of middleware behavior without dealing with database connections or import.meta issues. Tests can focus on business logic by providing mock implementations of the interfaces:

describe("UserEnrichmentHandler", () => {
  let mockUsersService: jest.Mocked<UsersService>;
  let mockRedirectService: jest.Mocked<RedirectService>;
  let handler: UserEnrichmentHandler;

  beforeEach(() => {
    mockUsersService = {
      getUser: jest.fn(),
    };

    mockRedirectService = {
      getRedirectUrl: jest.fn(() => "https://login.example.com"),
    };

    handler = new UserEnrichmentHandler(mockUsersService, mockRedirectService);
  });

  it("should fetch user data successfully", async () => {
    const mockUser = {
      userId: "test-123",
      email: "test@example.com",
      roles: [],
    };

    mockUsersService.getUser.mockResolvedValue(mockUser);

    const result = await handler.handleUserEnrichment("test-123");

    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data).toEqual(mockUser);
    }
  });

  it("should handle service errors gracefully", async () => {
    mockUsersService.getUser.mockRejectedValue(
      new Error("Database connection failed")
    );

    const result = await handler.handleUserEnrichment("test-123");

    expect(result.success).toBe(false);
    if (!result.success) {
      expect(result.error).toBe("Database connection failed");
    }
  });
});

This testing approach covers all the business logic paths without requiring actual database connections or complex mocking setups.

Performance Through Caching

This dependency injection pattern not only enables comprehensive testing but also provides the flexibility to enhance performance through strategic caching implementations.

Enterprise applications need middleware that performs well under load. Our middleware implements a caching strategy that significantly reduces database queries for user data that changes infrequently. The caching logic integrates seamlessly with the dependency injection pattern.

The choice of caching implementation depends on your deployment architecture. For traditional server deployments, in-memory caching works well, but serverless environments like Vercel or Netlify require external caching solutions.

Server-Based Deployment Caching

For applications running on persistent servers, Node-Cache provides simple in-memory caching:

import NodeCache from "node-cache";

const userCache = new NodeCache({
  stdTTL: 600, // 10 minute cache
  checkperiod: 60, // Check for expired keys every minute
});

class InMemoryCachedUsersServiceAdapter implements UsersService {
  constructor(private service: UsersService) {}

  async getUser(userId: string): Promise<UserData | null> {
    const cacheKey = `user-${userId}`;
    const cached = userCache.get<UserData>(cacheKey);

    if (cached) {
      console.log(`[Cache] Hit for user ${userId}`);
      return cached;
    }

    const data = await this.fetchAndCacheUser(userId);
    return data;
  }

  private async fetchAndCacheUser(userId: string): Promise<UserData | null> {
    try {
      const start = Date.now();
      const data = await this.service.getUser(userId);
      const end = Date.now();

      if (data) {
        userCache.set(`user-${userId}`, data);
        console.log(
          `[Cache] Miss for user ${userId}, fetched in ${end - start}ms`
        );
      }

      return data;
    } catch (error) {
      console.error("Error fetching user data:", error);
      return null;
    }
  }
}

Serverless Deployment Caching

For serverless deployments (Vercel, Netlify, AWS Lambda), use Redis or similar external caching:

import { Redis } from "@upstash/redis";

const redis = Redis.fromEnv();

class RedisCachedUsersServiceAdapter implements UsersService {
  constructor(private service: UsersService) {}

  async getUser(userId: string): Promise<UserData | null> {
    const cacheKey = `user-${userId}`;

    try {
      const cached = await redis.get<UserData>(cacheKey);
      if (cached) {
        console.log(`[Cache] Hit for user ${userId}`);
        return cached;
      }
    } catch (error) {
      console.warn("Cache read failed, falling back to database:", error);
    }

    return await this.fetchAndCacheUser(userId);
  }

  private async fetchAndCacheUser(userId: string): Promise<UserData | null> {
    try {
      const start = Date.now();
      const data = await this.service.getUser(userId);
      const end = Date.now();

      if (data) {
        // Cache for 10 minutes, ignore cache write failures
        const cacheKey = `user-${userId}`;
        redis.set(cacheKey, data, { ex: 600 }).catch(console.warn);
        console.log(
          `[Cache] Miss for user ${userId}, fetched in ${end - start}ms`
        );
      }

      return data;
    } catch (error) {
      console.error("Error fetching user data:", error);
      return null;
    }
  }
}

The dependency injection pattern makes it easy to swap between caching implementations based on your deployment target. You can even create a factory that chooses the appropriate adapter based on environment variables.

Dividing Middleware Responsibilities

Production applications benefit from dividing middleware into focused, single-responsibility functions. Rather than creating one large middleware that handles everything, we can create specialized middleware for different concerns and chain them together.

For example, authentication middleware focuses solely on parsing and validating user credentials:

// src/middleware/auth.middleware.ts
import jwt from "jsonwebtoken";
import type { APIContext, MiddlewareNext } from "astro";

export const onRequest = async (context: APIContext, next: MiddlewareNext) => {
  // Get JWT from Authorization header or cookie
  const authHeader = context.request.headers.get("authorization");
  const token =
    authHeader?.replace("Bearer ", "") || context.cookies.get("token")?.value;

  if (!token) {
    return Response.redirect("/login");
  }

  try {
    // Verify and decode the JWT
    const decoded = jwt.verify(token, import.meta.env.JWT_SECRET!, {
      algorithms: ["HS256"],
    }) as any;

    // Set user info for next middleware
    context.locals.userId = decoded.sub;
    context.locals.userEmail = decoded.email;

    return next();
  } catch (error) {
    console.error("JWT verification failed:", error);
    return Response.redirect("/login");
  }
};

This authentication middleware has a single responsibility: validate the user’s authentication status and extract basic user information. It doesn’t fetch additional user data or handle complex business logic. That work is delegated to subsequent middleware in the chain.

Security Considerations: In production, enhance this middleware with:

  • Proper token expiration handling and refresh token mechanisms
  • Secure cookie flags (Secure, HttpOnly, SameSite) for cookie-based auth
  • Rate limiting to prevent brute force attacks
  • JWT secret rotation and proper key management
  • Follow OWASP JWT security best practices

Integration with Astro’s Middleware Chain

Astro’s middleware system allows chaining multiple middleware functions using the sequence utility. Our user enrichment middleware integrates cleanly with authentication middleware and other request processing logic:

// src/middleware/index.ts
import { sequence } from "astro:middleware";
import { onRequest as authMiddleware } from "./auth.middleware";
import { onRequest as userEnrichmentMiddleware } from "./user-enrichment.adapter";

export const onRequest = sequence(authMiddleware, userEnrichmentMiddleware);

The middleware chain processes requests in order, with each middleware having access to the results of previous middleware through the context.locals object. This pattern allows complex request processing workflows while maintaining clear separation between different concerns.

The authentication middleware runs first, establishing the user’s identity and setting context.locals.userId. The user enrichment middleware then uses that userId to fetch and cache additional user data, setting context.locals.user with the complete user object. Each middleware has a focused responsibility and builds upon the work of previous middleware in the chain.

Framework Translation Guide

While this example uses Astro, the patterns translate directly to other frameworks:

NestJS Mapping

  • Core Handler@Injectable() service with business logic
  • Interfaces → Abstract classes or interfaces with @Inject() tokens
  • Production Adapters → Provider classes with @Injectable() decorator
  • Middleware Factory → Guards or Interceptors using dependency injection
  • Testing → Use NestJS testing utilities with Test.createTestingModule()

Express.js with TypeScript

  • Core Handler → Pure function or class with constructor injection
  • Interfaces → TypeScript interfaces
  • Production Adapters → Factory functions or classes
  • Middleware Factory → Higher-order function returning Express middleware
  • Testing → Use supertest with mocked services

Fastify

  • Core Handler → Plugin with encapsulated business logic
  • Interfaces → TypeScript interfaces with branded types
  • Production Adapters → Fastify decorators or plugin-scoped services
  • Middleware Factory → Fastify hooks with dependency injection
  • Testing → Use fastify.inject() with mocked dependencies

The key is maintaining the separation between business logic and infrastructure, regardless of framework-specific patterns.

Lessons for Production Applications

This middleware architecture demonstrates patterns that apply beyond Astro applications. The dual-file pattern solves testing problems that arise when production code uses APIs unavailable in test environments.

The caching strategy shows how to add performance optimizations without complicating the core business logic. By keeping caching concerns in the concrete adapters, the middleware logic remains focused on business rules while still benefiting from performance improvements.

Building production-ready middleware requires thinking beyond simple request processing to consider testing, performance, and resilience. The patterns demonstrated here create middleware that scales with your application while remaining maintainable and thoroughly tested.