Skip to content

ADR-062: Contract directory convention and minimal-cascade deployment

Status Accepted
Date 2026-05-13
Deciders CTO, Head of Platform Engineering, Head of Architecture
Affects repos bank-core, bank-kyc, bank-aml, bank-payments, bank-credit, bank-risk-platform, bank-platform, bank-app

Status: Accepted — 2026-05-13

Context

Modules are independently deployable but not independent in what they expose. A module may publish EventBridge events consumed by module B, write SSM parameters read by module C, and materialise Snowflake views queried by module D. These are distinct contracts, and a change to one does not affect consumers of the others.

The current CI approach uses changes: path guards to prevent unnecessary deploys within a module. This solves the "don't redeploy when nothing changed" problem inside one module but does not address cascade: when should a downstream module be rebuilt because its upstream changed something?

Without an explicit mechanism, the options are both bad:

  • Always cascade — any upstream deploy triggers all downstream deploys. This violates the microservice minimal-deployment principle, burns CI compute, and creates circular re-trigger risk.
  • Never cascade — developers manually trigger downstream builds after upstream changes. Stale dependencies reach production.

A further complication: a module has multiple contracts, each with different consumers. Cascading on the entire module change rather than on the specific changed contract triggers rebuilds in modules that have no actual dependency on what changed.

Decision

1. Each module defines a contract/ directory subdivided by named interface

MOD-NNN-module-name/
  contract/
    events/          ← EventBridge event type definitions (TypeScript interfaces)
    ssm/             ← SSM parameter name constants this module writes
    dbt/             ← Snowflake view and table schemas exposed to downstream consumers
    api/             ← REST or async API request/response types (if applicable)
  src/               ← internal implementation — never triggers cascade

Not every module has all subdirectories. A Lambda module with no Snowflake exposure has no dbt/. A dbt-only module has no events/ or ssm/. Each subdirectory that exists is a named contract. Adding a new subdirectory is a new contract, not a change to existing ones.

The naming of subdirectories is not prescribed beyond the standard set above. A module with two distinct EventBridge buses it publishes to may use events/liquidity/ and events/compliance/ rather than a flat events/. The rule is: the subdirectory path is what downstream modules reference in their changes: guards — pick names that are stable and self-documenting.

2. Upstream module CI: one trigger job per named contract (cross-repo dependencies)

When a module has consumers in other repositories, its CI file declares a separate trigger job for each contract. The trigger job fires only when that contract's subdirectory changed:

# In bank-risk-platform/.gitlab/ci/mod-032.gitlab-ci.yml

mod-032-notify-event-consumers:
  stage: notify
  trigger:
    project: totara-bank/bank-platform
    branch: main
    strategy: depend
  needs: [mod-032-deploy]
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
      changes:
        - MOD-032-lcr-nsfr-calculator/contract/events/**/*
  variables:
    UPSTREAM_MODULE: MOD-032
    UPSTREAM_CONTRACT: events

mod-032-notify-ssm-consumers:
  stage: notify
  trigger:
    project: totara-bank/bank-platform
    branch: main
    strategy: depend
  needs: [mod-032-deploy]
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
      changes:
        - MOD-032-lcr-nsfr-calculator/contract/ssm/**/*
  variables:
    UPSTREAM_MODULE: MOD-032
    UPSTREAM_CONTRACT: ssm

strategy: depend means the upstream pipeline waits for the downstream pipeline to complete before marking the trigger job green. This makes cross-repo failures visible in the originating pipeline.

3. Downstream module CI: respond to upstream trigger

The upstream trigger job is already scoped to the specific contract and the specific downstream project. The downstream module only needs to respond to being triggered:

mod-076-deploy:
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
      changes:
        - MOD-076-observability-platform/**/*
    - if: '$CI_PIPELINE_SOURCE == "pipeline"'
      when: always
    - if: '$CI_PIPELINE_SOURCE == "web"'
      when: manual

The CI_PIPELINE_SOURCE == "pipeline" rule fires when GitLab triggers this pipeline from another project's job. No additional filtering on UPSTREAM_MODULE or UPSTREAM_CONTRACT is needed in the downstream rule — the upstream trigger job's changes: guard already ensures this pipeline is only triggered when the contract that module cares about actually changed.

