ADR 0005: BEAM Object Model - Pragmatic Hybrid Approach

Status

Implemented (2026-02-08) — Epic BT-162

Context

Beamtalk aims to bring Smalltalk's "everything is an object" philosophy to the BEAM virtual machine. However, BEAM's architecture fundamentally differs from traditional Smalltalk VMs:

This creates tension: Should we try to emulate Smalltalk's object model fully (slow, complex) or embrace BEAM's strengths while accepting limitations?

Key constraints:

  1. BEAM processes are isolated with separate heaps (no global memory access)
  2. No runtime stack frame manipulation or continuations
  3. Cannot swap object identity (become:) without rebuilding entire process state
  4. Immutable data means copying, not mutation, for value types

Inspiration: LFE Flavors by Robert Virding successfully implements OOP on BEAM using a pragmatic approach. Flavors proves that process-based objects with error isolation and method combinations work well on BEAM.

Decision

Adopt a pragmatic hybrid approach: Embrace BEAM's actor model rather than fight it. Reify what we can efficiently (classes, methods, blocks, processes) and explicitly document what we cannot (active stack frames, become:, global reference scanning).

Core Design Principles

  1. "Everything is a process" aligns with "everything is an object" - We shift reification from memory-level objects to process-level actors
  2. Value types vs Actors - Distinguish between heap-allocated values (Point, Color) and process-based actors (Counter, Server)
  3. Sealed primitives - Integer, String, Float, Boolean are sealed value types that cannot be subclassed
  4. Uniform message-sending syntax - All method calls use the same syntax, compiler chooses implementation (inline call vs process message)

What We Support

Smalltalk FeatureBEAM SupportBeamtalk Implementation
Classes as objects✅ FullMaps with metaclass protocol, registered via process registry
Methods as objects✅ FullWrapped funs with metadata (selector, arity, source)
Blocks as closures✅ FullErlang funs (first-class, can capture variables)
Objects with identity✅ FullActors: processes with pids + #beamtalk_object{} record
Values: tuples/maps copied by value
doesNotUnderstand:✅ FullGen_server handle_call fallback, structured errors
Method combinations✅ FullBefore/after methods (inspired by Flavors)
Error isolation✅ FullCatch at instance, re-raise at caller with context
Reflection✅ Fullclass, respondsTo:, allInstances (ETS tracking)

What We Don't Support

Smalltalk FeatureBEAM LimitationWorkaround
Stack frames/thisContextNo runtime stack accessPost-exception stack traces only
become: (identity swap)Cannot replace process state atomicallyProxy pattern, manual migration
Global reference scan (pointersTo)No shared heapManual tracking via ETS registry
ContinuationsNot available on BEAMUse futures/promises for async control flow
Direct slot access (instVarAt:)Violates gen_server encapsulationPrimitives error, actors disallowed. Named field access via fieldAt:/fieldAt:put: provided instead — see ADR 0035
Changing object's classCannot hot-swap gen_server behaviorRequires process restart

Class Hierarchy

ProtoObject (minimal - identity, DNU)
  └─ Object (common behavior - nil testing, printing, reflection)
       ├─ Integer      (primitive - sealed, no process)
       ├─ Float        (primitive - sealed, no process)
       ├─ String       (primitive - sealed, no process)
       ├─ Boolean      (primitive - sealed, no process)
       ├─ Array, List  (primitive - sealed, no process)
       ├─ Point, Color (user value types - no process)
       └─ Actor        (process-based - has pid, mailbox)
            └─ Counter, MyService (user actors)

Value types (Object subclasses):

Actors (Actor subclasses):

Implementation Strategy

Compile-time codegen (not runtime interpretation):

Error handling:

Reflection and introspection:

Consequences

Positive

  1. Leverage BEAM strengths: Massive concurrency, distribution, fault tolerance, hot code reloading
  2. Performance: Direct function calls for primitives, no interpreter overhead
  3. Proven approach: LFE Flavors validates this design works in production
  4. Clear mental model: "Value types vs actors" is familiar to modern developers (Swift, Rust)
  5. Erlang interop: Seamless integration with Erlang/Elixir libraries
  6. Error isolation: Process crashes don't affect other objects (Smalltalk debugger requires manual handling)

