Skip to content

Writing Middleware

This guide walks through creating custom middleware from scratch. If you just need to use existing middleware, see Using Middleware. For the conceptual model, see Middleware Model.

The Middleware Interface

A middleware is any object that implements the OperationMiddleware interface:

typescript
interface OperationMiddleware<
    PayloadType = Record<string, unknown>,
    MetadataType = Record<string, unknown>,
    ResponsePayloadType = Record<string, unknown>,
>
{
    beforeRequest : (request : StrataRequest) =>
        Promise<StrataRequest | undefined>;

    success ?: (request : StrataRequest) =>
        Promise<StrataRequest | undefined>;

    failure ?: (request : StrataRequest) =>
        Promise<StrataRequest | undefined>;

    teardown ?: () => Promise<void>;
}

Key points:

  • beforeRequest is required. The other hooks are optional.
  • All hooks are async. Return a Promise, even if the work is synchronous.
  • Hooks run serially. A slow hook delays the entire chain. Keep them fast.
  • Return the request. If you return undefined, the original request is used unchanged.
  • teardown is for cleanup. Called when the service shuts down -- close connections, flush buffers, etc.

The recommended approach is a class, because it gives you a natural place for configuration and state.

Implementing Each Hook

beforeRequest()

This is the only hook that runs before the operation handler. Use it for:

  • Validating or transforming the incoming request payload
  • Checking authentication or authorization
  • Setting up context (timing, tracing, etc.)
  • Short-circuiting the request (rejecting it before it hits the handler)
typescript
import type { OperationMiddleware, StrataRequest } from '@strata-js/strata';

class RequestLoggerMiddleware implements OperationMiddleware
{
    async beforeRequest(request : StrataRequest) : Promise<StrataRequest>
    {
        console.log(`Incoming: ${ request.context }/${ request.operation }`);
        return request;
    }
}

success()

Called after the operation handler succeeds. Use it for:

  • Transforming or enriching the response payload
  • Recording metrics (duration, status)
  • Caching successful results
typescript
async success(request : StrataRequest) : Promise<StrataRequest>
{
    // The response payload is available on the request object
    console.log(`Success: ${ request.context }/${ request.operation }`);
    return request;
}

failure()

Called after the operation handler fails (either via request.fail() or by throwing an exception). Use it for:

  • Logging errors
  • Transforming error responses
  • Recording failure metrics
  • Cleanup of resources set up in beforeRequest()
typescript
async failure(request : StrataRequest) : Promise<StrataRequest>
{
    console.error(`Failed: ${ request.context }/${ request.operation }`);
    return request;
}

teardown()

Called when the service shuts down. Use it to close connections, flush buffers, or release resources.

typescript
async teardown() : Promise<void>
{
    await this.metricsClient.flush();
    await this.metricsClient.close();
}

Modifying Requests and Responses

Modifying the Request (in beforeRequest)

The beforeRequest() hook receives the request before the handler sees it. You can modify payload, metadata, or any mutable properties:

typescript
async beforeRequest(request : StrataRequest) : Promise<StrataRequest>
{
    // Add a field to the payload
    request.payload.processedAt = new Date().toISOString();

    // Add metadata for downstream tracking
    request.metadata.traceId = crypto.randomUUID();

    return request;
}

Modifying the Response (in success / failure)

The success() and failure() hooks run after the handler has produced a result. You can modify the response payload through the request object:

typescript
async success(request : StrataRequest) : Promise<StrataRequest>
{
    // Wrap the response with additional metadata
    if(request.response)
    {
        request.response.servedAt = new Date().toISOString();
    }

    return request;
}

Short-circuiting Requests

In beforeRequest(), you can end processing early by calling request.succeed() or request.fail(). When you do this:

  1. All remaining beforeRequest() hooks are skipped.
  2. The operation handler is skipped.
  3. Execution jumps directly to the success() or failure() hooks.

This is the standard pattern for auth middleware:

typescript
async beforeRequest(request : StrataRequest) : Promise<StrataRequest>
{
    if(!request.auth)
    {
        request.fail('AUTH_REQUIRED', 'Authentication token is required.');
        return request;
    }

    const isValid = await this.validateToken(request.auth);
    if(!isValid)
    {
        request.fail('AUTH_INVALID', 'The provided token is invalid or expired.');
        return request;
    }

    return request;
}

And for cache middleware that returns a cached result without hitting the handler:

typescript
async beforeRequest(request : StrataRequest) : Promise<StrataRequest>
{
    const cacheKey = `${ request.context }:${ request.operation }:${ JSON.stringify(request.payload) }`;
    const cached = this.cache.get(cacheKey);

    if(cached)
    {
        request.succeed(cached);
        return request;
    }

    return request;
}

Error Handling

Middleware hooks should handle their own errors. An unhandled exception in a middleware hook can break the middleware chain and may prevent other middleware from executing:

typescript
async beforeRequest(request : StrataRequest) : Promise<StrataRequest>
{
    try
    {
        const user = await this.authService.validateToken(request.auth);
        request.payload.currentUser = user;
    }
    catch(error)
    {
        // Fail the request gracefully instead of letting the exception propagate
        request.fail('AUTH_ERROR', `Authentication failed: ${ error.message }`);
    }

    return request;
}

WARNING

Unhandled exceptions in success() or failure() hooks can prevent other middleware in the chain from running. Always wrap risky operations in try/catch within these hooks.

Stateful vs. Stateless Middleware

Stateless Middleware

