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.
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.
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¶
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. IntegrityError →
DatabaseConstraintError 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.pyorgates.pyas 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/, ormills/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.pywhenpacts/invoices/issue.pywould do, orinits/services/invoices/issuing.pywith no sibling. A folder needs at least two leaves to exist.- Port axis inside mills or specs
mills/web/proposals.pyorspecs/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.pyinside a noun. A verb cut must name a real activity.- A noun axis inside gates
gates/web/django/invoices.pywhen 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, orentitiesas 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 — aDOMEntityin a browser-port adapter earnsentities.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
specsare business invariants, and business rules are enforced inmillsalone. 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 atinitsor comes from the framework's settings accessor where there is one.- specs reading from
os.environorsettings, 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
millsis the interface leaking inward.
links¶
- 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.linksslices by kind, not by entity. Onemodels.pyholds 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__.pyis the public surface. Whatever it exports is public; everything else is internal.- An ORM model imported from outside links/
- Use the repository protocol from
pactsinstead. - 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 inmills, a protocol inpacts, a leaf ininits/services.py— before writing the gate. - A gate returning ORM instances to templates or serializers
- Return DTOs from
pacts. ORM instances never leavelinks. - 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 belowgatesis always the port.
inits¶
- A gate constructing repository or service instances
- That is the wiring
initsowns, and doing it in a gate breaks it. - mills importing from inits
- The dependency runs the other way.
initsknows the concrete classes;millssees only protocols. - inits containing business logic
- It should only wire, never decide.