Design principles¶
The framework is built in layers, each with a clear job and clear borders. The goal is to keep the logic that decides what "supported" means separate from the tools used to prove it. Terraform, a cloud provider, an issue tracker or a chat app should be replaceable without rewriting the core.
The four layers¶
| Layer | Holds | In this framework |
|---|---|---|
| Domain | Plain models of what a run is about, with no knowledge of storage, tools or APIs | Trigger, Topology, RunResult, CheckResult, SupportState |
| Use cases | The rules of the business: what happens, in what order, and what counts as a pass | Detect new releases, validate a trigger end to end, resume teardown after triage |
| Interfaces | The shape of everything the use cases need from the outside world | Detector, Provisioner, Validator, Publisher, ArtifactStore |
| Adapters | Concrete implementations of those interfaces | The Terraform CLI, the cloud SDK, the GitHub API, the tracker, Slack, Teams, object storage |
Tools sit at the edge because they're the parts most likely to be swapped; a Trigger or SupportState only changes when the meaning of "supported" does.
The dependency rule¶
One rule holds the layers together
The core never imports a tool. Tools are handed to it.
- Entry points: the scheduler, a CLI command or a chat button starts a use case with a
Trigger. Those callers sit at the edge and depend on the core, never the other way round. - Tools: the "validate trigger" use case needs something that can provision and something that can publish. It declares what it needs as a
Provisionerand aPublisher, and the wiring code at start-up decides whether that means Terraform and Linear, or OpenTofu and Jira.
class ValidateTrigger:
def __init__(self, provisioner: Provisioner, validators: ValidatorFactory,
publisher: Publisher, store: ArtifactStore):
self.provisioner, self.validators = provisioner, validators
self.publisher, self.store = publisher, store
def run(self, trigger: Trigger) -> RunResult:
self.publisher.publish(RunEvent.started(trigger))
target = self.provisioner.apply(trigger)
checks = [v.run(target) for v in self.validators.for_topology(trigger.topology)]
result = RunResult(trigger, target, checks)
self.store.save(result)
if result.passed:
self.provisioner.destroy(trigger)
self.publisher.publish(RunEvent.passed(result) if result.passed else RunEvent.failed(result))
return result
This class contains the whole lifecycle and none of the tooling. The Terraform details live in a TerraformProvisioner adapter, and the Jira or Linear details live in a tracker publisher.
Why it's worth the structure¶
| Benefit | What it means here |
|---|---|
| Replaceable tools | Swap Terraform for OpenTofu or Pulumi, or Jira for Linear, by writing one adapter |
| One lifecycle, many topologies | VM hosts, self-managed and managed clusters run the same use case with different adapters |
| Readable core | The run lifecycle reads as the business process, not as API calls |
| Real tests in the right places | The use case is checked against real adapters in the end-to-end run, and adapters are checked against their real tools |
How the layers map to the framework¶
The framework layers describe the flow of a run: intake, configuration, orchestration, validation and publishing. This page describes the dependencies. Each stage of the flow is a use case or part of one, and every tool it touches sits behind an interface.
| Flow stage | Use case side | Adapter side |
|---|---|---|
| Trigger intake | The ledger decides what's new | Scheduled image check, GitHub watcher, page check |
| Configuration generation | Triggers become a run configuration | The JSON writer for Terraform variables |
| Orchestration | Provision, then decide when to destroy | The Terraform CLI, remote state |
| Validation | Which checks apply to a topology | SSH, kubectl through the bastion, backend queries |
| Publishing | Which events go where | Tracker, docs, Slack, Teams, storage |