Architecture and State
Ports, Adapters, and Use Cases
Organize code around application decisions while HTTP, storage, and command-line details remain replaceable adapters.
Lesson 1 of 6 in the recommended order · About 25 min (estimate)
On this page
Outcome
Separate a use case from its delivery and persistence adapters.
Why it matters
At this level, code is judged by how safely it changes under load, failure, and team ownership. The technique in this lesson makes an important boundary visible enough to test and review.
Concept
A use case owns application policy. Ports describe what it needs; adapters translate HTTP, SQL, files, or queues into those ports. Dependencies point inward toward policy.
Read the code
from dataclasses import dataclass
from typing import Protocol
class Jobs(Protocol):
def add(self, name: str) -> None: ...
@dataclass
class CreateJob:
jobs: Jobs
def execute(self, name: str) -> str:
clean = name.strip()
if not clean:
raise ValueError("name required")
self.jobs.add(clean)
return clean
print("use case owns validation")
Read from the public behavior inward: identify the input boundary, the decision, and the observable result before studying syntax.
Inspect dependencies and an illustrative call
Depend on a port; call a supplied adapter
Listing versus illustration: The listing defines Jobs and CreateJob, then prints a message. It does not construct an adapter or call execute.
The call flow below adds an illustrative in-memory adapter and invokes execute(" review "). The dependency panels then describe the code and possible adapter roles separately.
Illustrative caller
Supply InMemoryJobs, construct CreateJob and call execute(" review ").
CreateJob.execute
Strip the name, reject blank input, then call self.jobs.add(clean).
InMemoryJobs.add
Supplemental test adapter records "review" and returns None; not defined in the listing.
Observed in the supplemental invocation
- Valid name
- The adapter receives "review" once, then execute returns that cleaned name.
- Blank name
- ValueError("name required") is raised before any adapter call.
Application code dependency
CreateJob.jobs: Jobs
Type annotation refers to the Jobs port
Port requirement- add(name: str) → None
Policy owner- CreateJob.execute owns stripping and the blank-name rule.
CreateJob does not import a concrete SQL, HTTP or memory adapter. A Protocol annotation does not enforce conformance at runtime.
Storage adapters · conceptual
InMemoryJobs / SQLiteJobs
Provide the behavior required by Jobs
In-memory option- Keep accepted names in local state.
SQLite option- Translate add into a database operation.
Neither implementation appears in the listing. Structural conformance does not require inheriting from Jobs; storage/error guarantees must be specified and tested.
Delivery adapters · conceptual
HTTP route / CLI command
Invoke CreateJob.execute with an input name
Delivery role- Translate incoming input and outgoing result/error.
Shared rule- Both paths reach the same use-case validation.
This is runtime invocation direction. Code dependencies should point toward the application boundary, not make the use case import the delivery framework.
Failures and limits
Adapter raises- The error propagates; execute does not return clean.
Non-string input- The shown method assumes str; it does not validate types at runtime.
Not specified- Lookup, duplicate policy, rollback and durability are not defined by the add signature.
Do not infer transaction or exactly-once guarantees from this boundary alone.
Predict the output
Predict the exact output before running the example.
Check your prediction
It prints use case owns validation. Nothing in the use case knows whether Jobs is backed by SQL, memory, or a remote service.
Modify the code
The listing defines CreateJob but does not call execute. Supply a small in-memory stand-in with an add(name) method, call execute with a valid name, then try a blank or whitespace-only name. Write down which layer rejects it and what the caller observes.
Review the change
Keep the failure at the narrowest boundary that owns the rule. Preserve a stable return value or exception contract so callers do not need to inspect implementation details.
Debug the bug
Putting domain validation only in an HTTP route lets a CLI or background worker bypass it. Policy belongs in the use case or model.
Try it yourself
Complete the focused implementation and run its deterministic checks.
Loading this exercise…
Practical challenge (optional)
Implement in-memory and SQLite adapters for the same port, then run one shared contract suite against both.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- Which layer owns the blank-name rule in the example, and why?
- In which direction should dependencies point between the use case and HTTP or SQL adapters?
- What shared test could prove two storage adapters honor the same port?
Answers
- The use case or domain model, because every delivery adapter must enforce the same application rule.
- Adapters depend on the application port; application policy must not import HTTP, SQL, or framework details.
- Run the same valid-name and adapter-failure checks through
CreateJobwith each adapter, and confirm blank names never reachadd. The shown port declares onlyadd(name), so lookup, duplicate handling and persistence guarantees need an explicit contract before a suite can assert them.
Sign in to track your progress on this exercise.
Summary and next step
You made the boundary explicit, predicted its behavior, tested a deterministic implementation, and examined its failure mode. Continue to the next lesson to combine this technique with a wider application or production constraint.