Architecture Principles
April 25, 2026 · View on GitHub
This document defines the core development architecture principles for the beamtalk compiler and runtime. These principles guide code organization, error handling, testing strategy, security practices, and dependency management.
Audience: AI agents, contributors, and maintainers working on the beamtalk codebase.
Table of Contents
- Layered Architecture & Dependency Rules
- Error Recovery Philosophy
- Testing Pyramid
- Security-First Development
- Dependency Management Philosophy
1. Layered Architecture & Dependency Rules
Principle
The beamtalk codebase is organized into layers with unidirectional dependencies. Dependencies flow downward only—higher layers depend on lower layers, never the reverse.
Layer Structure
┌─────────────────────────────────────┐
│ beamtalk-cli (binary) │ ← User-facing CLI tool
│ beamtalk-lsp (binary) │ ← IDE language server
│ beamtalk-mcp (binary) │ ← Model Context Protocol server
│ beamtalk-compiler-port (binary) │ ← Erlang compiler bridge
├─────────────────────────────────────┤
│ beamtalk-exec (library) │ ← REPL/execution engine
│ beamtalk-workspace (library) │ ← Workspace/session management
├─────────────────────────────────────┤
│ beamtalk-core (library) │ ← Compiler core (reusable)
│ ├─ queries/ (Language Service) │
│ ├─ parse/ (Lexer, Parser) │
│ ├─ analyse/ (Semantic Analysis)│
│ └─ codegen/ (Core Erlang gen) │
└─────────────────────────────────────┘
Rules
✅ ALLOWED:
beamtalk-clidepends onbeamtalk-corebeamtalk-lspdepends onbeamtalk-corequeriesdepends onparse,analyse,codegencodegendepends onparse(needs AST types)
❌ FORBIDDEN:
beamtalk-coreimportingbeamtalk-cliparseimportingcodegencodegenimportingqueries
Rationale
- Reusability - Core compiler logic is library, can be embedded elsewhere
- Testability - Library crates have no CLI/UI dependencies, easier to test
- Clear boundaries - Each layer has a well-defined responsibility
- LSP support - Language server and CLI both consume the same core
Examples
// ✅ GOOD - CLI depends on core
// crates/beamtalk-cli/src/main.rs
use beamtalk_core::parse::{lex, parse};
use beamtalk_core::codegen::generate_core_erlang;
// ❌ BAD - Core depends on CLI (NEVER DO THIS)
// crates/beamtalk-core/src/source_analysis/lexer.rs
use beamtalk_cli::repl::ReplContext; // ❌ WRONG!
Enforcement
Decision: Document only (no automated enforcement)
Rationale: Solo developer, code review sufficient. Can add cargo-deny later if team grows.
Action on violation: Flag in code review, refactor immediately.
2. Error Recovery Philosophy
Principle
Compiler errors are not exceptional—they're expected user input. The compiler should collect all errors, report them clearly, and provide partial results when possible.
Core Concepts
- Never panic on user input - Malformed source code, invalid arguments, missing files should return diagnostics, not crash
- Collect all errors - Don't stop at the first error, find as many as possible in one pass
- Partial results with diagnostics - Return a usable AST even if there are syntax errors
- Error messages are user-facing documentation - Clear, actionable, with fix suggestions
Error Handling Strategy (Hybrid Approach)
For user input (source code, CLI args):
// ✅ GOOD - Return partial results + diagnostics
pub fn parse(tokens: Vec<Token>) -> (Module, Vec<Diagnostic>)
pub fn compute_diagnostics(module: &Module, parse_diagnostics: Vec<Diagnostic>) -> Vec<Diagnostic>
For programmer errors (internal bugs, unreachable states):
// ✅ GOOD - Use Result for recoverable errors
pub fn read_source_file(path: &Path) -> Result<String, std::io::Error>
// ✅ GOOD - Panic for unreachable states (bugs)
match token.kind {
TokenKind::Identifier => { /* ... */ }
TokenKind::Number => { /* ... */ }
_ => unreachable!("parser bug: unexpected token in literal position"),
}
Examples
// ✅ GOOD - Parser continues after errors
pub fn parse_expression(&mut self) -> Expression {
match self.current_token() {
TokenKind::Identifier => self.parse_identifier(),
TokenKind::LeftParen => self.parse_parenthesized(),
_ => {
// Error recovery: return error node, continue parsing
self.diagnostics.push(Diagnostic::error(
"expected expression",
self.current_span(),
));
self.advance(); // Skip bad token
Expression::Error(ErrorNode { span: self.current_span() })
}
}
}
// ❌ BAD - Stop at first error
pub fn parse_expression(&mut self) -> Result<Expression, ParseError> {
match self.current_token() {
TokenKind::Identifier => Ok(self.parse_identifier()),
_ => Err(ParseError::ExpectedExpression), // ❌ Stops parsing
}
}
Error Message Quality
Error messages should:
- Highlight the exact span (byte-accurate)
- Explain why it's invalid (not just "syntax error")
- Suggest a fix (when possible)
Example:
error: cannot assign to field 'sum' inside a stored closure
--> test.bt:12:21
|
12 | myBlock := [:item | self.sum := self.sum + item]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= help: field assignments require immediate execution context
= help: use control flow directly: items do: [:item | self.sum := self.sum + item]
Rationale
- Better developer experience - See all errors at once, not one at a time
- IDE support - Partial AST enables completions even with syntax errors
- Robustness - Compiler never crashes on bad input
- Incremental compilation - Can continue working on other parts of the file
3. Testing Pyramid
Principle
The beamtalk test suite follows the testing pyramid pattern: many fast unit tests at the base, fewer slower integration tests in the middle, and a small number of end-to-end tests at the top.
Test Layers
╱ ╲ REPL-Protocol Tests (tests/repl-protocol/cases/*.btscript)
╱ ╲ - Real Beamtalk → BEAM execution via REPL TCP
╱ 10% ╲ - Slow, high confidence
╱───────╲ - User-facing scenarios
╱ ╲
╱ 20-30% ╲ Integration Tests (runtime/apps/*/test/*.erl)
╱ ╲ - Multiple units working together
╱───────────────╲ - Codegen + runtime simulation
╱ ╲
╰───────────────────╯ Unit Tests (#[cfg(test)] mod tests)
60-70% - Fast, isolated, high coverage
- Every public function tested
Unit Tests
Location: #[cfg(test)] modules in the same file as the code
Characteristics:
- Fast (<1ms per test)
- Isolated (no file I/O, no network, no external processes)
- Focused on single function/module
- Cover edge cases and error conditions
Coverage target: 70-80% overall, 80-90% for critical paths
Example:
// crates/beamtalk-core/src/source_analysis/lexer.rs
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lex_identifier() {
let tokens = lex("myVariable");
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].kind, TokenKind::Identifier("myVariable".into()));
}
#[test]
fn lex_empty_input() {
let tokens = lex("");
assert_eq!(tokens.len(), 0);
}
#[test]
fn lex_invalid_character() {
let (tokens, diagnostics) = lex("@invalid");
assert!(!diagnostics.is_empty());
}
}
Integration Tests
Location: runtime/apps/*/test/*_tests.erl (EUnit tests in Erlang)
Characteristics:
- Medium speed (10-100ms per test)
- Test multiple units working together
- Codegen + runtime interaction
- Use compiled test fixtures
Examples:
beamtalk_actor_tests.erl- Actor spawn, message passing, statebeamtalk_codegen_simulation_tests.erl- Compiled.btmodules running in runtime
REPL-Protocol Tests
Location: tests/repl-protocol/cases/*.btscript (real Beamtalk source files)
Characteristics:
- Slow (100ms-1s per test)
- Full pipeline: source → lexer → parser → codegen → erlc → BEAM → execute
- Test user-facing scenarios
- Verify expected output with
// =>assertions
Example:
// tests/repl-protocol/cases/arithmetic.btscript
42 + 3
// => 45
2 * 3 + 4
// => 10
Testing Best Practices
- Test edge cases first - Nulls, empty strings, zero values, boundary conditions
- Test error conditions - Invalid input, out-of-bounds, parse errors
- Prefer deterministic tests - No random values, no timing dependencies
- Don't test implementation details - Test behavior, not internal structure
- One assertion per test - Makes failures clear
- Descriptive test names -
test_parse_empty_blocknottest_parse_1
Test Priorities
When adding new features:
- Write unit tests for individual functions
- Write integration test for the feature end-to-end
- Write E2E test for user-facing scenario (if applicable)
When fixing bugs:
- Add regression test that reproduces the bug
- Fix the bug
- Verify test passes
4. Security-First Development
Principle
Treat all user input as untrusted. The compiler processes arbitrary source code, so robust input validation and error handling are critical.
Core Concepts
- Untrusted input = user source code - Never assume well-formed input
- Input validation at boundaries - Validate early, fail gracefully
- No unsafe code without justification - Rust's safety guarantees are valuable
- Dependency audits - Regularly check for vulnerabilities
- Fuzz testing - Find crashes from malformed input
Rules
✅ ALWAYS:
- Check buffer boundaries (no out-of-bounds access)
- Handle parse errors gracefully (no panics)
- Document unsafe code with safety invariants
- Run
cargo auditbefore releases
❌ NEVER:
- Execute user-provided code without sandboxing
- Trust file extensions (validate content)
- Panic on malformed input
- Use
unwrap()on user input - Add dependencies without security review
Examples
// ✅ GOOD - Validate and handle errors
pub fn read_source_file(path: &Utf8Path) -> Result<String, std::io::Error> {
// Validate path is not trying to escape
if path.as_str().contains("..") {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"path traversal not allowed",
));
}
std::fs::read_to_string(path)
}
// ❌ BAD - Unsafe without justification
pub fn get_token(&self, index: usize) -> &Token {
unsafe { self.tokens.get_unchecked(index) } // ❌ Why unsafe? Justify!
}
// ✅ GOOD - Justified unsafe with documented invariants
pub fn get_token(&self, index: usize) -> &Token {
// SAFETY: Caller guarantees index < self.tokens.len()
// This is enforced by the parser state machine which never
// advances past the end of the token stream.
unsafe { self.tokens.get_unchecked(index) }
}
Dependency Security
Audit all new dependencies:
# Check for known vulnerabilities
cargo audit
# Check licenses and security policies
cargo deny check
Decision: Manual fuzzing (set up infrastructure, run before releases)
Rationale: Fuzzing finds crashes from malformed input, but continuous fuzzing adds CI complexity. Manual runs before releases are sufficient for current maturity level.
Security Checklist
When reviewing code:
- User input validated at boundaries
- No panics on malformed input
- File I/O errors handled gracefully
- Unsafe code documented with SAFETY comment
- Dependencies audited (run
cargo audit) - Buffer accesses bounds-checked
5. Dependency Management Philosophy
Principle
Minimize dependencies. Prefer the standard library. Document every dependency.
Core Concepts
- Prefer std library - Only add dependencies when significantly beneficial
- No trivial dependencies - Don't add a crate for
is_evenormax - Vetted dependencies only - High downloads, active maintenance, good reputation
- Pin major versions - Use
"1.2"not"*"or"1" - Audit new dependencies - Security, license, maintenance status
- Document why - Commit message explains why dependency was added
Decision Criteria
Add a dependency when:
- Reimplementing would be complex and error-prone (e.g.,
miettefor diagnostics) - Significantly improves developer experience (e.g.,
instafor snapshot tests) - Provides critical functionality not in std (e.g.,
tracingfor structured logging) - Well-maintained, widely-used, aligns with Rust ecosystem standards
Don't add a dependency when:
- Functionality is trivial to implement (few lines of code)
- Only using 10% of the crate's features
- Unmaintained or low download count
- License incompatibility
- Increases compile time significantly for minor benefit
Examples
# ✅ GOOD - Documented dependencies
[dependencies]
# Efficient copy-on-write strings for AST (reduces allocations)
ecow = "0.2"
# UTF-8 paths for cross-platform consistency (avoids encoding issues)
camino = "1.1"
# Rich error diagnostics with source spans (LSP-quality error messages)
miette = "7.6"
# Structured logging for debugging (better than println!)
tracing = "0.1"
# ❌ BAD - Undocumented or questionable dependencies
some-random-crate = "0.1" # What does this do? Why?
is-even = "1.0" # Trivial: use `n % 2 == 0`
unmaintained-thing = "2.0" # Last updated 3 years ago
Dependency Review Process
When adding a new dependency:
- Check crates.io - Download count, last updated, version
- Check GitHub - Active development, issue response time, security policy
- Check license - Apache-2.0/MIT compatible
- Run audit -
cargo auditto check for known vulnerabilities - Document in commit - Explain why the dependency is needed
Example commit message:
feat: add miette for rich diagnostics
Add miette crate for LSP-quality error messages with source spans,
fix suggestions, and beautiful terminal output. Miette is widely
used in the Rust ecosystem (2M+ downloads) and provides features
that would take weeks to implement ourselves.
Alternative considered: Implement custom diagnostic rendering.
Rejected because miette is well-tested and LSP-compatible.
Keeping Dependencies Updated
Regular maintenance:
# Check for outdated dependencies
cargo outdated
# Update to latest compatible versions
cargo update
# Run tests after updating
cargo test --all-targets
Before releases:
# Security audit
cargo audit
# License and security policy check
cargo deny check
Current Dependencies Rationale
Core dependencies (beamtalk-core):
ecow- Copy-on-write strings, reduces AST memory usagecamino- UTF-8 paths, avoids Windows encoding issuesmiette- Rich diagnostics, LSP-quality error messagesthiserror- Error type derive macros, reduces boilerplate
Development dependencies:
insta- Snapshot testing, perfect for codegen teststracing- Structured logging, better than println debugging
Runtime dependencies (Erlang side):
- Minimal - Erlang/OTP standard library only
Cross-References
- Domain-Driven Design: AGENTS.md - Domain-Driven Design section
- Rust Best Practices: AGENTS.md - Rust Development Best Practices
Questions or Clarifications?
If these principles are unclear or don't address a specific scenario, open a Linear issue with the Documentation label.
Last updated: 2026-02-03