Station X2 · Bilingual extensionOpen the adventure map
Two-language partners
Calling each other inside one BEAM is easy. Agree on the exchanged data first.
Between an Elixir API and an Erlang worker, how do we fix the text, data-shape, and error protocol so record or Unicode changes do not spread?
One field was added to a record, and Elixir's positional reads all broke
Erlang changes a private record while Elixir keeps reading tuple positions. The boundary also mixes binaries and charlists, so one small change spreads into many failures.
- Raw boundary terms and guards
- Unicode round-trip tests for Chinese text, emoji, and empty text
- Bilingual contract tests for success, business errors, and process exits
The problem it solves
The two languages can call each other directly inside one BEAM, but text, records, and structs have different shapes. Keep conversions at the boundary and agree on success and failure terms.
You will be able to
Call Erlang from Elixir and call Elixir from Erlang with the correct module atom
Convert Elixir Strings, binaries, and charlists explicitly at the boundary
Turn exceptions and differing returns into stable results that both sides can pattern match
- Finish the shared-terms station and know that
:okandokname the same atom - Use Mix and Rebar3 to run a small project and its tests
See what it does before how it is written
Keep conversion at the edge. Use tagged tuples in the core protocol so charlists do not spread through the project.
# The Elixir API validates input and converts boundary data
defmodule Scheduler do
@spec submit(binary(), map()) ::
{:ok, reference()} | {:error, atom()}
def submit(queue, payload) when is_binary(queue) do
# The Erlang worker expects a charlist and a key-value list
:job_worker.submit(
String.to_charlist(queue),
Map.to_list(payload)
)
catch
# Turn an exit across the boundary into a stable error tuple
:exit, reason -> {:error, normalize_exit(reason)}
end
endWhy this shape?
Why add an adapter layer?
An adapter translates private records or structs into stable terms, keeping change at one boundary.
It adds a small layer now and prevents one internal field change from spreading through every caller.
Coming from Java, Python, or JavaScript
Java
- Familiar starting point
- Adapters and DTOs between modules or JVM languages.
- What BEAM changes
- The adapter translates records, structs, binaries, and error tuples.
- False friend
- Do not read a private record by tuple position.
Python
- Familiar starting point
- A boundary function translating third-party objects into dataclasses or dicts.
- What BEAM changes
- Stable terms give two BEAM languages one protocol.
- False friend
- Translate exceptions at the adapter as well.
JavaScript
- Familiar starting point
- An API schema or anti-corruption layer.
- What BEAM changes
- The boundary may stay inside one VM and still deserves a fixed shape.
- False friend
- Internal convenience is not a public contract.
Why do the two versions of jobs not match?
First pass an Elixir String to a worker that accepts only a charlist. Then convert it in an adapter and add tests.
- 01
Pass
"jobs"from Elixir and record what the guard or function clause tells you - 02
Use
String.to_charlist/1in the adapter before calling the Erlang worker - 03
Add a round-trip test with a non-ASCII queue name and confirm that the text stays unchanged
# Run only the ExUnit file for the language boundary
mix test test/interoperability_test.exs- A binary and a charlist match different guards
- After an explicit boundary conversion, both sides meet the agreement
- A Unicode test catches code that wrongly treats text as single bytes
Make Elixir read an Erlang record by tuple position, then add a field to the record. Watch the coupling break.
Explicit text conversion at the boundary keeps the String, binary, and charlist agreement consistent.
One successful path cannot show that every success, failure, and edge return from a third-party library uses the same text format.
Key ideas in this code
charlist
A list of integers that are character code points, common in Erlang APIs. An Elixir String is usually a UTF-8 binary.
record
Erlang expands a record into a tuple at compile time. Across languages, use public functions or maps instead of depending on tuple positions.
exception boundary
The agreement for exceptions, exits, and throws in both languages. The boundary turns them into stable error tuples.
Design patterns used here
Adapter
Centralize text, record, struct, and error conversions in one boundary module.
Anti-Corruption Layer
Keep one language's private representation out of the other side's core code.
Data Transfer Object
Exchange simple, stable tagged terms or maps.
Which return agreement is usually most stable for long-term work between the two languages?
Share one task queue
Let Elixir provide the API and validation while an Erlang gen_server owns the queue. Tests must truly cross the language boundary.
Hint 1Take the first step
Write a few concrete success and failure terms before building the modules.
Hint 2Make it a little smaller
Keep all text and error conversion inside one thin adapter module.
Hint 3You are close
Let ExUnit call the Erlang worker, and let EUnit call the Elixir normalize function.
Hint 4Work backward from the finish
Choose one success signal and write the smallest test for it. If the computer cannot show the result, rewrite the signal as something you can truly observe.
Each language has one clear, real responsibility
Binary, charlist, and exception conversions stay at the boundary
Two-way tests fail at once if either side breaks a return shape
Remember three things
- 1
Sharing BEAM makes calls easy. A clear data agreement keeps long-term cooperation easy too.
- 2
Text forms, records, and exceptions cause the most confusion and need careful boundary handling.
- 3
An adapter should be thin, focused, and genuinely tested from both directions.