Skip to content

gates

Purpose: Entry points — everything that handles inbound requests or commands.

gates is where the outside world enters the system. It receives input, calls a service for business logic, and returns output. It knows about the delivery mechanism (HTTP, CLI) but delegates all domain logic to mills.

Depends on / depended on by

Depends on pacts
Depended on by nothing on the web — the framework dispatches; inits imports gate classes on a CLI

pacts is the only project code a gate imports — not links, and not mills either. A gate calls services through the protocols pacts declares, and inits supplies the implementations: attached to the request on the web, injected into the constructor on a CLI. The gate never names the class it is calling.

What it contains

  • Request handlers / views / API endpoints
  • CLI commands
  • Forms and input validation
  • Routing
  • Template helpers
  • Serializers / schema definitions (if framework-provided)

Entry points call services, not repositories

The data path out of a gate is a service call. A gate never imports a repository, a model, or the service class itself, and never reaches a repository directly — not even for a single trivial read.

# gates/web/django/proposals.py
class ProposalDetailView(View):
    request: RootRequest

    def get(self, request: RootRequest, pk: int) -> HttpResponse:
        proposal: ProposalDTO = request.services.proposals.get(pk)
        ...

Services are exposed as a flat namespace, wired in inits/services.py. If no service exists for what you need, create one — a mill in mills, a protocol in pacts, and a leaf in inits/services.py — before writing the gate.

How the services reach the gate is per-port. On the web, middleware attaches them to the request. In a CLI there is no request — inits constructs the gate class and injects the mills into its constructor (see inits); the gate then uses what it was given.

Entry points return DTOs, never models

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

Entry points never start transactions

Atomicity is a service concern. A gate that opens a transaction has taken on a decision that belongs in mills. See pattern 7.

Validation: gates own the format

Gates validate format, mills validate meaning. A gate checks that input parses — an email, an int, a date. Whether the input makes business sense — "email or username required", seat limits — is a mill's job. A form whose clean methods grow business rules is a gate leaking into mills.

Errors are handled at the call-site

Mills raise coarse domain errors from pacts; the gate wraps the service call and decides what the error means for that screen — a message, a fallback, a redirect. There is no central error-to-status mapping.

try:
    event = self.request.services.events.read_by_slug(slug, self.site_id)
except NotFoundError:
    messages.error(self.request, _("Event not found."))
    return {}, None

Permissions: gates while trivial, mills when they mean something

Checks like "is authenticated" or "is event manager" are near-format checks on the request and live in gates. A permission system that encodes business rules — who may do what, under which conditions — is meaning, and belongs in a mill. The threshold, not the layer, is the rule.

Context typing (web)

A web gate types the request as a typing-only subclass of the framework's request class, defined inside the adapter — never instantiated, mutated onto the real request by the inits middleware:

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

ServicesProtocol comes from pacts (see pacts); the subclass stays gate-local because it imports the framework. Class-based views annotate request: RootRequest at class level and in method signatures. CLI gates have no context at all — they receive their dependencies at construction.

Slicing axis

gates is sliced by portadapterpage.

The port and adapter directories exist from day one — you always know which delivery mechanism you are building. Below them, gates mirrors the shape of the interface itself: page group and page for a web app, a command for a CLI, a view for a TUI, a tool for MCP. Whatever grouping the interface already has is the grouping the files get.

gates/cli/argparse.py                  # start here — one adapter, one page
gates/cli/argparse/export.py           # a CLI's pages are its commands
gates/web/flask/dashboard.py           # one page plus its action handlers
gates/web/flask/checkout/payment.py    # page group / page

Gates mirror the interface; mills mirror the domain. The two trees are not expected to match, and nothing asks them to — a single page often calls services from several nouns, and one noun often surfaces on pages scattered across the sitemap. Naming a gates directory after a noun when no such page exists imports the domain tree into the interface, where it does not belong.

Red flags

The registry lives in one place: gates red flags, plus layout and slicing for the entries that cut across layers — a single gates.py, and a noun axis below the adapter.