Negative

  1. Not Smalltalk-compatible: Cannot run Smalltalk code without modification
  2. Limited metaprogramming: No become:, thisContext, or continuations
  3. Manual tracking: allInstances and reference finding require explicit registration
  4. Learning curve: Developers must understand value vs actor distinction
  5. Migration cost: Converting value types to actors requires code changes

Neutral

  1. Trade-off accepted: We gain BEAM's strengths at the cost of some Smalltalk metaprogramming features
  2. Documentation burden: Must clearly explain what works and what doesn't (this ADR helps)
  3. Future extensions: Could add more Smalltalk features (e.g., better image snapshots) if BEAM capabilities improve

Alternatives Considered

Four approaches were evaluated in detail (see Appendix A for the underlying BEAM constraints):

Option 1: Pragmatic Hybrid (Selected)

Embrace BEAM's actor model. Reify classes, methods, blocks, and processes. Accept limitations on stack frames, become:, and global reference scanning.

Option 2: Meta-Circular Interpreter

Build a Smalltalk-like VM on top of BEAM — interpret bytecodes, manage own heap, implement stack frames.

Option 3: Dual-Mode Execution

Compile to native BEAM for normal execution, switch to interpreter mode for metaprogramming operations that need stack access.

Option 4: CPS Transformation

Use continuation-passing style to make stack frames explicit and capturable.

Why Pragmatic Hybrid Won

The meta-circular interpreter loses BEAM's core value proposition. Dual-mode is too complex to maintain. CPS solves one problem at high cost. The pragmatic hybrid accepts real limitations but delivers the best developer experience on BEAM.

References

