typescriptclean-architecturecloudflare-workershono

How to maintain Clean Architecture principles with Cloudflare Workers' context-dependent bindings in a Hono API?


I am currently developing a Hono API application practicing the basics of Clean Architecture (you can look it up), but tldr it provides ways to isolate layers between each other, making it easier to test different parts of the application due to having the layers loosely coupled together.

However, I am currently having issues with trying to attain this loose coupling due to Cloudflare Workers having the bindings (and therefore my database connection) only being available inside the context variable of Hono. I am forced to create a function in the application layer that accepts the bindings from the interface to the application before executing the application layer, and this makes the testing of this layer impractical, let alone this already violates the clean architecture principle.

Here's a snippet of my code with the issue. In this one, I am only referencing an environment variable that can be accessed in the context variable which is a string, but this could be anything.

// application/service/jwtservice.ts
export class JWTServiceImpl implements JWTService {
    private secret: string = '';

    bindEnv(env: EnvContextProvider): void {
        this.secret = env.jwtSecret;
    }

    sign(payload: ReturnType<typeof Users.prototype.getSafeData>): string {
        return jwt.sign(payload, this.secret);
    }

    verify(token: string): boolean {
        return jwt.verify(token, this.secret) ? true : false;
    }
}

// application/authentication/login.ts
export class LoginUseCase {
    constructor(
        private readonly usersRepository: UsersRepository,
        private readonly jwtService: JWTService,
    ) {}

    async verifyToken(token: string, env: EnvContextProvider): Promise<void> {
        this.jwtService.bindEnv(env);
        // this feels wrong
        const data = this.jwtService.verify(token);
        if (!data) {
            throw new AuthenticationError('Invalid Token');
        }
    }
}
// interfaces/controller/authcontroller.ts
export class AuthController {
    public router: OpenAPIHono<OpenAPIHonoSettings>;
    constructor(
        app: OpenAPIHono<OpenAPIHonoSettings>,
        private loginUseCase: LoginUseCase,
    ) {
        this.router = app;
        this.setupRoutes();
    }

    private setupRoutes() {
        return [
            this.router.openapi(checkLoginRoute, async (c) => {
                const token = getCookie(c, 'loginToken3');
                if (!token) {
                    return c.json({ success: false }, StatusCodes.UNAUTHORIZED);
                }
                try {
                    await this.loginUseCase.verifyToken(token, new EnvContextProviderImpl(c));
                    // this feels wrong, im supposed to do this in the DI container/app init
                    return c.json({ success: true }, StatusCodes.OK);
                } catch (error) {
                    if (error instanceof AuthenticationError) {
                        return c.json({ success: false }, StatusCodes.UNAUTHORIZED);
                    }
                    c.var.logger.error('Internal Server Error', { error });
                    return c.json({ success: false }, StatusCodes.INTERNAL_SERVER_ERROR);
                }
            }),
        ] as const;
    }
}

I would like to be enlightened on how I should approach this. I looked up using context providers for this but it still involves injecting this context into the use case, which I believe am already doing.


Solution

  • I found a clever solution to this. In the DI container, we usually register our dependencies during app initialization. However, it is fine to register a dependency in context. Here's a snippet of how I did it.

    export function mountPrismaDatabase(app: OpenAPIHono<OpenAPIHonoConfig>) {
        app.use(async (c, next) => {
            // order is important here.
            // we initialize our prisma client connection first
            // and bind it to the context
            const adapter = new PrismaD1(c.env.DB);
            const client = new PrismaClient({ adapter });
            c.set('prisma', client);
            await next();
        });
    
        app.use(async (c, next) => {
            // we then register that context to our di container
            registerContextModule(applicationContainer, c);
            await next();
        });
    }
    

    I can then consume this dependency in my infrastructure.

    type OAHonoContext = Context<OpenAPIHonoConfig>;
    
    export class AuthenticationService implements IAuthenticationService {
        constructor(private _context: OAHonoContext) {}
    
        // ...code...
    
        createToken(session: Session): string {
            return jwt.sign(session.getData(), this._context.env.JWT_SECRET);
        }
    
        // ...code...
    }
    

    If you need more details, you may check this basic todo repo that I am currently using to practice clean architecture in my profile. I am linking the exact commit hash so that this link won't be broken when I move files around.