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 defmodule Firehose do
use Boundary,
deps: [Firehose.Repo],
exports: :all
@moduledoc """ @moduledoc """
Firehose keeps the contexts that define your domain Firehose keeps the contexts that define your domain
and business logic. and business logic.
+10
View File
@@ -3,6 +3,16 @@ defmodule Firehose.Application do
# for more information on OTP Applications # for more information on OTP Applications
@moduledoc false @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 use Application
@impl true @impl true
+2
View File
@@ -1,4 +1,6 @@
defmodule Firehose.Repo do defmodule Firehose.Repo do
use Boundary, top_level?: true, deps: [], exports: []
use Ecto.Repo, use Ecto.Repo,
otp_app: :firehose, otp_app: :firehose,
adapter: Ecto.Adapters.Postgres adapter: Ecto.Adapters.Postgres
+4
View File
@@ -1,4 +1,8 @@
defmodule FirehoseWeb do defmodule FirehoseWeb do
use Boundary,
deps: [Firehose],
exports: [Endpoint, Telemetry]
@moduledoc """ @moduledoc """
The entrypoint for defining your web interface, such The entrypoint for defining your web interface, such
as controllers, components, channels, and so on. as controllers, components, channels, and so on.
+14 -1
View File
@@ -11,7 +11,7 @@ defmodule Firehose.MixProject do
aliases: aliases(), aliases: aliases(),
deps: deps(), deps: deps(),
test_coverage: test_coverage(), test_coverage: test_coverage(),
compilers: [:phoenix_live_view] ++ Mix.compilers(), compilers: extra_compilers(Mix.env()) ++ [:phoenix_live_view] ++ Mix.compilers(),
listeners: [Phoenix.CodeReloader] listeners: [Phoenix.CodeReloader]
] ]
end end
@@ -44,6 +44,14 @@ defmodule Firehose.MixProject do
defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"] 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. # Specifies your project dependencies.
# #
# Type `mix help deps` for examples and options. # Type `mix help deps` for examples and options.
@@ -81,6 +89,11 @@ defmodule Firehose.MixProject do
{:microprints, {:microprints,
git: "https://gitea.apps.sustainabledelivery.com/QWAN/microprints-phoenix.git", git: "https://gitea.apps.sustainabledelivery.com/QWAN/microprints-phoenix.git",
branch: "main"}, 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}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:ex_crap, "~> 0.1.1", only: [:dev, :test], runtime: false}, {:ex_crap, "~> 0.1.1", only: [:dev, :test], runtime: false},
{:muex, "~> 0.12", 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
+4
View File
@@ -46,6 +46,10 @@ defmodule Blogex.MixProject do
{:phoenix_live_view, "~> 1.0"}, {:phoenix_live_view, "~> 1.0"},
{:jason, "~> 1.4"}, {:jason, "~> 1.4"},
{:plug, "~> 1.15"}, {:plug, "~> 1.15"},
# Declared directly although unused today (no boundaries, no compiler
# entry): `ex_crap` supplies boundary transitively, and a compile-time
# tool must not arrive or vanish as a side effect of a metric tool.
{:boundary, "~> 0.11", runtime: false},
{:ex_doc, "~> 0.34", only: :dev, runtime: false}, {:ex_doc, "~> 0.34", only: :dev, runtime: false},
{:rustler, "~> 0.38.0", optional: true}, {:rustler, "~> 0.38.0", optional: true},
{:ex_crap, "~> 0.1.1", only: [:dev, :test], runtime: false}, {:ex_crap, "~> 0.1.1", only: [:dev, :test], runtime: false},
@@ -0,0 +1,27 @@
defmodule Blogex.BoundaryDependencyTest do
use ExUnit.Case, async: true
alias Mix.Dep.Lock
# blogex declares no boundaries and does not run the boundary compiler, so
# this dependency is inert today. It is declared directly anyway (app/mix.exs
# does the same) so the compiler cannot arrive or vanish as a side effect of
# `ex_crap`, which supplies it transitively. This pins "direct, and locked to a
# version that satisfies the requirement"; if boundary is ever wired up here,
# the compile gate is that change's pin, not this test.
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
defp locked_version(app) do
Lock.read()
|> Map.fetch!(app)
|> elem(2)
end
end