If a downstream module consumes contracts from multiple upstream modules across multiple repos, it may receive triggers from any of them. The when: always rule is correct in all cases.

4. Intra-repo dependencies: changes: list on the dependent job's rule

When both modules are in the same repository, no separate trigger job is needed. The dependent module's own rule lists the upstream contract path alongside its own source path:

mod-035-deploy:
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
      changes:
        - MOD-035-irrbb-model/**/*
        - MOD-032-lcr-nsfr-calculator/contract/events/**/*
    - if: '$CI_PIPELINE_SOURCE == "web"'
      when: manual

needs: [mod-032-deploy] with optional: true should be added when strict ordering is required — the optional: true means MOD-035 only waits for MOD-032 if MOD-032 actually ran in the same pipeline.

5. Shared packages follow the same convention

The packages/shared/ directory in each monorepo is treated as a module with its own contract/:

packages/shared/
  contract/
    errors/        ← exported error classes
    observability/ ← exported observability types
  src/             ← internal implementation

A change to packages/shared/src/postgres/client.ts does not cascade to modules that do not use the postgres client. A change to packages/shared/contract/errors/ cascades only to modules that import the error types.

Shared packages are published to the GitLab Package Registry on any change to packages/shared/**/*. Consuming modules pin to a semver range. The contract/ cascade mechanism remains the trigger for downstream module CI — package version is a delivery mechanism, not the trigger signal.

6. contract: field on wiki dependency items

The contract: field on a module's dependency item names the subdirectory consumed. This is documentation — it is not read by CI at runtime:

dependencies:
  - module: MOD-032
    optional: false
    contract: events
    reason: Subscribes to liquidity breach events for regulatory alert routing.
  - module: MOD-104
    optional: false
    contract: ssm
    reason: Reads shared infrastructure SSM outputs for EventBridge bus ARN.

contract: null (or omitted) on an existing dependency means the dependency was recorded before this ADR and the contract surface has not yet been identified. It does not imply the dependency is uncascaded — it means the wiring is incomplete.

7. Bank-wiki has no runtime role in cascade

Bank-wiki records the dependency graph and the named contracts as documentation. CI pipelines do not query the wiki at runtime. The cascade logic is entirely in .gitlab/ci/mod-NNN.gitlab-ci.yml files in each code repository. The wiki is the human-readable record of what is wired and why; the CI files are the executable specification.

Consequences

Positive

  • A bug fix or internal refactor that does not touch contract/ never triggers any downstream rebuild, regardless of how many consumers exist. The common case is zero cascade.
  • Contract changes are explicit and auditable: a PR that modifies contract/events/ is immediately understood to be a potentially breaking change by reviewers.
  • Multiple contracts per module are independently versioned by change history — no global version counter required.
  • GitLab's native changes:, trigger:, needs:, and CI_PIPELINE_SOURCE handle all mechanics. No custom tooling.
  • Cross-repo dependencies are visible in the upstream module's CI file, making the coupling explicit to the team that owns the upstream module.

Negative / trade-offs

  • Contract subdirectories must be bootstrapped for existing deployed modules before cascade wiring can be added. This is a one-time migration effort per module.
  • The contract/ convention requires discipline: an interface change made only in src/ without updating contract/ will silently fail to cascade. Code review must enforce that public interface changes are reflected in contract/.
  • strategy: depend on cross-repo triggers makes upstream pipeline duration dependent on downstream pipeline duration. A slow downstream module adds latency to the upstream pipeline's green signal.
  • The first repo to adopt this pattern (bank-risk-platform) will be the integration test for cross-repo wiring. Other repos cannot wire cross-repo triggers until the upstream module's trigger job is committed.

Rollout

Adopted one repository at a time. bank-risk-platform is the pilot. Within each repository:

  1. Add contract/ directories to built modules (extract existing public interface files)
  2. Add changes: contract/<name>/**/* to intra-repo dependent module rules
  3. Add notify-* trigger jobs for cross-repo consumers
  4. Update wiki dependency items with contract: field as wiring is completed

The changes: path guard already in place on each module (from the GitLab CI migration) remains the primary gate. The contract/ mechanism adds the cascade layer on top.


All ADRs Compiled 2026-05-22 from source/entities/adrs/ADR-062.yaml