Open Questions

  1. ProtoObject vs Object boundary DECIDED: Follow Pharo's split. ProtoObject: class, ==, /=, doesNotUnderstand:args:. Object: nil testing (isNil, notNil, ifNil:ifNotNil:), reflection (respondsTo:, fieldNames, fieldAt:, fieldAt:put:, perform:, perform:withArguments:), display (printString, printOn:, inspect, describe), other (yourself, hash, new). Follows proven Smalltalk convention — no reinvention needed. (Note: originally used instVarNames/instVarAt:/instVarAt:put: — renamed per ADR 0035.)
  2. Metaclass protocol DECIDED: Commit to full Smalltalk metaclass model as the target. Phase 1 (current): class objects understand a fixed protocol via beamtalk_object_class.erl (methods, superclass, name, new/spawn). Class method sends (e.g., Integer methods) route through gen_server:call to the class process — not direct module calls. The ClassReference AST node already resolves to a #beamtalk_object{} wrapping the class pid; codegen just needs to emit gen_server:call(ClassPid, {Selector, Args}) instead of call 'module':'method'(). Phase 2 (future): real metaclass hierarchy mirroring instance side, with class-side method inheritance through the metaclass chain.
  3. Extension methods DECIDED: Extensions are logically part of the class's method dictionary, checked during the hierarchy walk — not a separate lookup step. Matches Pharo's model where extensions are regular methods. For sealed primitives (where we can't modify the compiled module), the runtime checks the extension registry (beamtalk_extensions.erl) as part of that class's method lookup before walking to the superclass. Extensions on Integer take priority over inherited Object methods, same as Pharo.
  4. #beamtalk_object{} record DECIDED: Core object model decision. Actors are wrapped in {beamtalk_object, Class, ClassMod, Pid} — enables dispatch (extract pid → gen_server:call), follows LFE Flavors' #flavor-instance{} pattern. Value types (including primitives) are bare Erlang terms with no wrapper — dispatch must know receiver type at compile time. Trade-offs: free Erlang interop for value types, but you can't inspect a value type's class at runtime without compiler support.
  5. Object identity and equality DECIDED: Follow ADR 0002 — use Erlang operators. == is value equality, === is exact equality. For actors: two references to the same pid are == (records compare by value). For value types: 42 == 42 is true (value comparison). Identity and equality collapse for actors (same pid = same identity = same value). Consistent across all object types, no special cases.
  6. Nil DECIDED: nil is the Erlang atom 'nil', class is UndefinedObject, dispatched through beamtalk_nil.erl. Sealed primitive singleton value type. Follows Pharo's model. Already implemented.
  7. Blocks as message receivers DECIDED: Blocks are Erlang funs dispatched through beamtalk_block.erl — same pattern as other primitives. They respond to value, value:, value:value:, class, respondsTo:, perform:, etc. No gen_server needed — dispatch is a direct function call. Already implemented.
  8. Single dispatch DECIDED: Beamtalk uses single dispatch (method lookup based on receiver only). Follows Smalltalk, matches BEAM's gen_server model. Behavior composition across unrelated classes uses mixins/traits (see Q9) — the modern approach (Rust, Swift, Kotlin, Pharo). No plans for multiple dispatch.
  9. Mixins/traits: Deferred to a future ADR. We commit to single dispatch + a composition mechanism (traits, mixins, or similar) for sharing behavior across unrelated classes. Design must work well on BEAM — may go beyond Erlang behaviours. Needs its own ADR to explore Pharo traits, Newspeak mixins, and BEAM-specific options. Tracked as BT-274.
  10. self and super semantics DECIDED: Follow Smalltalk. self always refers to the enclosing object — in actors it's the #beamtalk_object{} record, in value types it's the value itself, in blocks it's the enclosing method's self (lexical capture). super refers to the enclosing class's superclass, never the block. Already implemented via state threading in codegen.

Appendix A: Why the Hard Parts Are Hard

Detailed analysis of Smalltalk features that conflict with BEAM's architecture. Each explains why the feature can't be directly supported and what workarounds exist.

Stack Frames and thisContext

Smalltalk's thisContext gives access to the executing stack frame as a first-class object, enabling debugger implementation, non-local returns, exception restart, and continuation capture. BEAM cannot support this because: (1) no reified stack — BEAM uses registers and a non-inspectable call stack, (2) aggressive tail call optimization eliminates frames, (3) per-process isolation means no global stack introspection.

What we can do: Post-exception stack traces via erlang:get_stacktrace(), current function/module via macros, but no mid-execution stack inspection or continuation capture.

Use CaseSmalltalkBeamtalk
Post-mortem debugging✅ Stack traces available
Step debugger⚠️ Via tracing, not stack manipulation
Exception restart⚠️ Partial via conditions/restarts
Continuation capture❌ Not possible

become: (Object Identity Swapping)

Smalltalk's become: swaps all references globally — used for object migration, proxy replacement, and persistence. BEAM can't do this: pids are immutable, there's no global heap to scan, and no pointer indirection for transparent swapping.

Workarounds: Proxy pattern with doesNotUnderstand: delegation, named registry for replaceable references, and BEAM's code_change/3 for schema evolution. The registry pattern is more explicit than become: but arguably cleaner for distributed systems.

Global Reference Scanning (pointersTo, allInstances)

Smalltalk can scan the entire heap. BEAM has per-process heaps with no system-wide object graph. Workaround: Explicit instance tracking via ETS, which is actually more efficient than heap scanning for large systems. Process monitors provide lifecycle notifications superior to weak references.

Image Snapshots

Beamtalk follows BEAM's no-image model: code lives in files, state in running processes. For distributed systems this is superior — Mnesia/DETS handle distributed persistence, text source files enable version control, and per-actor persistence gives selective control. The no-image model is a feature, not a limitation.

Direct Memory Slot Access and Class Changing

Positional slot access (instVarAt: 1) is replaced by named field access (safer, more maintainable). Changing an object's class requires process restart with state migration — the explicit restart pattern provides clear upgrade boundaries for distributed systems.

Appendix B: Lessons from LFE Flavors

LFE Flavors by Robert Virding is the closest prior art — a successful OOP implementation on BEAM. Key design patterns and our adoption decisions:

Flavors PatternBeamtalk AdoptionNotes
gen_server per instance✅ YesSame approach — each actor is a process
Two modules per class❌ NoSingle module per class; class process uses beamtalk_object_class.erl
Process dictionary for ivars❌ NoUse map in gen_server state (more explicit, inspectable)
Error catching at caller✅ YesErrors caught at instance, re-raised at caller with context
Synchronous by default❌ NoBeamtalk is async-first (futures), explicit await for sync
Instance handle record✅ Yes#beamtalk_object{class, module, pid} wraps actors

Key insight: Flavors validates that OOP semantics work well on BEAM when each object is a process, instance variables are maps, methods dispatch via a method table, and errors are isolated. Beamtalk adds async-first futures, full compile-time Rust codegen, ETS-based instance tracking, and Smalltalk-style reflection.