diff --git a/app/lib/firehose.ex b/app/lib/firehose.ex index 91e1f02..8b6d762 100644 --- a/app/lib/firehose.ex +++ b/app/lib/firehose.ex @@ -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. diff --git a/app/lib/firehose/application.ex b/app/lib/firehose/application.ex index 4a4975a..b1d5f56 100644 --- a/app/lib/firehose/application.ex +++ b/app/lib/firehose/application.ex @@ -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 diff --git a/app/lib/firehose/repo.ex b/app/lib/firehose/repo.ex index 53d1325..299f0d9 100644 --- a/app/lib/firehose/repo.ex +++ b/app/lib/firehose/repo.ex @@ -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 diff --git a/app/lib/firehose_web.ex b/app/lib/firehose_web.ex index 0aa22ea..a8fcbe0 100644 --- a/app/lib/firehose_web.ex +++ b/app/lib/firehose_web.ex @@ -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. diff --git a/app/mix.exs b/app/mix.exs index b08ac8b..93c78a6 100644 --- a/app/mix.exs +++ b/app/mix.exs @@ -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}, diff --git a/app/test/firehose/boundary_test.exs b/app/test/firehose/boundary_test.exs new file mode 100644 index 0000000..5aabfda --- /dev/null +++ b/app/test/firehose/boundary_test.exs @@ -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 diff --git a/blogex/mix.exs b/blogex/mix.exs index 885a612..4a0a8bf 100644 --- a/blogex/mix.exs +++ b/blogex/mix.exs @@ -46,6 +46,10 @@ defmodule Blogex.MixProject do {:phoenix_live_view, "~> 1.0"}, {:jason, "~> 1.4"}, {: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}, {:rustler, "~> 0.38.0", optional: true}, {:ex_crap, "~> 0.1.1", only: [:dev, :test], runtime: false}, diff --git a/blogex/test/blogex/boundary_dependency_test.exs b/blogex/test/blogex/boundary_dependency_test.exs new file mode 100644 index 0000000..96895b2 --- /dev/null +++ b/blogex/test/blogex/boundary_dependency_test.exs @@ -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