The Problem
Erlang gives a function two visibility states. A function is local to its module, or exported to any code that can name the module and arity.
That model has worked well for a long time. It supports apply/3,
remote calls, shell debugging, generated code, hot code loading, and the
general openness that makes BEAM systems practical to operate. Anyone who
has debugged a running system from the shell knows the value of being
able to call the function that actually shows what is happening.
The trouble starts when an export exists only because a few nearby modules need to cooperate. The function is not intended as part of the public API of the system, though Erlang currently has no way to express that distinction.
Once the function is exported it becomes visible to everything else. Other modules can call it. Other applications can call it. AI-assisted tools can discover it and start using it because exported functions are easier to find than internal structure.
After that the function becomes part of the real API whether that was intended or not. Changing it is no longer a local change because other code may already depend on it. The compiler cannot distinguish intended callers from accidental callers.
Over time these dependencies spread through the system. The dependency graph stops matching the intended structure of the codebase because internal boundaries gradually become public boundaries.
Teams already try to control this in different ways. xref checks call
direction. Kappa introduced API modules and
ownership rules. OTP projects often leave internal exports out of the
public documentation. Some codebases add Dialyzer checks, review rules,
or custom tooling.
Those approaches help inside a codebase. They do not prevent another codebase from calling an exported function once it becomes visible.
Architectural Intent Should Survive The Team That Wrote It
In Your Code Has No Memory, I wrote that source code keeps behavior while the reason for the behavior disappears over time. The same thing happens to boundaries.
A team may know that a module was intended as an internal adapter or a private implementation detail. A later team sees only exported functions and existing call sites.
Call direction becomes part of the architecture because it defines which parts of the system are allowed to depend on which other parts. When internal calls spread outside their intended boundaries the system becomes harder to change because dependencies exist in places the original authors never planned for.
Teams change over time. Ownership changes. Internal shortcuts stay in the code long after the context that created them is gone.
Architectural intent needs to survive that process.
What Kappa Taught Me
Kappa grew out of practical problems at Klarna. The codebase was growing, the number of teams was growing, and dependencies were spreading between applications in ways that made refactoring slower and riskier.
Kappa tracked ownership of applications, modules, and database tables. It introduced API modules to limit inter-application calls. It checked record field usage so internal data structures stayed local to their owning application.
The purpose was not isolation for its own sake. The purpose was to make dependencies visible and keep changes local when possible.
Visibility Domains
The current EEP draft introduces two attributes:
-scope(Scope).
-scope_export([Name/Arity, ...]).
A module declares a scope and marks selected functions as exported inside that scope.
For example:
-module(my_module).
-scope(my_scope).
-export([public_function/1]).
-scope_export([internal_function/1]).
In this example public_function/1 is part of the public API.
internal_function/1 is exported for use by modules inside the same
scope.
The important part is that compatibility remains unchanged for existing
code. A module without these attributes behaves exactly as it does today.
Existing -export(...) entries remain public exports.
That means scoped exports can be introduced gradually inside an existing system.
The proposal does not introduce a new module system. OTP applications continue to work exactly as they do today for releases, supervision, configuration, and packaging.
Architectural structure does not always line up with OTP application boundaries though. One OTP application may contain several layers. One domain may span several applications.
The proposal adds a way to describe visibility boundaries directly in the code.
The Thin Waist
Many systems eventually converge on a smaller public API while more implementation details move behind it.
The stable part of the system stays relatively small while internal structure changes more frequently over time.
The problem is that Erlang currently has no built-in distinction between exports intended for public use and exports intended only for nearby internal cooperation.
Tooling And Runtime Checks
There are several possible enforcement levels.
The lightest version is metadata only. The compiler accepts
-scope_export(...), stores the information in the BEAM file, and tools
can read it.
Documentation tools can separate public exports from scoped exports.
Language servers can warn about cross-scope calls. xref can report
boundary drift in CI.
A stronger version adds compile-time or analysis-time warnings for cross-scope calls. That requires whole-program analysis because the tool needs visibility into both caller and callee.
The strongest version adds runtime enforcement where calls across scope boundaries raise an error.
That catches dynamic paths such as apply/3, remote funs, and other
cases where static analysis is incomplete.
Runtime enforcement also creates operational questions because shell debugging is part of normal Erlang operations. During incidents operators sometimes need to call functions directly in running systems.
Erlang also already contains escape hatches. A module compiled with
export_all together with hot code loading already allows operators to
debug almost any part of a running system.
The proposal should help systems preserve structure during normal development without making production systems harder to operate.
What The Prototype Shows
There is a prototype implementation in the runtime.
The prototype mainly exists to verify that scoped exports can be implemented inside the current BEAM model without significant runtime overhead and without changing existing module semantics.
The implementation currently handles direct calls, tail calls, apply/3,
external funs, unloaded module stubs, closures, and rpc:call between
nodes in both interpreter and JIT paths.
The compiler also exposes scoped export information through
module_info, which makes the information available to tooling even when
runtime enforcement is disabled.
The prototype does not say much about whether the semantics are the right ones. It mostly shows that the mechanism itself is practical to implement.
Open Questions
The proposal still leaves several open questions.
Should scoped exports exist only as metadata for tools, or should the runtime enforce them as well?
Should runtime behavior support modes such as off, warn, and enforce?
How should shell debugging behave when operators need direct access to scoped functions during incidents?
How should trusted peer libraries be represented without turning visibility scopes into a permission system?
Should module_info(exports) expose scoped exports together with public
exports, or should they appear separately?
How should documentation present scoped exports so the public API remains clear?
I would like the discussion to stay close to production systems and operational experience.
The most useful feedback will probably come from systems where dependency drift has already become a maintenance problem, and from maintainers who already struggle to communicate the difference between public APIs and shared internal implementation details.
There is now an Erlang Forums discussion thread for the proposal. The EEP process requires public discussion, and that thread is the right place to test the terminology, semantics, and trade-offs.
If this proposal turns out to be the wrong solution, the underlying problem still remains.
- Happi