Native Erlang Integration
August 1, 2026 · View on GitHub
Beamtalk compiles to BEAM via Core Erlang, which means it runs on the same virtual machine as Erlang and Elixir. While most Beamtalk code is written in .bt files, some use cases require hand-written Erlang — OTP gen_server lifecycle management, port handling, NIF wrappers, or direct access to hex.pm packages. Beamtalk provides a structured integration path for these cases.
This guide covers the complete native Erlang workflow: project layout, FFI calls, native-backed actors, hex dependencies, the stub generator, and practical examples.
Overview
There are three mechanisms for integrating Erlang code with Beamtalk, each suited to different scenarios:
| Mechanism | Use case | Declared in |
|---|---|---|
(Erlang module) FFI | Call any Erlang function from a method body | Per-method |
native: + self delegate | A class wholesale-backed by a hand-written Erlang module (a stateless Object or an Actor gen_server) | Class declaration |
[native.dependencies] | Use hex.pm packages from native Erlang code | beamtalk.toml |
These mechanisms are complementary. A single package might use all three — FFI for simple utility calls, native: for classes that delegate wholesale to one Erlang module, and hex dependencies for ecosystem libraries like gun or cowboy.
Unified model (ADR 0101).
native:is the universal "this class is backed by a named Erlang module" declaration; the class kind selects the lowering — anative:Objectdelegates with an in-process synchronous call, while anative:Actordelegates across a process boundary viagen_server:call. Both share the same boundary, so all FFI is safe by default:error:*exceptions become structured#beamtalk_error{}values (a user never sees a raw Erlang tuple at the REPL), whileexit:*/throw:*still propagate. See Native Stateless Objects and Wrap-by-Default Error Handling.
Directory Layout
Native Erlang source files live in a native/ directory, separate from src/:
my_package/
beamtalk.toml # Package manifest
src/ # Beamtalk sources
MyClass.bt
MyActor.bt
native/ # Erlang sources
my_backing_module.erl
native/include/ # Erlang headers (optional)
my_records.hrl
native/test/ # Erlang tests (EUnit/Common Test)
my_backing_module_tests.erl
test/ # Beamtalk tests (BUnit)
MyClassTest.bt
MyActorTest.bt
The native/ directory name is deliberate — it matches the native: keyword on actor classes and signals that the contents are a "native escape hatch" rather than primary Beamtalk source.
Convention: Prefix native Erlang module names with the package name to avoid collisions (e.g., my_package_pool.erl for a package named my_package). BEAM has a flat module namespace — only one version of any module can be loaded at a time.
Erlang FFI — (Erlang module) Calls
The simplest integration mechanism. Any Beamtalk method can call an Erlang function using the (Erlang module) syntax:
// Call a function on the native module
Object subclass: NativeCalc
class add: a with: b =>
(Erlang native_calc) add: a with: b
class multiply: a with: b =>
(Erlang native_calc) multiply: a with: b
class version =>
(Erlang native_calc) version
The corresponding Erlang module in native/:
%% native/native_calc.erl
-module(native_calc).
-export([add/2, multiply/2, version/0]).
add(A, B) -> A + B.
multiply(A, B) -> A * B.
version() -> 1.
Keyword mapping: In Erlang FFI calls, the function name is taken from the first keyword (with the colon removed), and all arguments are passed positionally. (Erlang my_mod) do: x with: y calls my_mod:do(X, Y). Unary selectors map directly: (Erlang my_mod) status calls my_mod:status().
Instance methods pass self explicitly when delegating to Erlang:
sealed retryWith: opts :: Dictionary -> HTTPResponse =>
(Erlang beamtalk_http) retry: self options: opts
Class-side utility objects (no instances, just namespace wrappers) delegate each class method:
sealed Object subclass: System
class getEnv: name :: String -> String | Nil =>
(Erlang beamtalk_system) getEnv: name
class osPlatform -> String =>
(Erlang beamtalk_system) osPlatform
FFI is the right choice when:
- You need to call a handful of Erlang functions
- The Erlang module is stateless or manages its own state externally
- You want explicit control over which Erlang function each method calls
Wrap-by-Default Error Handling
All Erlang calls — inline (Erlang …) FFI, native: Object delegation, and native: Actor delegation — share one error boundary (ADR 0101 Part 2). The boundary handles the two channels a BEAM function can use independently:
| Outcome of the Erlang call | Channel | Beamtalk result | How you handle it |
|---|---|---|---|
returns {ok, V} / {error, R} | return value | a Result(V, R) value (ADR 0076) | isOk / value / andThen: |
raises error:Reason | exception | a raised #beamtalk_error{} | on:do: / ensure:, or it bubbles to the REPL |
raises exit:Reason | exception | propagates unwrapped (caught by an enclosing on:do: as erlang_exit, else process death) | supervision / let-it-crash |
raises throw:Term | exception | passes through unchanged | ^ and Beamtalk exceptions are throw/catch |
The key guarantee: a user never sees a raw Erlang error tuple at the REPL. A badarg, {badkey, K}, function_clause, etc. is converted to a structured #beamtalk_error{} with a kind, a hint, and a Class/selector breadcrumb — not a bare {badarg, [...]}.
exit:/throw: are deliberately not wrapped: process exit and foreign throws are semantically meaningful (intentional erlang:exit/1, {noproc, _} from a dead actor, control-flow throws) and must not be flattened into #beamtalk_error{}. So (Erlang erlang) exit: #killed still terminates the process with reason killed.
Same function, both channels
The return channel (Result) and the exception channel (#beamtalk_error{}) are orthogonal and mutually exclusive per call — a function either returns or raises — so the same function can use both:
// File readAll: -> Result(String, Error)
// Modeled, recoverable failure → a RETURNED Result error: value
File readAll: "missing.txt" // => Result error: File 'readAll:': file not found
// Misuse / fault (a non-String path) → a RAISED #beamtalk_error{}
File readAll: 12345 // raises: Type error in 'readAll:' on File
The split is principled: Result for expected/recoverable outcomes the API models, exceptions for misuse/faults. Wrap-by-default changes no return type — a raised #beamtalk_error{} is invisible to the type system, so Result remains the only type-visible error channel.
Error context: Class + selector vs Erlang MFA
A wrapped native: error carries the Beamtalk Class and selector, so it reads Stream / take: rather than the backing beamtalk_stream:take:
(Stream from: 1) take: -1 // raises Type error in 'take:' on Stream
Inline (Erlang …) FFI has a documented limitation: the proxy knows the Erlang MFA, not the Beamtalk class, so an inline-FFI error carries the ErlangModule context (e.g. Type error in 'atom_to_list' on ErlangModule). It is still structured — just less specific than a native: method's breadcrumb. Prefer native: for whole-class delegation precisely so errors name your class.
Native Stateless Objects — native: for Object
native: is not limited to actors. A stateless Object that delegates wholesale to a single Erlang module declares the module on subclass: and uses => self delegate bodies — identical surface to a native Actor, but the lowering is an in-process synchronous call rather than a gen_server round-trip (the class kind selects the lowering). Stream is the worked example:
sealed typed Object subclass: Stream(E) native: beamtalk_stream
class sealed from: start :: Integer -> Stream(Integer) => self delegate // ⇒ beamtalk_stream:from(Start)
select: predicate :: Block -> Stream(E) => self delegate // ⇒ beamtalk_stream:select(Self, Predicate)
take: count :: Integer -> List(E) => self delegate // ⇒ beamtalk_stream:take(Self, Count)
asList -> List(E) => self delegate // ⇒ beamtalk_stream:asList(Self)
A class may freely mix self delegate methods with full-bodied Beamtalk methods.
Object vs Actor delegation
native: Object | native: Actor | |
|---|---|---|
self delegate lowers to | beamtalk_erlang_proxy:native_call(Mod, Fn, [Self|Args], {Class, Sel}) | beamtalk_actor:sync_send(Pid, Sel, Args) |
Receiver (self) | first positional arg | the actor pid |
| Call shape | in-process, synchronous — cannot block, time out, or raise noproc | crosses a process boundary — can block, time out, raise noproc, cross nodes |
| State | none (state: is for Actors) | lives in the gen_server |
Caution (leaky abstraction). Because both kinds read
self delegate, switching a delegation class betweenObject subclass:andActor subclass:silently flips the lowering (in-process call ⇄ cross-processsync_send) with no type error. The class kind one line above is the only signal of the failure/performance profile.
The naming rule (normative)
The Erlang function name is the first keyword with its colon removed; the remaining keyword values follow as positional args. Self-threading is inferred from the declaration side:
- Instance methods prepend
self:take: count→mod:take(Self, Count);inject: i into: b→mod:inject(Self, I, B); unaryasList→mod:asList(Self). - Class methods omit
self:class from: start→mod:from(Start).
This is the same first-keyword convention as inline FFI ((Erlang mod) do: x with: y → mod:do(X, Y)).
The delegate sentinel
self delegate typechecks because the Object/Value base (and Object class, for class-side constructors) defines a delegate sentinel — exactly mirroring the delegate sentinel ADR 0056 added to Actor. On a native: class, codegen rewrites self delegate to the real call before the sentinel runs; on a non-native: class the sentinel raises, catching the misuse. delegate is therefore a reserved selector on the Object protocol.
Inheritance
native: delegation follows the class declaration, not inheritance. A subclass that does not redeclare native: inherits no backing module and cannot add self delegate methods of its own; a subclass that redeclares native: other_mod uses other_mod only for the self delegate methods it declares.
Reserved-word constraint
If a method's first keyword is an Erlang reserved word (after and andalso band begin bnot bor bsl bsr bxor case catch cond div end fun if let not of or orelse receive rem try when xor), codegen emits a compile error rather than producing invalid output like mod:receive(Self, X). Use inline FFI with an explicitly-named function for those (rare) cases.
Instantiating a native: Class (BT-2998)
A native: class keeps its instances in whatever shape its backing module defines — beamtalk_datetime's calendar tuple, beamtalk_uuid's 16 raw bytes, beamtalk_stream's generator closure — and normally declares no fields of its own. The inherited Value class>>new builds an instance out of $beamtalk_class plus the declared field defaults, so on such a class it can only produce a correctly-tagged but empty map: dispatch accepts it, and the first real message then fails deep inside the Erlang module.
So new/new: on a native: class with no declared fields is a compile error, and the compiled new/0 raises instantiation_error for the dynamic paths (REPL, perform:) where no static receiver class is known. Both messages name the class's actual constructors:
DateTime new
// error: `native:` class `DateTime` cannot be instantiated with `new` — its
// instances live in its backing Erlang module, not in declared fields
// help: Use one of: DateTime now, DateTime year:month:day:, DateTime fromString:
Two escapes, both of which say "this class really can build an instance":
- Declare your own class-side
new.RandomandQueuedo —class sealed new -> Random => self delegate— and it is a real constructor, so nothing is refused. - Declare fields. A
native:class carryingfield:declarations (Package,SupervisionNode) has a genuine default instance, sonewkeeps building it.
Native Actors — native: and self delegate
When an Actor needs a hand-written gen_server (for port management, deferred replies, handle_info/2, or complex OTP patterns), declare it with native::
Actor subclass: DatabasePool native: my_db_pool
class connect: config => self spawnWith: config
query: sql -> List => self delegate
query: sql params: params -> List => self delegate
transaction: block -> Object => self delegate
close -> Nil => self delegate
How It Works
The native: keyword on subclass: tells the compiler to generate a facade module instead of a full gen_server. Methods with => self delegate bodies are compiled into dispatch calls that forward through beamtalk_actor:sync_send/3 to the backing gen_server. Methods with full Beamtalk bodies (like connect: above) compile normally.
The result is that callers interact with DatabasePool using standard Beamtalk message sends — they never see the Erlang gen_server behind it:
pool := DatabasePool connect: #{"host" => "localhost", "port" => 5432}
rows := pool query: "SELECT * FROM users"
pool close
Writing the Backing Gen_Server
The hand-written Erlang module must implement the standard gen_server protocol. Messages arrive in the wire format {Selector, Args, PropCtx} where PropCtx is a propagated context map (ADR 0069). Add a guard clause to strip it, then match on {Selector, Args}:
%% native/my_db_pool.erl
-module(my_db_pool).
-behaviour(gen_server).
-export([start_link/1, init/1, handle_call/3, handle_cast/2,
handle_info/2, terminate/2]).
start_link(Config) ->
gen_server:start_link(?MODULE, Config, []).
init(Config) ->
{ok, Conn} = connect_db(Config),
{ok, #{conn => Conn}}.
%% Strip propagated context (ADR 0069 Phase 2b) — required boilerplate
handle_call({Selector, Args, PropCtx}, From, State) when is_map(PropCtx) ->
beamtalk_actor:restore_propagated_ctx(PropCtx),
handle_call({Selector, Args}, From, State);
handle_call({'query:', [SQL]}, _From, #{conn := Conn} = State) ->
{reply, {ok, execute(Conn, SQL, [])}, State};
handle_call({'query:params:', [SQL, Params]}, _From, #{conn := Conn} = State) ->
{reply, {ok, execute(Conn, SQL, Params)}, State};
handle_call({'transaction:', [Block]}, _From, #{conn := Conn} = State) ->
Result = run_transaction(Conn, Block),
{reply, {ok, Result}, State};
handle_call({close, []}, _From, #{conn := Conn} = State) ->
close_db(Conn),
{reply, {ok, nil}, State};
handle_call(_Request, _From, State) ->
{reply, {error, not_implemented}, State}.
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, #{conn := Conn}) ->
close_db(Conn),
ok.
Key Rules
start_link/1 is required. The facade always calls BackingModule:start_link(Config) where Config is the map passed to spawnWith:. When spawn is called without arguments, Config is #{} (empty map).
Use {ok, Result} reply wrapping. Replies must be {ok, Value} or {error, Reason}. The DirectValue fallback exists for legacy modules but new native actors must use wrapped replies to avoid ambiguity with error tuples.
No state: declarations. Native actors cannot declare state: fields — all instance state lives in the gen_server. classState: is permitted for class-level state (e.g., a singleton reference).
// This is a compile error:
Actor subclass: Broken native: some_module
state: count = 0 // => error: native actor cannot declare state fields
Type annotations are recommended. Since self delegate method bodies are opaque, the type annotation is the only type information available to the compiler and LSP:
query: sql :: String -> List => self delegate
The compiler emits a warning if a self delegate method has no return type annotation.
Sync and Async Dispatch
The call site determines the dispatch mode — no annotation needed in the .bt file:
pool query: sql-- sync viagen_server:call(handle_call/3)pool query: sql!-- async cast viagen_server:cast(handle_cast/2)
Backing gen_servers that support fire-and-forget should implement both handle_call and handle_cast clauses for the relevant selectors.
gen_statem Support
native: also works with gen_statem backing modules. Both gen_server and gen_statem expose the same gen:call/4 API internally. Synchronous calls arrive as {call, From} events in state callbacks:
%% gen_statem handle_event_function mode
%% Strip propagated context (ADR 0069 Phase 2b)
handle_event({call, From}, {Selector, Args, PropCtx}, State, Data) when is_map(PropCtx) ->
beamtalk_actor:restore_propagated_ctx(PropCtx),
handle_event({call, From}, {Selector, Args}, State, Data);
handle_event({call, From}, {'readLine', []}, _State, Data) ->
{keep_state, Data, [{reply, From, {ok, read_line(Data)}}]}.
The generate native Stub Generator
Rather than writing the gen_server boilerplate by hand, use the generate native CLI command to generate a skeleton from the .bt file:
beamtalk generate native DatabasePool
This reads src/DatabasePool.bt (or DatabasePool.bt in the current directory), extracts the native: module name and all self delegate methods, and generates a skeleton Erlang file:
%% Generated from DatabasePool.bt — fill in implementations
%% @doc Backing gen_server for the DatabasePool native Actor.
-module(my_db_pool).
-behaviour(gen_server).
-export([start_link/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
-spec start_link(map()) -> {ok, pid()} | {error, term()}.
start_link(Config) ->
gen_server:start_link(?MODULE, Config, []).
-spec init(map()) -> {ok, map()}.
init(_Config) ->
%% TODO: initialise state from Config
{ok, #{}}.
%% Strip propagated context (ADR 0069 Phase 2b) — required boilerplate
handle_call({Selector, Args, PropCtx}, From, State) when is_map(PropCtx) ->
beamtalk_actor:restore_propagated_ctx(PropCtx),
handle_call({Selector, Args}, From, State);
%% --- Delegate methods from DatabasePool.bt ---
%% query: -> List
handle_call({'query:', [Sql]}, _From, State) ->
%% TODO: implement query:
{reply, {ok, todo}, State};
%% query:params: -> List
handle_call({'query:params:', [Sql, Params]}, _From, State) ->
%% TODO: implement query:params:
{reply, {ok, todo}, State};
%% transaction: -> Object
handle_call({'transaction:', [Block]}, _From, State) ->
%% TODO: implement transaction:
{reply, {ok, todo}, State};
%% close -> Nil
handle_call({close, []}, _From, State) ->
%% TODO: implement close
{reply, {ok, todo}, State};
handle_call(_Request, _From, State) ->
{reply, {error, not_implemented}, State}.
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, _State) ->
ok.
The generated file is a starting point — fill in the TODO implementations, then move the file to native/. The generator produces {ok, todo} reply wrapping by default, matching the required {ok, Result} protocol.
File search order: The command looks for {ClassName}.bt in the current directory, then src/{ClassName}.bt, then lib/{ClassName}.bt.
Error cases:
- Class without
native:declaration: error with guidance to addnative:to thesubclass:declaration - Missing
.btfile: error listing the searched paths - Class with no
self delegatemethods: warning (generates a skeleton with only the catch-all clause)
Hex Dependencies
Packages that need hex.pm libraries (e.g., gun for HTTP, cowboy for servers) declare them in beamtalk.toml:
[package]
name = "http"
version = "0.1.0"
description = "HTTP client and server for Beamtalk"
[dependencies]
# Beamtalk package dependencies (if any)
[native.dependencies]
# Hex dependencies for native Erlang code
gun = "~> 2.1"
cowboy = "~> 2.12"
Version Constraint Syntax
Constraints follow hex.pm conventions:
| Constraint | Meaning |
|---|---|
"~> 2.1" | >= 2.1.0 and < 3.0.0 |
"~> 2.1.0" | >= 2.1.0 and < 2.2.0 |
">= 1.0.0 and < 2.0.0" | Explicit range |
"2.12.0" | Exact version |
How the Build Works
beamtalk build handles native Erlang compilation automatically using one of two paths, selected by whether [native.dependencies] is present:
Without hex deps — .erl files in native/ are compiled directly via compile:file/2. This is fast (~200ms) and requires no external tooling.
With hex deps — a rebar.config is generated from beamtalk.toml and rebar3 compiles both hex deps and native/*.erl files in a single invocation. Beamtalk bundles a pinned copy of rebar3, so no separate installation is needed.
The compilation order is always:
- Hex dependencies (if any)
- Native Erlang files in
native/ - Beamtalk
.btfiles insrc/
This ensures that native modules are available when .bt files reference them via FFI or native:.
Packages Without Native Erlang
A package can declare [native.dependencies] without a native/ directory — for example, to call a hex package directly via (Erlang module) FFI without any hand-written Erlang glue:
[native.dependencies]
jiffy = "~> 1.1"
Object subclass: FastJson
class parse: str => (Erlang jiffy) decode: str
class stringify: obj => (Erlang jiffy) encode: obj
Dependency Resolution
All native dependencies across the entire Beamtalk dependency graph are resolved together. beamtalk build collects [native.dependencies] from every transitive package and passes them to a single rebar3 invocation. Resolved versions are pinned in beamtalk.lock for reproducible builds.
This top-level resolution is required by BEAM's flat module namespace — only one version of any module can be loaded at a time. If two packages declare different constraints for the same hex package, rebar3's constraint solver finds a version satisfying both (or reports a conflict).
Compilation and Build Integration
Build Pipeline
beamtalk build
|
+-- Phase 1: Native Erlang
| Path A (no hex deps): compile:file/2 for each native/*.erl
| Path B (with hex deps): rebar3 compile (hex deps + native/*.erl)
|
+-- Phase 2: Beamtalk .bt files
Compile .bt -> .core -> .beam
Code path includes Phase 1 output
Testing Native Code
Native Erlang tests live in native/test/ and follow standard Erlang conventions:
beamtalk test
-> rebar3 eunit (native/test/*_tests.erl)
-> rebar3 ct (native/test/*_SUITE.erl, if present)
-> BUnit (test/*.bt)
EUnit modules should be named *_tests.erl and Common Test suites *_SUITE.erl. BUnit tests exercise the Beamtalk-facing API, while native tests cover the Erlang implementation directly.
REPL Hot-Loading
Native Erlang modules participate in workspace hot-loading:
:load_projectscansnative/alongsidesrc/, recompiling changed.erlfiles before.btfiles:reload ClassNamedemand-compiles the referenced native.erlfile if it is newer than its.beam
Both paths use compile:file/2 directly — no rebar3 invocation for interactive reloading.
Runtime Dependencies
All native Erlang modules in a Beamtalk package have an implicit dependency on beamtalk_runtime. Common runtime modules available to native code:
| Module | Purpose |
|---|---|
beamtalk_error | Structured error creation (#beamtalk_error{}) |
beamtalk_actor | sync_send/3 for actor dispatch |
beamtalk_object_ops | Object protocol operations |
beamtalk_result | ok/1, error/1 result wrapping |
This dependency does not need to be declared in beamtalk.toml — the build tool ensures the runtime is on the code path.
Practical Example: Creating a Native-Backed Class
This walkthrough creates a key-value store backed by an ETS table. ETS tables require Erlang for lifecycle management, and the actor owns the table (ETS tables die with their owner process), so Actor supervision gives table durability for free.
Step 1: Define the Beamtalk API
Create src/KeyValueStore.bt:
// Copyright 2026 James Casey
// SPDX-License-Identifier: Apache-2.0
/// A persistent key-value store backed by an ETS table.
///
/// ## Examples
///
/// ```beamtalk
/// store := KeyValueStore create
/// store put: #name value: "Alice"
/// store get: #name // => "Alice"
/// store keys // => #(#name)
/// store size // => 1
/// ```
Actor subclass: KeyValueStore native: kv_store
class create => self spawn
class create: name => self spawnWith: #{"name" => name}
get: key -> Object => self delegate
put: key value: value -> Nil => self delegate
delete: key -> Nil => self delegate
keys -> List => self delegate
size -> Integer => self delegate
Step 2: Generate the Erlang Skeleton
beamtalk generate native KeyValueStore
This produces kv_store.erl with handle_call/3 clauses for each delegate method. Move it to the native/ directory:
mkdir -p native
mv kv_store.erl native/
Step 3: Implement the Gen_Server
Edit native/kv_store.erl:
%% Copyright 2026 James Casey
%% SPDX-License-Identifier: Apache-2.0
%% @doc Backing gen_server for the KeyValueStore native Actor.
-module(kv_store).
-behaviour(gen_server).
-export([start_link/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
start_link(Config) ->
gen_server:start_link(?MODULE, Config, []).
init(Config) ->
Name = maps:get(<<"name">>, Config, undefined),
Tid = case Name of
undefined -> ets:new(?MODULE, [set, private]);
_ -> ets:new(binary_to_atom(Name), [set, private, named_table])
end,
logger:set_process_metadata(#{domain => [beamtalk, runtime]}),
{ok, #{tid => Tid}}.
%% Strip propagated context (ADR 0069 Phase 2b) — required boilerplate
handle_call({Selector, Args, PropCtx}, From, State) when is_map(PropCtx) ->
beamtalk_actor:restore_propagated_ctx(PropCtx),
handle_call({Selector, Args}, From, State);
handle_call({'get:', [Key]}, _From, #{tid := Tid} = State) ->
Result = case ets:lookup(Tid, Key) of
[{_, Value}] -> Value;
[] -> nil
end,
{reply, {ok, Result}, State};
handle_call({'put:value:', [Key, Value]}, _From, #{tid := Tid} = State) ->
true = ets:insert(Tid, {Key, Value}),
{reply, {ok, nil}, State};
handle_call({'delete:', [Key]}, _From, #{tid := Tid} = State) ->
true = ets:delete(Tid, Key),
{reply, {ok, nil}, State};
handle_call({keys, []}, _From, #{tid := Tid} = State) ->
Keys = [K || {K, _V} <- ets:tab2list(Tid)],
{reply, {ok, Keys}, State};
handle_call({size, []}, _From, #{tid := Tid} = State) ->
{reply, {ok, ets:info(Tid, size)}, State};
handle_call(_Request, _From, State) ->
{reply, {error, not_implemented}, State}.
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, #{tid := Tid}) ->
ets:delete(Tid),
ok.
Step 4: Write Tests
Create native/test/kv_store_tests.erl for EUnit tests of the Erlang implementation:
%% Copyright 2026 James Casey
%% SPDX-License-Identifier: Apache-2.0
-module(kv_store_tests).
-include_lib("eunit/include/eunit.hrl").
basic_test() ->
{ok, Pid} = kv_store:start_link(#{}),
{ok, nil} = gen_server:call(Pid, {'put:value:', [key, <<"hello">>]}),
{ok, <<"hello">>} = gen_server:call(Pid, {'get:', [key]}),
gen_server:stop(Pid).
Create test/KeyValueStoreTest.bt for BUnit tests of the Beamtalk API:
// Copyright 2026 James Casey
// SPDX-License-Identifier: Apache-2.0
TestCase subclass: KeyValueStoreTest
testPutAndGet =>
store := KeyValueStore create
store put: #name value: "Alice"
self assert: (store get: #name) equals: "Alice"
testSize =>
store := KeyValueStore create
self assert: (store size) equals: 0
store put: #a value: 1
self assert: (store size) equals: 1
Step 5: Build and Run
beamtalk build
beamtalk test
Quick Reference
When to Use Each Mechanism
| Need | Use |
|---|---|
| Call a stateless Erlang function | (Erlang module) FFI in method body |
Stateless Object delegating wholesale to one module | Object subclass: … native: module + self delegate |
| Wrap a few utility functions as class methods | Object subclass: with inline FFI calls |
| Actor with OTP gen_server control | Actor subclass: … native: module + self delegate |
| Actor with Beamtalk-only logic | Standard Actor subclass: (no native:) |
| Hex.pm package dependency | [native.dependencies] in beamtalk.toml |
| Port, NIF, or raw process | Per-method (Erlang module) FFI |
self delegate vs FFI in Native Actors
A native: class can mix both patterns:
Actor subclass: MyActor native: my_backing_module
// Delegates through gen_server — lifecycle-aware, timeout-aware
getData -> Object => self delegate
// Direct FFI call — bypasses gen_server for concurrent reads
cachedValue -> Object =>
(Erlang my_backing_module) directLookup: self
Use self delegate for operations that modify state or need gen_server guarantees. Use FFI for read-only operations that can safely bypass the gen_server mailbox (e.g., concurrent ETS reads).
Related Documentation
- Erlang FFI -- FFI syntax reference
- ADR 0055 -- Erlang-backed class authoring protocol
- ADR 0056 -- Native actors design decisions
- ADR 0072 -- User Erlang sources in packages
- ADR 0101 -- Unified interop:
native:for stateless Objects, wrap-by-default FFI