When building large-scale, modular systems in Elixir, you eventually hit a architectural crossroads: do you split your domain into multiple Umbrella apps/Engines, or do you maintain a single cohesive monolith with strict boundaries?
In JusrisOS, we chose a local-first microkernel architecture. The system relies on an immutable Core (kernel, ports, adapters, sync layer) and a set of shared-nothing plugins located under lib/jusris_os_core/plugins/.
However, as the system grew, standard compilation (mix compile.elixir) presented two distinct structural problems:
-
Compilation Cascades & False Warnings: The Core module often references plugin schemas dynamically or at runtime (e.g., central state modules tracking registered extensions). During standard compilation, if the Core compiles before or alongside plugins, Elixir raises
module is not availablewarnings or triggers full recompilation loops. -
Strict Boundary Enforcement: Plugins must remain completely decoupled (shared-nothing). They can depend on
KernelorSupport, but never on each other. Standard compilation treats all.exfiles inlib/as a flat dependency graph, making it easy for transitive dependencies to leak across domain boundaries.
To solve this, we wrote a custom compiler: Mix.Tasks.Compile.Phased.
The Solution: A Phased Compiler Task
Instead of hacking custom scripts, we extended Mix.Task.Compiler to introduce a 3-phase compilation pipeline directly into Mix.
defmodule Mix.Tasks.Compile.Phased do
use Mix.Task.Compiler
@manifest "compile.elixir"
@core_manifest "compile.elixir.core"
@plugins_manifest "compile.elixir.plugins"
@plugins_root "lib/jusris_os_core/plugins"
By replacing :elixir with :phased in mix.exs, our custom task takes full control over how files are partitioned, compiled, and validated, while maintaining $100\%$ compatibility with standard Mix tooling (mix xref, mix clean, protocol consolidation, and LiveView asset compilation).
How the 3-Phase Pipeline Works
Phase 1: Core Compilation (The Invariant Base)
The compiler first isolates all core sources from plugin sources:
defp partition_sources(srcs) do
all_files = Mix.Utils.extract_files(srcs, [:ex]) |> Enum.sort()
slugs = plugin_slugs()
plugin_files = Enum.filter(all_files, &plugin_file?(&1, slugs))
{all_files -- plugin_files, plugin_files}
end
It compiles the Core using its own manifest (compile.elixir.core). Because Core modules might reference plugin structs at runtime before plugins exist in the BEAM code path, Phase 1 applies three key compiler overrides:
core_opts =
opts
|> Keyword.put(:consolidate_protocols, false)
|> Keyword.put(:infer_signatures, false)
|> Keyword.put(:no_warn_undefined, :all)
-
no_warn_undefined: :all: Suppresses temporary "undefined module" warnings during the initial bootstrap pass. -
infer_signatures: false: Disables type inference temporarily to force a re-check pass later. -
consolidate_protocols: false: Postpones protocol consolidation until plugins (and theirdefimplimplementations) are loaded.
If Phase 1 fails due to a syntax or logical error in the Core, compilation halts immediately without touching the plugins.
Phase 2: Parallel Plugin Compilation (Shared-Nothing)
Once the Core is compiled and guaranteed to be consistent, Phase 2 kicks in. Plugins are compiled via Kernel.ParallelCompiler, distributing files across all available Erlang schedulers:
Mix.Compilers.Elixir.compile(
plugins_manifest(),
plugin_srcs,
dest,
cache_key,
erlang_manifests,
erlang_modules,
opts
)
Because our microkernel architecture mandates that plugins are strictly shared-nothing—they only depend on the Core and never on sibling plugins—compiling all plugin sources in parallel is fast, safe, and completely isolated.
Phase 3: Core Re-checking & Validation
After plugins are compiled, their modules inhabit the code path. Now, Phase 3 performs a verification pass over the Core:
recheck_opts =
opts
|> Keyword.put(:consolidate_protocols, false)
|> Keyword.delete(:force)
Mix.Compilers.Elixir.compile(
core_manifest(),
core_srcs,
dest,
cache_key,
erlang_manifests,
erlang_modules,
recheck_opts
)
By toggling infer_signatures back on and removing no_warn_undefined: :all, Mix triggers a reinfer? check. It does not recompiling the Core binaries, but it re-evaluates module references. If the Core contains a reference to a non-existent plugin or broken module, it is caught here with accurate compiler diagnostics.
Merging Manifests for Tooling Compatibility
To keep the rest of the Elixir ecosystem happy (mix clean, mix xref, phoenix_live_view colocated hooks), the phased compiler merges the isolated manifests (compile.elixir.core and compile.elixir.plugins) back into the canonical compile.elixir manifest:
defp merge_manifests(core_manifest, plugins_manifest, dest_manifest) do
case {read_raw_manifest(core_manifest), read_raw_manifest(plugins_manifest)} do
{{vsn, cm, cs, ce, cp, cck, ccwd, cdc, cpm, ccm, cproto},
{vsn2, pm, ps, pe, _, _, _, _, _, _, pproto}}
when is_integer(vsn) and vsn == vsn2 ->
merged = {
vsn,
Map.merge(cm, pm),
Map.merge(cs, ps),
Map.merge(ce, pe),
cp,
cck,
ccwd,
cdc,
cpm,
ccm,
merge_protocols(cproto, pproto)
}
File.mkdir_p!(Path.dirname(dest_manifest))
File.write!(dest_manifest, :erlang.term_to_binary(merged, [:compressed]))
:ok
_ ->
:ok
end
end
And finally, it triggers downstream compiler hooks (such as :boundary tracer unloading or LiveView asset compilation):
Enum.reduce(Mix.ProjectStack.pop_after_compiler(:elixir), result, fn fun, acc ->
fun.(acc)
end)
Key Takeaways
- Enforce Boundaries at Build Time: Custom Mix compilers allow you to move architectural rules (like microkernel core vs. plugin isolation) into the compilation pipeline itself.
-
Incremental Compilation Intact: By maintaining dedicated manifests per phase (
.coreand.plugins), Mix can incrementally recompile modified plugin files without invalidating the Core cache. -
Zero Ecosystem Tradeoffs: Merging binary term manifests back into
compile.elixirensures native Mix tasks remain functional.
Top comments (0)