yak(boundary-repo-as-sibling-boundary-web-repo-forbidden-tgrc): boundary: Repo as sibling boundary, web->Repo forbidden

This commit is contained in:
Willem van den Ende
2026-10-29 10:08:56 +00:00
parent 3ef38bc8fc
commit 13e3186f58
8 changed files with 163 additions and 1 deletions
+4
View File
@@ -1,4 +1,8 @@
defmodule Firehose do
use Boundary,
deps: [Firehose.Repo],
exports: :all
@moduledoc """
Firehose keeps the contexts that define your domain
and business logic.
+10
View File
@@ -3,6 +3,16 @@ defmodule Firehose.Application do
# for more information on OTP Applications
@moduledoc false
# Booting the app is the one job that must reach into the web tier (it starts
# FirehoseWeb.Telemetry and FirehoseWeb.Endpoint) while the web tier reaches
# back into the contexts. Declaring the Application a top-level boundary of
# its own is the layout boundary's README prescribes for `mix phx.new`
# projects, and it keeps the graph acyclic:
# Firehose.Application -> {Firehose, FirehoseWeb} -> Firehose -> Firehose.Repo.
# Putting FirehoseWeb in Firehose's deps instead is a cycle, which boundary
# rejects ("dependency cycle found: Firehose -> FirehoseWeb -> Firehose").
use Boundary, top_level?: true, deps: [Firehose, FirehoseWeb]
use Application
@impl true
+2
View File
@@ -1,4 +1,6 @@
defmodule Firehose.Repo do
use Boundary, top_level?: true, deps: [], exports: []
use Ecto.Repo,
otp_app: :firehose,
adapter: Ecto.Adapters.Postgres
+4
View File
@@ -1,4 +1,8 @@
defmodule FirehoseWeb do
use Boundary,
deps: [Firehose],
exports: [Endpoint, Telemetry]
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, components, channels, and so on.
+14 -1
View File
@@ -11,7 +11,7 @@ defmodule Firehose.MixProject do
aliases: aliases(),
deps: deps(),
test_coverage: test_coverage(),
compilers: [:phoenix_live_view] ++ Mix.compilers(),
compilers: extra_compilers(Mix.env()) ++ [:phoenix_live_view] ++ Mix.compilers(),
listeners: [Phoenix.CodeReloader]
]
end
@@ -44,6 +44,14 @@ defmodule Firehose.MixProject do
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
# The boundary compiler must be listed *before* `:elixir`: it enforces by
# registering an `after_compiler(:elixir, ...)` callback, so a later position
# would look wired and report nothing. It stays out of `:prod` on boundary's
# own advice (a violation must not be able to fail a release build); `:dev`
# and `:test` are where code is written.
defp extra_compilers(env) when env in [:dev, :test], do: [:boundary]
defp extra_compilers(_), do: []
# Specifies your project dependencies.
#
# Type `mix help deps` for examples and options.
@@ -81,6 +89,11 @@ defmodule Firehose.MixProject do
{:microprints,
git: "https://gitea.apps.sustainabledelivery.com/QWAN/microprints-phoenix.git",
branch: "main"},
# Compile-time boundary enforcement (Firehose / FirehoseWeb / Firehose.Repo).
# Declared directly even though `ex_crap` already pulls it in transitively:
# dropping a test-only metric tool must not silently take the boundary
# compiler with it.
{:boundary, "~> 0.11", runtime: false},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:ex_crap, "~> 0.1.1", only: [:dev, :test], runtime: false},
{:muex, "~> 0.12", only: [:dev, :test], runtime: false},
+98
View File
@@ -0,0 +1,98 @@
defmodule Firehose.BoundaryTest do
use ExUnit.Case, async: true
alias Mix.Dep.Lock
# Boundary enforcement itself is a compile-time property: the pin on it is the
# warnings-fatal compile in `make verify` (and the recorded violation
# demonstration in the yak notes). These tests pin the *declarations* that
# compile gate reads, so a later edit that quietly re-opens the web -> Repo
# hole shows up as a failure here instead of only as an architectural drift.
#
# They read the `Boundary` module attribute that `use Boundary` persists. That
# is the same attribute boundary's own classifier reads
# (deps/boundary/lib/boundary/definition.ex: `Module.register_attribute(...,
# Boundary, persist: true)`), so this is the declaration as compiled, not a
# grep of the source text.
describe "boundary dependency" do
test "boundary is a direct dependency, not one inherited from ex_crap" do
assert {:boundary, requirement, opts} =
List.keyfind(Mix.Project.config()[:deps], :boundary, 0)
assert opts[:runtime] == false, "boundary is compile-time only; it must not ship at runtime"
assert Version.match?(locked_version(:boundary), requirement)
end
end
describe "a sibling Repo boundary" do
test "Firehose.Repo is its own top-level boundary with no dependencies" do
definition = boundary(Firehose.Repo)
assert definition.opts[:top_level?] == true,
"Firehose.Repo must be a sibling of Firehose/FirehoseWeb, not nested inside Firehose"
assert deps(Firehose.Repo) == [],
"the Repo is the bottom of the graph: it may depend on nothing"
end
test "Firehose may use Firehose.Repo" do
assert Firehose.Repo in deps(Firehose)
end
test "FirehoseWeb may not use Firehose.Repo" do
refute Firehose.Repo in deps(FirehoseWeb),
"web -> Repo is the hole this boundary closes; only contexts may touch the Repo"
end
end
describe "the context boundary's surface" do
test "Firehose exports everything, so the web tier can keep using context modules" do
assert boundary(Firehose).opts[:exports] == :all
end
end
# Application boot is the only thing that reaches from the context tier into
# the web tier. Making it its own top-level boundary (boundary's README
# prescribes exactly this for `mix phx.new` layouts) is what keeps the graph
# acyclic; the alternative -- FirehoseWeb in Firehose's deps -- is rejected by
# boundary with "dependency cycle found: Firehose -> FirehoseWeb -> Firehose".
describe "the application boundary" do
test "Firehose.Application depends on both tiers, and neither tier on the other's boot code" do
assert boundary(Firehose.Application).opts[:top_level?] == true
assert Enum.sort(deps(Firehose.Application)) == [Firehose, FirehoseWeb]
refute FirehoseWeb in deps(Firehose),
"Firehose depending on FirehoseWeb is the dependency cycle"
end
end
describe "enforcement wiring" do
# A compiler that is registered after :elixir looks wired but never reports
# anything, so the gate would be decoration. Position is the property.
test "the boundary compiler runs before :elixir in this environment" do
compilers = Mix.Project.config()[:compilers]
assert :boundary in compilers, "boundary compiler is not wired in #{Mix.env()}"
assert Enum.find_index(compilers, &(&1 == :boundary)) <
Enum.find_index(compilers, &(&1 == :elixir)),
"boundary must be listed before :elixir: it hooks after_compiler(:elixir, ...)"
end
end
defp boundary(module) do
assert [definition] = module.__info__(:attributes)[Boundary],
"#{inspect(module)} declares no boundary"
definition
end
defp deps(module), do: Keyword.get(boundary(module).opts, :deps, [])
defp locked_version(app) do
Lock.read()
|> Map.fetch!(app)
|> elem(2)
end
end