Stateless middleware doesn't track anything between requests. Each invocation is independent. Most middleware is stateless:

typescript
class HeaderEnricherMiddleware implements OperationMiddleware
{
    readonly #defaultRegion : string;

    constructor(region : string)
    {
        this.#defaultRegion = region;
    }

    async beforeRequest(request : StrataRequest) : Promise<StrataRequest>
    {
        request.metadata.region = request.metadata.region ?? this.#defaultRegion;
        return request;
    }
}

Stateless middleware is safe to share across contexts and operations. One instance handles all requests.

Stateful Middleware

Stateful middleware tracks data across the lifecycle of a single request (e.g., timing) or across multiple requests (e.g., caching, rate limiting).

For per-request state (tracking data from beforeRequest to success/failure), use a Map keyed by request ID:

typescript
class TimingMiddleware implements OperationMiddleware
{
    #startTimes = new Map<string, number>();

    async beforeRequest(request : StrataRequest) : Promise<StrataRequest>
    {
        this.#startTimes.set(request.id, Date.now());
        return request;
    }

    async success(request : StrataRequest) : Promise<StrataRequest>
    {
        const start = this.#startTimes.get(request.id);
        if(start)
        {
            console.log(`${ request.context }/${ request.operation }: ${ Date.now() - start }ms`);
            this.#startTimes.delete(request.id);
        }
        return request;
    }

    async failure(request : StrataRequest) : Promise<StrataRequest>
    {
        // Clean up even on failure
        this.#startTimes.delete(request.id);
        return request;
    }
}

TIP

Always clean up per-request state in both success() and failure() to prevent memory leaks.

For cross-request state (like caching or rate limiting), use an appropriate data structure and be aware of concurrency. Strata processes multiple requests concurrently, so your state management must be safe for concurrent access.

Testing Middleware

Since middleware is just a class with async methods, it's straightforward to test. Create a mock request, call the hooks directly, and assert the results:

typescript
import { describe, it, expect } from 'vitest';
import { StrataRequest } from '@strata-js/strata';
import { AuthMiddleware } from './authMiddleware.js';

describe('AuthMiddleware', () =>
{
    it('should fail requests without an auth token', async () =>
    {
        const middleware = new AuthMiddleware({ secret: 'test-secret' });

        // Create a minimal request-like object for testing
        const request = new StrataRequest({
            context: 'users',
            operation: 'get',
            messageType: 'request',
            payload: { userId: '123' },
            metadata: {},
        });

        const result = await middleware.beforeRequest(request);
        expect(result.status).toBe('failed');
    });

    it('should pass requests with a valid auth token', async () =>
    {
        const middleware = new AuthMiddleware({ secret: 'test-secret' });
        const token = generateTestToken('test-secret');

        const request = new StrataRequest({
            context: 'users',
            operation: 'get',
            messageType: 'request',
            payload: { userId: '123' },
            metadata: {},
            auth: token,
        });

        const result = await middleware.beforeRequest(request);
        expect(result.status).toBe('pending');  // Still pending = not short-circuited
    });
});

For integration testing, use the null backend so you can run a full service without needing Redis:

typescript
const service = new StrataService({
    service: { serviceGroup: 'TestService' },
    backend: { type: 'null' },
});

service.useMiddleware(new AuthMiddleware({ secret: 'test-secret' }));

Complete Example: Rate Limiting Middleware

Here is a complete, real-world middleware that implements per-operation rate limiting using a sliding window counter:

typescript
import type { OperationMiddleware, StrataRequest } from '@strata-js/strata';
import { logging } from '@strata-js/strata';

// -----------------------------------------------------------------------------------------

const logger = logging.getLogger('rateLimitMiddleware');

// -----------------------------------------------------------------------------------------

interface RateLimitConfig
{
    maxRequests : number;       // Maximum requests per window
    windowMs : number;          // Window size in milliseconds
    keyFn ?: (request : StrataRequest) => string;  // Custom key extractor
}

// -----------------------------------------------------------------------------------------

export class RateLimitMiddleware implements OperationMiddleware
{
    readonly #config : RateLimitConfig;
    readonly #windows = new Map<string, { count : number; resetAt : number }>();

    constructor(config : RateLimitConfig)
    {
        this.#config = config;
    }

    async beforeRequest(request : StrataRequest) : Promise<StrataRequest>
    {
        // Build a rate limit key -- default is by auth token, fall back to client
        const keyFn = this.#config.keyFn
            ?? ((req : StrataRequest) => req.auth ?? req.client ?? 'anonymous');
        const key = `${ request.context }:${ request.operation }:${ keyFn(request) }`;

        const now = Date.now();
        let window = this.#windows.get(key);

        // Reset window if expired
        if(!window || now >= window.resetAt)
        {
            window = { count: 0, resetAt: now + this.#config.windowMs };
            this.#windows.set(key, window);
        }

        window.count++;

        if(window.count > this.#config.maxRequests)
        {
            logger.warn(`Rate limit exceeded for key: ${ key }`);
            request.fail('RATE_LIMITED', 'Too many requests. Please try again later.');
            return request;
        }

        return request;
    }

    async teardown() : Promise<void>
    {
        this.#windows.clear();
    }
}

// -----------------------------------------------------------------------------------------

Register it:

typescript
// Limit to 100 requests per minute per authenticated user
users.useMiddleware(new RateLimitMiddleware({
    maxRequests: 100,
    windowMs: 60000,
}));

Next Steps