Skip to content

Patterns & Red Flags

These patterns describe how GLIMPSE layers collaborate at runtime. Where one shows up as an import, the linter catches it; most are calls rather than imports, and those hold by code review.

Patterns

1. Entry points return DTOs, never models

Templates, serializers, and CLI output receive DTOs from pacts. ORM instances never leave links.

# gates/web/django/proposals.py
def detail(request: RootRequest, pk: int) -> HttpResponse:
    proposal: ProposalDTO = request.services.proposals.get(pk)
    return render(request, "proposals/detail.html", {"proposal": proposal})

2. Entry points call services, not repositories

Gates never import repositories, persistence models, or the service classes themselves. The data path out of a gate is a service call, and services are exposed as a flat namespace wired in inits/services.py.

# correct
proposals = request.services.proposals.list_active()

# wrong — imports a concrete class from links
from myproject.links.db.postgres import ProposalRepository

# wrong — imports a concrete class from mills; the protocol is in pacts
from myproject.mills.proposals import ProposalService

If no service exists for what you need, create one — a mill in mills, a protocol in pacts, a leaf in inits/services.py — before writing the gate.

3. Services take the protocols they use

A mill service receives the two or three repository protocols it actually needs, plus a TransactionProtocol if it writes. Never a direct import of a concrete repository, never a dependency passed as a method argument. With an ambient ORM (Django), never a whole Unit of Work either — with a session-based ORM (SQLAlchemy), the session already is one, and injecting it is idiomatic.

class ProposalService:
    def __init__(
        self,
        proposals: ProposalRepositoryProtocol,
        users: UserRepositoryProtocol,
        transaction: TransactionProtocol,
    ) -> None:
        self._proposals = proposals
        self._users = users
        self._transaction = transaction

This is the interface segregation principle at the service boundary. inits knows the concrete classes and does the wiring.

4. Mills have no side-effect imports

No ORM, no HTTP layer, no CLI parser, no settings access. A mill sees protocols and DTOs from pacts, constants from specs, and pure helpers from anywhere — package names are not the test. If a mill's test needs a live database, the mill has leaked infrastructure.

5. Writes use TypedDicts

DTOs are for reads. TypedDicts are for writes — they travel from gates into mills as typed input, and from mills into links as what repository write methods accept (create(data: CreateProposalDict) -> ProposalDTO). A CreateXDict carries no id — the store assigns it.

# pacts/proposals.py — the proposals noun
class CreateProposalDict(TypedDict):
    title: str
    author_id: int

