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:, andCI_PIPELINE_SOURCEhandle 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 insrc/without updatingcontract/will silently fail to cascade. Code review must enforce that public interface changes are reflected incontract/. strategy: dependon 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:
- Add
contract/directories to built modules (extract existing public interface files) - Add
changes: contract/<name>/**/*to intra-repo dependent module rules - Add
notify-*trigger jobs for cross-repo consumers - 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