Draft: improve(DEV-558): implement QueryAdapter and Port for Emails
Following our meeting about the Hexagon Architecture, I'm analysing small changes we can introduce to improve our architecture. This is mostly as an exercise since I'll be starting the Registry very soon.
This MR introduces a QueryAdapter to the architecture. This adapter implements the QueryInPort, which determines the API for read-only data accesses.
We should use this implementation as a base to analyse the benefits and costs of it and discuss future directions.
My Analysis:
✅ Modules should only export ports
// src/modules/accounts/accounts.module.ts
- exports: [AccountsService, EmailsService],
+ exports: [AccountsService, IEmailsQueryInPort],
// src/modules/auth/auth.service.ts
- @Inject(EmailsService)
- private readonly emails: EmailsService,
+ @Inject(IEmailsQueryInPort)
+ private readonly emails: IEmailsQueryInPort,
This increases coding security, as not all functions inside EmailsService should be accessible everywhere. By exposing only the read only ones in IEmailsQueryInPort, we encapsulate write access to inside the Module.
✅ Better interfaces
// src/modules/accounts/emails/emails.query.in-port.ts
+ export interface IEmailsQueryInPort {
Interfaces are a nice way for us to see the actions that each service/adapter/class exposes. With time, all services should be using interfaces, and my hope is that it will help us to standardise naming conventions and flag bad functions.
✅ Services much smaller and cleaner
// src/modules/accounts/emails/emails.service.ts
- async getAccountUUID(email: EmailAddress): Promise<AccountUUID> {
- return await this.repository
- .findOneOrFail({ where: { email }, select: { accountUUID: true } })
- .then((account) => account.accountUUID)
- }
- async findOneWithSelect(
- email: EmailAddress,
- select?: Array<keyof Email>,
- ): Promise<Email> {
- return await this.repository.findOneOrFail({ where: { email }, select })
- }
By moving out all these read-only functions from the service, which have close to zero business logic, services become much shorter.
🟡 Lengthy module declaration
+ {
+ provide: IEmailsQueryInPort,
+ useClass: EmailQueryAdapter,
+ },
For each interface + implementation couple, we need to use provide and useClass in module.
🟧 Module dependencies don't change
// src/modules/tickets/tickets.module.ts
@Module({
imports: [
AccountsModule,
Since external modules use IEmailsQueryInPort, they need to import AccountsModule, which is the one who provides EmailQueryAdapter binding at runtime. This means that we won't solve the problem of Module cycle dependencies, but only Service cycle dependencies. @ingvaras any thoughts on this?