We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Namespacing module attributes in libraries
almirsarajcic
I was refactoring an Ash app and wanted to put boundaries between its domains, so I tried adding use Boundary to the existing Ash domain modules. That’s where the options started clashing.
Both macros wrote to the same attribute in my module. Here’s the clash and the suggested fix, simplified:
# Shared attribute: the second assignment wins
@opts ash_opts
@opts boundary_opts
# Each library gets its own private attribute
@_spark_dsl_opts ash_opts
@_boundary_definition_opts boundary_opts
Both wrote to @opts in my module. The second assignment replaced the first library’s entire options list, even though their option keys were different.
With Boundary first, its export declaration could disappear. Reversing the calls could lose an Ash validation setting instead. Both libraries needed those options later during compilation, so changing the order just changed which configuration got overwritten.
The Ash-side code belongs to Spark.Dsl. In both libraries, @opts is internal storage. You configure them through use, and Spark passes options to its supported callbacks as arguments; neither documents @opts as something you should read or change yourself.
So, what’s the solution? Should every library have its own @<library>_opts?
I looked at how some popular Elixir libraries handle this, including Phoenix, LiveView, Ecto, Plug, Absinthe and Oban, along with Elixir’s ExUnit. Several already use the library name:
- Phoenix.LiveView:
@phoenix_live_opts - Phoenix.Router:
@phoenix_routes,@phoenix_pipeline - Ecto.Schema:
@ecto_fields,@ecto_assocs - Plug.Builder:
@plug_builder_opts - ExUnit.Case:
@ex_unit_tests,@ex_unit_module - Absinthe:
@absinthe_blueprint,@absinthe_scope_stack
It varies even within Phoenix. Phoenix.Component uses names such as :__attrs__ and :__slots__, without a library prefix. Oban.Worker takes another approach and generates an __opts__/0 function containing its options.
Elixir already recommends starting private attributes with an underscore so completion tools can hide them. That still leaves two libraries using @_opts with the same problem.
I’d like to propose @<library>_<attribute> for attributes a library expects callers to use, and @_<library>_<attribute> for its private state:
# Public configuration
@my_library_timeout 5_000
# Private state
@_my_library_opts [timeout: 5_000]
The library name keeps their state separate; the underscore tells tools it’s private. Add a subsystem if it helps, as in @_boundary_definition_opts.
This is a suggestion for new attributes. I wouldn’t rename existing public APIs such as Ecto’s @primary_key or ExUnit’s @tag. And the attributes above are internal despite not having a leading underscore, so the current spelling alone won’t tell you what’s public.
I’ve suggested namespacing Boundary’s internal attributes in this PR, with a regression covering both declaration orders. I’ve also submitted the corresponding Spark change.
copied to clipboard