DEV Community

Matheus de Camargo Marques
Matheus de Camargo Marques

Posted on

Building a Phased Microkernel Compiler in Elixir: How We Tamed Dependency Graphs in JusrisOS

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:

  1. 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 available warnings or triggers full recompilation loops.
  2. Strict Boundary Enforcement: Plugins must remain completely decoupled (shared-nothing). They can depend on Kernel or Support, but never on each other. Standard compilation treats all .ex files in lib/ 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"

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode
  • 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 their defimpl implementations) 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
)

Enter fullscreen mode Exit fullscreen mode

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
)

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. 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.
  2. Incremental Compilation Intact: By maintaining dedicated manifests per phase (.core and .plugins), Mix can incrementally recompile modified plugin files without invalidating the Core cache.
  3. Zero Ecosystem Tradeoffs: Merging binary term manifests back into compile.elixir ensures native Mix tasks remain functional.

Top comments (0)