DRY, SRP, IoC, DI, and Service Contracts
Keep Zheta Forge changeable by assigning one reason to change per service and injecting implementations behind explicit contracts.
Run it in the public monorepo
This course is built around the public Zheta Kubernetes Lab monorepo. The excerpt below is runnable source, not pseudocode.
Source: services/control_plane/app.py
def build_controller() -> ForgeController:
database = Database(os.getenv("CONTROL_DATABASE_URL", "sqlite:///.lab/control.db"))
repository = SqlProjectRepository(JsonProjectRepository(database))
artifact_store = S3ArtifactStore(os.environ["ARTIFACT_BUCKET"]) if os.getenv("ARTIFACT_BUCKET") else SqlArtifactStore(Database(os.getenv("ARTIFACT_DATABASE_URL", "sqlite:///.lab/artifacts.db")))
outbox = OutboxPublisher(database, publisher_from_url(os.getenv("BROKER_TOPIC", "http://broker:8080"), SERVICE_TOKEN)); outbox.start()
forge = Forge(repository, GeneratorClient(os.getenv("GENERATOR_URL", "http://generator:8080")), RuntimeClient(os.getenv("RUNTIME_URL", "http://runtime:8080")), artifact_store, outbox)
return ForgeController(forge)
Code to reality
- Declared intent
- Select concrete persistence, artifact, service-client, and event adapters at one visible composition root.
- Interpreter
- Python passes SERVICE_TOKEN into the broker publisher, starts the outbox relay, and injects the concrete object graph.
- Software effect
- Local broker HTTP or cloud SNS publishes transactional outbox events while the Forge model remains adapter-independent.
- Hardware effect
- The chosen URLs and stores decide which process, network endpoint, database, object storage, and broker consume resources.
- Observable evidence
- Exact source, adapter tests, a running outbox relay, broker delivery, and stable domain tests prove the wiring.
Start with the people and the result they need
The source tables below remain the detailed contract. Begin with these customer paths:
- D16-UC-01
- Person: Platform developer
- Job: Add a storage implementation without changing application workflow rules
- Observable result: All three archetypes use the same stable port while selecting tenant-safe adapters by environment
- D16-UC-02
- Person: Service owner
- Job: Change one responsibility without redeploying unrelated services
- Observable result: Release, grant, runtime, and generated-app owners change independently behind versioned contracts
Turn each customer job into a testable story
Now turn each customer job into a story with a result that an engineer can check:
- D16-US-01
- Story: As a platform developer, I want domain logic to depend on a storage port, so that local PostgreSQL and AWS implementations can change without…
- Observable acceptance: Same contract suite passes for allowed adapters, rejects tenant leakage, and records implementation, environment, run, and trace
- D16-US-02
- Story: As a service owner, I want each service to own one coherent capability and schema, so that a release-policy change does not force connector or…
- Observable acceptance: Ownership map, versioned schema, consumer checks, rejected breaking change, and unaffected service probe are recorded
Add real state and observable proof
Finally trace each story through the system that owns its state and the evidence that proves the outcome:
- D16-FLOW-01
- Trigger: Developer selects the dev persistence adapter
- Responsible systems: ForgeController, Forge model, repository/artifact ports, SQLite and S3 adapters, tenant policy
- Authoritative state: Control-plane database for projects; artifact database or S3 for artifacts
- Owned record: ServiceContract
- Observable evidence: Actor, contract version, adapter, tenant scope, expected and observed outcomes, environment, timestamp, commit, run, and trace IDs
- Failure signal: Adapter constructed inside Forge, semantic mismatch, or cross-tenant read
- D16-FLOW-02
- Trigger: Service owner proposes a breaking event or API schema
- Responsible systems: Control-plane, generator, runtime, evidence HTTP contracts; transactional outbox and broker ports
- Authoritative state: Each service database plus durable outbox; SNS/SQS or SQLite broker delivery state
- Owned record: AdapterBinding
- Observable evidence: Rejected schema diff, impacted consumers, denial, prior response, environment, timestamp, commit, and run ID
- Failure signal: Cyclic dependency, two writers, failed HTTP/auth contract, exhausted outbox relay, or unrelated service regression
The enterprise problem and today’s slice
Enterprise problem: A platform that can generate Research Brief, Service Desk, and Field Inspection applications becomes fragile when shared code means shared ownership, one service controls unrelated jobs, or business logic constructs databases and cloud clients directly. Whole-course context: The customer lifecycle now reaches verified export and retirement; today uses that evidence to draw internal boundaries that can evolve without breaking the lifecycle. Today’s slice: We apply Don’t Repeat Yourself (DRY), Single Responsibility Principle (SRP), Inversion of Control (IoC), Dependency Injection (DI), and contract tests to the control plane, runtime, and generated applications. End-of-day evidence: A service map, interface contract, positive contract test, rejected incompatible implementation, ownership decision, environment, timestamp, source revision, run, and trace IDs show replaceability. Still unsolved: Kubernetes packaging, infrastructure ownership, Terraform, and AWS isolation remain deferred.
Customer use cases
Design principles matter only when they protect customer outcomes, so boundaries should follow reasons to change rather than fashionable service names. These use cases test reuse and independent replacement across all three archetypes.
| Use case ID | Actor | Customer job | Success outcome | Denial or recovery evidence |
|---|---|---|---|---|
| D16-UC-01 | Platform developer | Add a storage implementation without changing application workflow rules | All three archetypes use the same stable port while selecting tenant-safe adapters by environment | Adapter with incompatible semantics or missing tenant enforcement fails contract tests before deployment |
| D16-UC-02 | Service owner | Change one responsibility without redeploying unrelated services | Release, grant, runtime, and generated-app owners change independently behind versioned contracts | Consumer compatibility gate rejects breaking schema; prior implementation remains deployable |
Actor-centred user stories
Copying less code is not enough if one shared module couples unrelated releases. These stories define DRY as one authoritative decision and DI as supplied capability, not hidden construction.
| Story ID | Use case IDs | User story | Observable acceptance conditions |
|---|---|---|---|
| D16-US-01 | D16-UC-01 | As a platform developer, I want domain logic to depend on a storage port, so that local PostgreSQL and AWS implementations can change without rewriting customer workflows | Same contract suite passes for allowed adapters, rejects tenant leakage, and records implementation, environment, run, and trace |
| D16-US-02 | D16-UC-02 | As a service owner, I want each service to own one coherent capability and schema, so that a release-policy change does not force connector or runtime changes | Ownership map, versioned schema, consumer checks, rejected breaking change, and unaffected service probe are recorded |
End-to-end product flows
Hidden dependencies turn a small customer request into an unpredictable graph of constructors and side effects. The flow resolves dependencies at the application boundary and exercises behavior through contracts.
| Flow ID | Use case IDs | Path | Trigger | Numbered steps | Terminal evidence |
|---|---|---|---|---|---|
| D16-FLOW-01 | D16-UC-01 | Happy | Developer selects the dev persistence adapter | 1. Composition root creates the adapter. 2. Inject it through a declared port. 3. Domain service executes tenant-safe workflow. 4. Contract suite compares outcomes. 5. Record adapter and evidence. | Actor, contract version, adapter, tenant scope, expected and observed outcomes, environment, timestamp, commit, run, and trace IDs |
| D16-FLOW-02 | D16-UC-02 | Denied | Service owner proposes a breaking event or API schema | 1. Publish candidate schema. 2. Run provider and consumer contracts. 3. Detect incompatible field or semantic change. 4. Block release. 5. Probe prior service as positive control. | Rejected schema diff, impacted consumers, denial, prior response, environment, timestamp, commit, and run ID |
The developer changes an implementation while the customer-visible workflow stays stable. That is the practical promise the design principles must prove.
System design derived from the flows
Service boundaries fail when they merely mirror technical layers or every function becomes a network call. Use SRP for one reason to change, DRY for one authoritative rule, and IoC/DI where replacing an external capability has real value.
| Use case ID | Entry point | Responsible services | Authoritative store | Failure evidence |
|---|---|---|---|---|
| D16-UC-01 | services/control_plane/app.py composition root and tests/test_domain.py | ForgeController, Forge model, repository/artifact ports, SQLite and S3 adapters, tenant policy | Control-plane database for projects; artifact database or S3 for artifacts | Adapter constructed inside Forge, semantic mismatch, or cross-tenant read |
| D16-UC-02 | Python service-contract review | Control-plane, generator, runtime, evidence HTTP contracts; transactional outbox and broker ports | Each service database plus durable outbox; SNS/SQS or SQLite broker delivery state | Cyclic dependency, two writers, failed HTTP/auth contract, exhausted outbox relay, or unrelated service regression |
The real Python composition root in services/control_plane/app.py constructs ForgeController, injects a repository, generator client, runtime client, artifact store, and outbox into the Forge model, and then exposes an HTTP Handler. This is a small MVC split: domain.py is the model, controller.py translates authenticated commands, and app.py is both transport/view and composition root. DI is the constructor wiring; IoC is the process entry point choosing concrete adapters. The root passes SERVICE_TOKEN to publisher_from_url(...) and calls outbox.start(). Locally, http://broker:8080 is the real broker transport; in cloud, the required BROKER_TOPIC is SNS and subscriber delivery is SQS.
Data model and ownership
An interface can hide ownership confusion if two adapters both mutate the same record. Contracts therefore include record owner, identity scope, idempotency, and lifecycle semantics.
Generated-application database: Required in this slice — each generated application owns its tenant domain model, while injected adapters provide persistence without moving that authority into the control plane.
| Record or entity | Store and owner | Primary key | Foreign key or opaque reference | Tenant key | Material constraint | Lifecycle and deletion | Use case IDs |
|---|---|---|---|---|---|---|---|
| ServiceContract | Schema Registry, owned by Platform Architecture | contract_name plus semantic_version | Opaque owning_service_id | None — contract is globally addressable but scoped by owner | One owner; compatibility policy and authorization semantics are explicit | Draft, approve, deprecate with deadline, retain versions for audit | D16-UC-01, D16-UC-02 |
| AdapterBinding | Environment Configuration Store, owned by Runtime Platform | binding_id | Opaque contract version and workload identity refs | environment_id | Exactly one active adapter per port, application, and environment | Create, validate, activate, roll back, retire with environment | D16-UC-01 |
| DomainRecord | Generated-application database, owned by generated app | domain_record_id | Opaque source and workflow refs | app_tenant_id | Only domain owner mutates; tenant predicate is mandatory | Create, update, export, retain, and erase by application lifecycle | D16-UC-01, D16-UC-02 |
Contract versions progress through explicit lifecycle states, and rejection leaves the prior customer workflow observable. This makes “replaceable” a tested property rather than a diagram label.
Inject an adapter at one composition root
Domain code that constructs AWS clients cannot run locally without cloud coupling and cannot expose its required authority. This labelled excerpt reflects the public monorepo’s actual Python ports and composition root at services/control_plane/app.py and services/control_plane/adapters.py.
def build_controller() -> ForgeController:
database = Database(os.getenv("CONTROL_DATABASE_URL", "sqlite:///.lab/control.db"))
repository = SqlProjectRepository(JsonProjectRepository(database))
artifact_store = S3ArtifactStore(os.environ["ARTIFACT_BUCKET"]) if os.getenv("ARTIFACT_BUCKET") else SqlArtifactStore(Database(os.getenv("ARTIFACT_DATABASE_URL", "sqlite:///.lab/artifacts.db")))
outbox = OutboxPublisher(database, publisher_from_url(os.getenv("BROKER_TOPIC", "http://broker:8080"), SERVICE_TOKEN)); outbox.start()
forge = Forge(repository, GeneratorClient(os.getenv("GENERATOR_URL", "http://generator:8080")), RuntimeClient(os.getenv("RUNTIME_URL", "http://runtime:8080")), artifact_store, outbox)
return ForgeController(forge)
| Effect field | What happens |
|---|---|
| Declared intent | Forge behavior receives repository, service clients, artifact storage, and outbox implementations at one composition root |
| Interpreter | Python imports concrete adapters and constructs the object graph when the control-plane process starts |
| Software effect | Local URLs select SQL stores and the authenticated HTTP broker; ARTIFACT_BUCKET selects S3; the started outbox relays committed events |
| Hardware effect | The selected adapter consumes local or AWS CPU, memory, storage, and network resources |
| Evidence | tests/test_domain.py, restart/tenant tests, adapter configuration, denied cross-tenant case, outbox delivery, and source revision |
Key takeaways
Good boundaries preserve customer behavior while allowing one owned implementation to change.
- DRY centralises decisions, not every similar line.
- SRP follows reasons to change and ownership.
- IoC and DI make dependencies explicit but never bypass authorization.
Checklist
A service boundary is credible only when ownership and compatibility are testable.
- [ ] Each record and policy has one mutating owner
- [ ] Ports state tenant, identity, idempotency, and failure semantics
- [ ] Composition occurs at a small number of visible roots
- [ ] Breaking contract and unaffected positive control are proven