Skip to content

Layers and the dependency rule

wloop is one Go module laid out as ports and adapters. The use cases and the values they work on know nothing of SQLite, GitHub, Slack, Orca or the coding agent; each outside tool is an adapter behind a small port, and one composition root picks the adapters. The rules below are tests in internal/archtest; Architecture rules lists each one.

flowchart TD
  root["<b>composition root</b><br/>cmd/wloop, internal/cli<br/>picks the adapters, wires every port"]
  daemon["<b>daemon</b><br/>routes, SSE, jobs"]
  transport["<b>transports</b><br/>transport/mcp, web"]
  clients["<b>clients</b><br/>tui, menubar, hook, turncli"]
  api["<b>api</b><br/>wire types"]
  readers["<b>readmodel, catalog, intent</b>"]
  core["<b>core</b><br/>use cases and their ports"]
  domain["<b>domain</b><br/>values and pure rules"]
  adapters["<b>adapters</b><br/>adapter/*, workspace/*, github, slack, gitx"]

  root --> daemon & transport & clients & adapters
  clients --> api
  daemon --> readers
  daemon --> core
  transport --> core
  readers --> core
  api --> domain
  core --> domain
  adapters -. implement the ports .-> core
  adapters --> domain

Dependencies point down and inward. An adapter imports the port it implements and the domain; the core imports the domain and its own ports; the domain imports only the standard library.

LayerPackagesMay import
Domaininternal/domain/...the standard library and the domain; no adapter, manager or internal/github
Coreinternal/core/...the standard library, the core, the domain and internal/meter; never os/exec, net/http or database/sql
APIinternal/apithe domain
Clientsinternal/client/...internal/api, internal/api/client, the domain, internal/termfmt and internal/i18n
Read modelinternal/readmodel, internal/readmodel/cardsports; no adapter, GitHub, brief queue, situation deriver or Orca client
Daemoninternal/daemonanything but the adapters, internal/github and the Orca client, which it reaches through ports serve.go wires
Adaptersinternal/adapter/..., internal/workspace/..., internal/gitxthe ports they implement and the domain
Composition rootinternal/cli, cmd/wloopeverything

Some packages sit beside the layers with rules of their own:

  • The intent vocabulary: internal/intent (the loader), internal/intent/builtin (the names Go provides), internal/intent/expr (the predicate compiler, the only importer of cel-go), internal/frontmatter, internal/catalog (the actions a row offers). Each imports a fixed set; the loader never reaches the core, the daemon or an adapter, and only the catalog builds action specs or evaluates an intent’s predicates.
  • Shared leaves: internal/brand (every user-visible name), internal/meter (the cost of each boundary call, standard library only), internal/procgroup (the process group of each agent call the daemon runs, stopped on cancel and on the daemon’s stop), internal/termfmt, internal/keys, internal/config, internal/i18n (locale matching, plural forms and {name} interpolation, the standard library and golang.org/x/text only).
  • Outside the core for now: internal/brief runs its agent only through turns.Asker and reads the store only through the inventory; internal/profile composes the review profile’s blocks; internal/turn renders a turn’s instructions.

A port is an interface declared by the core package that uses it, with at most five methods. A capability only some adapters offer is a second, optional interface the core finds by type assertion and does without, such as turns.Questions beside turns.Agent, or workspace.Board and workspace.Waker beside workspace.Manager. Types several core packages share, such as Clock, Changes, Source and Effects, live in internal/core/port. Ports and adapters lists them.

internal/cli builds the binary’s commands, and three of its files choose what runs:

  • root.go holds the registries of agents and workspace managers: one entry per agent kind and per manager, the only place outside the adapters that names one, and Defaults, the agent and managers a config file that names none picks;
  • sources.go lists the compiled-in plugins, the only file that names a source (slack*.go wire the Slack source’s commands);
  • storecmd.go opens the store for the daemon, migrating it when it is older, and serve.go wires every port of the daemon.

The commands call the daemon through internal/api/client. Those that work with the daemon down, such as install, the stateless checks of doctor and the developer tools, run in the command’s own process and never write the store.

To addPut it inThen
A behavior: when something happens, on which units, with which textthe profile: a workflow under profiles/default/workflows/<name>/ or an intent under profiles/default/intents/<name>/see Profile first
A fact a predicate readsbuiltin.Facts in internal/intent/builtin/facts.go, computed by the provider’s facts adapter (internal/adapter/facts/<provider>)make docs-ref
A trigger that fires once per signala workflow with [marks] and handled() in its when; the signal as a fact of its provider if it is not one yetsee Marks
A workflow step or an intent builtinits name in internal/intent/builtin/builtin.go, its implementation in internal/cli/steps.go or internal/cli/builtins.go (internal/daemon/builtins.go for the daemon’s routes)make docs-ref
A key of a manifest, actions.toml, tasks.toml, views.toml, gates.toml, records.toml, keys.toml or the configurationa row of builtin.Grammar in internal/intent/builtin/grammar*.go, with its kind, values, default and doc, and the field that decodes itmake docs-ref
A role or an effectbuiltin.Roles in internal/intent/builtin/roles.go, builtin.Effects in internal/intent/builtin/effects.gomake docs-ref
A use casea package under internal/core, with the ports it needs declared there and a fake in <name>test
A value or a pure ruleinternal/domain/<name>
An outside toolan adapter under internal/adapter/<tool> implementing the core’s port, plus one entry in internal/cli/root.go
A source of unitsa plugin: see Plugins
An API routeits path in internal/api, its handler in internal/daemon, its call in internal/api/client
A CLI commandinternal/cli, calling the daemon through internal/api/clientmake docs-ref
A view in the cockpit, the terminal or the menu barserved by the daemon as data (rows, actions, facets, counters) and rendered by internal/web/static, internal/client/tui or internal/client/menubar; a plugin’s own view through its Module.UI
A native host for the cockpit’s windowan adapter beside internal/client/window/wails implementing window.Host, picked in internal/cli/nativehost.go; when and where the window shows stays in internal/client/window
A store table or queryinternal/adapter/store/sqlite/schema.sql with a migration to the next schema version, and a store method there; SQL text lives nowhere elsesee Changing the store’s schema
A user-visible namederived from internal/brand
A constraint future code must obey, that a test keepsan entry under docs/src/content/docs/architecture/decisions/ naming that testmake docs-ref; see the decision log