class ProposalDTO(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    title: str

The split is the id. A DTO has one because a stored thing has one; a thing being created does not, and giving the DTO a nullable id to cover both pushes a null check into everything that touches it. A write shape is also short-lived — built just before the call and consumed by it — so it never makes the trip a DTO makes.

6. Web requests typed via a gate-local subclass

A web gate types the request as a typing-only subclass of the framework's request class — defined inside the adapter, never instantiated. The inits middleware mutates the real request; the subclass gives the annotation something true-shaped to say. Only ServicesProtocol comes from pacts.

# gates/web/django/request.py
class RootRequest(HttpRequest):
    services: ServicesProtocol

CLI gates have no context — they receive dependencies at construction, wired by inits.

7. Multi-repo writes use transaction.atomic()

Any operation writing to more than one repository is wrapped in transaction.atomic(), obtained from the injected TransactionProtocol.

with self._transaction.atomic():
    self._proposals.save(proposal_dto)
    self._audit.record(entry_dict)

Entry points never start transactions. Atomicity is a service concern.

8. New repo methods need a matching Protocol in pacts

Before adding a method to a repository in links, define it in the corresponding Protocol in pacts. mills depends on the protocol, not the concrete class.

9. DTOs must be constructible from a store row

Every DTO in pacts must be buildable from what links loaded, whichever DTO library the project picked — Pydantic is not required. A row that does not match the DTO is mapped by a private helper on the repository, in links, never by a method on the DTO: the mapping is the adapter's, and a second adapter maps differently. Spellings per store: DTO requirements.

10. Registries are flat @cached_property trees

New repositories become a @cached_property on inits/repositories.py. New services become a @cached_property on inits/services.py. Both stay flat until they cross ~12 leaves — see Growing rules.

11. Protocol implementations declare the protocol as a base class

class ProposalRepository(ProposalRepositoryProtocol):
    ...

Where a protocol exists, its implementation names it as a base class — see pacts for the rule and its exception. The declaration is only a check if a type checker runs: subclassing a Protocol inherits its stub bodies, so an unimplemented method returns None at runtime instead of failing.

12. Domain errors are caught at the call-site

Mills raise coarse, shared errors from pacts (NotFoundError, not ProposalNotFound). The gate wraps the service call and decides what the error means for that screen — message, fallback, redirect. No central error-to-status mapping. On the way in, adapter code translates store exceptions into pacts errors (e.g. IntegrityErrorDatabaseConstraintError inside savepoint()), so no ORM exception ever reaches a mill.


Drift red flags

These patterns indicate architectural drift

If you see any of these in a codebase, treat them as bugs.

Layout and slicing

links.py or gates.py as a single file
Both need the {port}/{adapter} axis from day one. The port is knowable before any code is written; deferring it costs an import rewrite the day a second adapter appears.
A layer promoted to a package before it earned it
pacts/, specs/, or mills/ as a directory while there is one noun, well under ~1000 lines, and no merge friction. The tree is anticipating nouns you have not discovered.
Nested folders holding one or two small files
pacts/invoices/issue/create.py when pacts/invoices/issue.py would do, or inits/services/invoices/issuing.py with no sibling. A folder needs at least two leaves to exist.
Port axis inside mills or specs
mills/web/proposals.py or specs/api/.... Mills and specs have no delivery-mechanism axis. If you see a port word inside these layers, the code belongs elsewhere.
A catch-all verb module
manage.py, organize.py, misc.py inside a noun. A verb cut must name a real activity.
A noun axis inside gates
gates/web/django/invoices.py when the interface has no such page. Gates mirror the interface; mills mirror the domain. The two trees are not expected to match.
common, shared, utils, or entities as a module or folder name
Magnets for unrelated code. Each says where a file sits, not what it holds, so anything can be filed there and nothing can ever be found. Shared types go to pacts, under the noun that needed them first; everything else takes a name from the axis it belongs to. The exception is a real concept that happens to carry the word — a DOMEntity in a browser-port adapter earns entities.py; a bag of dataclasses does not.

pacts

pacts split by technical kind instead of noun
pacts/dtos.py, pacts/protocols.py, pacts/repos/. These group by what the type is, not by what domain concern it belongs to. This forces unrelated nouns to share files and makes the package harder to navigate.
pacts/core.py, pacts/common.py, or similar
A common/ bucket wearing a nicer name. Every contract has a principled home under the noun / port / wiring axes.
A DTO that cannot be built from a store row or ORM instance
Repositories cannot return it. Whatever the project uses for DTOs — Pydantic is not required — construction has to work from the row the adapter loaded. A row that does not match the DTO is mapped in the repository, not by a method on the DTO.
A protocol implementation that does not name the protocol as a base class
The conformance check is left to a structural match that can silently drift. The exception is very generic structural protocols — TransactionProtocol, callbacks — with multiple unrelated duck-typed implementations.

specs

specs imported from links, gates, or inits
specs are business invariants, and business rules are enforced in mills alone. A constant needed elsewhere is either a contract (pacts — a max length or an allowed range is a fact about the shape of the data, and belongs beside the contract it constrains) or configuration, which enters at inits or comes from the framework's settings accessor where there is one.
specs reading from os.environ or settings, or performing IO
It is a constants layer. Environment-dependent values enter at inits.

mills

mills importing anything with side effects
An ORM, HTTP machinery, a CLI parser, settings access — absolute violation. Pure computation is fine wherever it comes from. So is the ambient stuff the rule was never about: the clock, a random draw, a UUID, a log line.
A service taking a whole Unit of Work instead of the protocols it uses
Applies to ambient-ORM projects. With a session-based ORM the session already is a unit of work, and injecting it is idiomatic.
A page axis inside mills
Gates mirror the interface, mills mirror the domain. A sitemap in mills is the interface leaking inward.
Model and repository in the same links file
This collapses the internal-vs-public boundary. Models are internal to the adapter; repositories are its public surface.
links files named per entity
links/db/postgres/user.py. links slices by kind, not by entity. One models.py holds many entities' models.
Suffix-sibling links files
repositories_invoices.py, models_users.py. Promote the kind to a {kind}/ package with submodules instead. Halve, don't shard.
A links facade that re-exports models, or omits a public repository
links/{port}/{adapter}/__init__.py is the public surface. Whatever it exports is public; everything else is internal.
An ORM model imported from outside links/
Use the repository protocol from pacts instead.
A repository imported directly in a gate or a mill
Inject it through inits.

gates

A gate importing project code other than pacts
An ORM model, a repository class, a service class from mills — all the same violation. A gate calls services through their protocols. If none exists, create one — a mill in mills, a protocol in pacts, a leaf in inits/services.py — before writing the gate.
A gate returning ORM instances to templates or serializers
Return DTOs from pacts. ORM instances never leave links.
A gate that opens a transaction
Atomicity is a service concern.
Business rules in form validation
Gates validate format — an email, an int, a date. Meaning ("email or username required", seat limits) belongs in mills, which alone may read specs.
A non-port axis at the top level of gates
gates/mills/.... The first axis below gates is always the port.

inits

A gate constructing repository or service instances
That is the wiring inits owns, and doing it in a gate breaks it.
mills importing from inits
The dependency runs the other way. inits knows the concrete classes; mills sees only protocols.
inits containing business logic
It should only wire, never decide.