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
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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},
|
||||||
|
|||||||
@@ -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"},
|
{: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
|
||||||
Reference in New Issue
Block a user