Skip to content

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:

  1. Events are inherently asynchronous and cannot veto an HTTP request in flight.
  2. Read-model projections introduce eventual consistency windows and a second source of truth.
  3. 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. ISubscriptionLimitsGateway established 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 with forwardRef() 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, the forwardRef declarations must be updated. This is a low-risk, one-time cost.
  • Gateway name drift. ISubscriptionLimitsGateway now also contains hasActiveAgencyPlans(), stretching its name. If a third plans-read method is added in the future, consider renaming to IPlansReadGateway in 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 no CircularDependencyException warning in NestJS logs.