Appearance
007 - Bidirectional Gateway Pattern (Identity ↔ Plans)
Status
Accepted
Date
2026-04-05
Context
The Business Registration Gates feature (see feature doc) introduced three fail-fast validation gates at POST /identity/auth/user/register/business and two symmetric admin guards.
Gate 2 (active plans check) and Guard 1 (enable-flag pre-condition) require the Identity module to read from the Plans domain synchronously:
"Does this agency have at least one active public plan?"
Guard 2 (last-plan deactivation) requires the Plans module to read from the Identity domain synchronously:
"Is business registration enabled for this agency?"
Until this feature, the module communication rule was:
- Identity → Plans via
ISubscriptionLimitsGateway(already established for user-capacity checks) - Plans → Identity: no cross-module read existed
The new requirement created a need for the reverse direction: Plans must be able to read an agency flag from Identity.
Why Synchronous?
All three gates must produce a synchronous HTTP response. Approaches based on domain events or read-model projections were considered (see Alternatives) but rejected because:
- Events are inherently asynchronous and cannot veto an HTTP request in flight.
- Read-model projections introduce eventual consistency windows and a second source of truth.
- The flag changes rarely; the overhead of a synchronous DB read is negligible.
Existing Pattern: ISubscriptionLimitsGateway
The unidirectional IdentityModule → PlansModule gateway was already accepted as a pattern in this codebase (used by RegisterUserBusinessOwnerHandler and StorageModule). The decision in this ADR is specifically about whether to extend that pattern to create a bidirectional import cycle at the module level.
Decision
Introduce IOrganizationFlagsGateway as the reverse-direction gateway, following the identical structural pattern as ISubscriptionLimitsGateway. Allow Identity and Plans to import each other at the NestJS module level, using forwardRef() to resolve the cycle.
Interface (new, in shared/)
typescript
// apps/api/src/shared/application/services/organization-flags-gateway.service.interface.ts
export interface IOrganizationFlagsGateway {
/**
* Returns true if the given agency has allowBusinessRegistration enabled.
* Returns false if the agency does not exist, is not of type AGENCY, or the flag is off.
*/
isBusinessRegistrationEnabled(agencyId: string): Promise<boolean>;
}
export const OrganizationFlagsGatewayKey = Symbol('IOrganizationFlagsGateway');Adapter (in Identity, the owner of the flag)
typescript
// apps/api/src/modules/identity/infrastructure/adapters/organization-flags-gateway.adapter.ts
@Injectable()
export class OrganizationFlagsGatewayAdapter implements IOrganizationFlagsGateway {
constructor(
@Inject(OrganizationRepositoryKey)
private readonly orgRepo: IOrganizationRepository,
) {}
async isBusinessRegistrationEnabled(agencyId: string): Promise<boolean> {
const org = await this.orgRepo.findById(agencyId);
return org?.type === OrgTypes.AGENCY && org.allowBusinessRegistration;
}
}The adapter is registered in IdentityInfrastructureAdapters and exported via IdentityModule.
Module-Level Cycle with forwardRef()
typescript
// identity.module.ts
imports: [
...,
forwardRef(() => PlansModule), // for ISubscriptionLimitsGateway
]
// plans.module.ts
imports: [
...,
forwardRef(() => IdentityModule), // for IOrganizationFlagsGateway
]Module Topology After This Change
graph TD
subgraph Shared["shared/application/services/"]
SLG["ISubscriptionLimitsGateway"]
OFG["IOrganizationFlagsGateway (NEW)"]
end
subgraph Identity["IdentityModule"]
IRH["RegisterUserBusinessOwnerHandler<br/>(consumes ISubscriptionLimitsGateway)"]
IUO["UpdateMyOrganizationHandler<br/>(consumes ISubscriptionLimitsGateway)"]
IOA["OrganizationFlagsGatewayAdapter<br/>(implements IOrganizationFlagsGateway)"]
end
subgraph Plans["PlansModule"]
PUA["UpdateAgencyPlanHandler<br/>(consumes IOrganizationFlagsGateway)"]
PSA["SoftDeleteAgencyPlanHandler<br/>(consumes IOrganizationFlagsGateway)"]
PSL["SubscriptionLimitsGatewayAdapter<br/>(implements ISubscriptionLimitsGateway)"]
end
Identity -->|"imports (forwardRef)"| Plans
Plans -->|"imports (forwardRef)"| Identity
IOA -->|"implements"| OFG
PSL -->|"implements"| SLG
IRH -.->|"injects via"| SLG
IUO -.->|"injects via"| SLG
PUA -.->|"injects via"| OFG
PSA -.->|"injects via"| OFG
style Shared fill:#f5f0ff,stroke:#7b68ee
style Identity fill:#e8f5e9,stroke:#27ae60
style Plans fill:#e3f2fd,stroke:#1976d2
Key rule: Each adapter only injects its own module's repositories. OrganizationFlagsGatewayAdapter injects IOrganizationRepository (identity). SubscriptionLimitsGatewayAdapter injects IPlanRepository (plans). Neither adapter calls back into the other module, so no circular provider construction occurs.
Alternatives Considered
A — Denormalize allowBusinessRegistration into a plans-local read model
Project the flag into a plans-owned table via domain events (BusinessRegistrationToggled).
Rejected: Introduces an eventual consistency window between when the admin toggles the flag and when the plans module sees the updated state. Guard 2 requires a synchronous, consistent read at mutation time. A stale cache could allow deactivation of the last plan immediately after the flag was turned ON.
B — Add the flag check to ISubscriptionLimitsGateway itself
Instead of creating IOrganizationFlagsGateway, add isBusinessRegistrationEnabled() as a method on the existing ISubscriptionLimitsGateway, but invert the implementation so plans reads from identity.
Rejected: This would require the Plans module to own an implementation of a method that reads Identity state, inverting the dependency arrow inside the gateway and making the naming incoherent (SubscriptionLimitsGateway would contain organization flag reads). The two-gateway design keeps each interface aligned with a single concern.
C — Integration event veto pattern
Emit a domain event from the plan mutation handlers; have identity subscribe and "veto" by publishing a compensating command.
Rejected: NestJS EventBus events are synchronous within the process, but the veto would arrive after the handler has already returned. Reliable cross-module vetoing via events requires saga/process-manager infrastructure that is not present in this codebase. The synchronous gateway pattern is simpler and correct.
D — Dedicated ToggleBusinessRegistrationCommand endpoint
Expose a separate PATCH /organization/me/business-registration endpoint that only manages the flag, avoiding the need for Guard 1 inside the generic UpdateMyOrganizationHandler.
Rejected: Unnecessary surface expansion. The existing PATCH /organization/my is already the canonical settings mutation endpoint. A dedicated endpoint would require a second network call for admins who update multiple settings at once. The conditional check inside the generic handler is gated on the specific DTO field and costs zero for unrelated updates.
Consequences
Positive
- Synchronous consistency. Gates and guards always read the current state, with no window for stale data.
- Minimal code. The bidirectional pattern reuses the exact same structural components as the existing unidirectional gateway — one new interface file, one new adapter file, two module import additions.
- No new patterns.
ISubscriptionLimitsGatewayestablished the gateway pattern. This is the second instance, not an invention. - Testable in isolation. Both adapters depend only on their own module's repository contracts; they can be unit-tested with mocks independently.
Tradeoffs and Risks
- Module-level cycle. The NestJS module dependency graph now has a cycle:
IdentityModule → PlansModule → IdentityModule. NestJS resolves this correctly withforwardRef()as long as neither side constructs the other at module init time. The adapters satisfy this constraint because they only inject their own repositories. forwardRef()maintenance burden. If either module is split or refactored, theforwardRefdeclarations must be updated. This is a low-risk, one-time cost.- Gateway name drift.
ISubscriptionLimitsGatewaynow also containshasActiveAgencyPlans(), stretching its name. If a third plans-read method is added in the future, consider renaming toIPlansReadGatewayin a follow-up ADR. - Tested bootstrap. The bidirectional import with
forwardRef()must be verified by booting the app (pnpm --filter @daramex/api dev) and confirming noCircularDependencyExceptionwarning in NestJS logs.
Related Docs
- Business Registration Gates — the feature that motivated this ADR
- ADR 004 — Module Self-Containment Architecture — established the gateway pattern
- API Module Architecture — module dependency graph (updated to reflect this change)
- Plans Catalog and Management — affected plans endpoints