yak(boundary-repo-as-sibling-boundary-web-repo-forbidden-tgrc): boundary: Repo as sibling boundary, web->Repo forbidden
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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},
|
||||
|
||||
@@ -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
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user