Skip to content

How Modules Connect: Behaviour-Based Dependency Injection

The Problem

Gleam has no traits, protocols, or interfaces. Core modules need to call services and plugins, but the project's import policy forbids core/ from importing directly from services/ or plugins/. This keeps core decoupled as a pure wireframe -- but core still needs to call LLMClient.chat(), SessionStore.load_session(), Guardrail.check(), and so on.

The Solution

Records of function fields serve as structural contracts. Each one is a behaviour record -- a custom type defined in core/behaviours/behaviours.gleam whose fields are function references. Every service or plugin that wants to connect to core exposes a constructor function that returns a populated behaviour record. Core modules accept these records as parameters and call the functions on them.

The Pattern (Step by Step)

1. Define the behaviour type in core

In core/behaviours/behaviours.gleam:

gleam
pub type SessionStore {
  SessionStore(
    load_session: fn(Connection, String) -> Result(Option(#(SessionRow, List(Message))), String),
    create_session: fn(Connection, String, String, String) -> Result(String, String),
    append_message: fn(Connection, String, Message) -> Result(Nil, String),
    // ... 16 functions total
  )
}

2. Implement the real logic in a service

In services/storage/session_db.gleam:

gleam
pub fn load_session(conn: Connection, session_key: String)
  -> Result(Option(#(SessionRow, List(Message))), String) {
  // Real SQLite queries here
}

3. Export a constructor via a companion *_behaviour.gleam module

In services/storage/session_db_behaviour.gleam:

gleam
import core/behaviours/behaviours.{type SessionStore, SessionStore}
import services/storage/session_db

pub fn session_store() -> SessionStore {
  SessionStore(
    load_session: session_db.load_session,
    create_session: session_db.create_session,
    append_message: session_db.append_message,
    // ...
  )
}

The companion module exists to avoid import cycles: core/behaviours/ must not import services/, so the construction step needs its own module inside services/.

4. Core modules accept the behaviour record as a parameter

In core/loop/orchestrator.gleam (the pure conversation loop):

gleam
import core/behaviours/behaviours.{type SessionStore}

pub fn loop(
  ctx: LoopContext,  // LoopContext contains session_store: SessionStore
  // ...
) -> LoopResult {
  case ctx.session_store.load_session(conn, session_key) {
    Ok(Some(#(row, messages))) -> // ...
  }
}

5. Wiring happens at startup

In agent_supervisor.gleam:

gleam
// Construct all behaviour records
let log = logger_behaviour.logger()
let session_store = session_db_behaviour.session_store()
let guardrail = guardrails_behaviour.guardrail()
let db_connector = db_behaviour.db_connector()

// Pass them into the gateway supervisor (which passes them further down)
gateway_supervisor.start(log, conn, completions_client, ..., session_store, guardrail, db_connector, ...)

The Complete Wiring Flow

agent_supervisor.gleam
  |-- constructs: Logger, SessionStore, Guardrail, DbConnector
  |-- passes to --> plugins/gateways/supervisor.gleam
  |                   |-- builds GatewayConfig with Logger, SessionStore, Guardrail, DbConnector
  |                   |-- starts Telegram gateway plugin
  |
  |-- passes to --> services/supervisor/supervisor.gleam
                      |-- constructs: LLMClient, MemoryStore
                      |-- starts: Pulse, Cron (each gets LLMClient, MemoryStore, DbConnector)
                      |-- initializes: Harness, Notifications

Per-request wiring (gateway handler or CLI loop):
  agent.gleam
    |-- constructs: ContextBuilder, TokenEstimator, CompressorHook, GuardrailHook
    |-- passes into --> core/loop/runner.gleam (composition root)
          |-- builds LoopContext {
          |     llm_client, session_store, memory_store, guardrail,
          |     logger, context_builder, token_estimator,
          |     compressor_hook, guardrail_hook, db_connector, ...
          |   }
          |-- passes to --> core/loop/orchestrator.gleam (pure conversation loop)

All 13 behaviour records are constructed at the top level and threaded down. Each layer only constructs what it needs and passes the rest through.

All 13 Behaviour Records

#BehaviourPurpose
1LLMClientChat completions, retry with jittered backoff, error classification. chat: fn(Client, List(Message), List(ToolDefinition)) -> Result(ChatResponse, ClientError), chat_with_retry, is_retryable, should_compress
2SessionStore16 CRUD functions for session persistence: load_session, create_session, append_message, touch_session, end_session, generate_id, load_session_row, set_session_title, list_sessions, search_messages, find_session_for_message, resolve_root_session, load_messages_range, list_sessions_rich, delete_session, export_session_json, find_session_by_title, list_recently_active, prune_sessions
3MemoryStoreMemory CRUD: format_for_system_prompt, add_entry, get_entries, replace_entry, remove_entry, usage_string
4GuardrailCommand safety classification: check (classifies commands), classify_risk, strip_ansi, is_approved_pd, consume_approval_pd, cache_approval_pd
5LoggerStructured logging: debug, info, warn, error -- each takes (LogCategory, String, String)
6ContextBuilderSystem prompt assembly: build_tool_continuation, system_prompt
7TokenEstimatorToken counting via character-based heuristics: estimate_request, context_usage_percent, context_window_warning
8CompressorHookHistory summarization: should_compress, split_history, build_summary_prompt, apply_compression, is_cooling_down, record_success, record_failure
9GuardrailHookTool-loop circuit breaker: fresh, record_success, record_failure, escalation, block_tool, halt
10ReflectionHookPost-turn review: should_reflect, spawn_review
11DndCheckerDo-not-disturb rules: is_quiet_now, list_active, add_rule, toggle_indefinite, remove_all
12CronStoreCron job persistence: list_enabled, list_all, upsert, delete, touch
13DbConnectorSQLite lifecycle: open, close, checkpoint, integrity_check, migrate

Constructor Modules

Constructor moduleReturns
services/storage/db_behaviour.gleamDbConnector
services/storage/session_db_behaviour.gleamSessionStore
services/storage/memory_db.gleamMemoryStore
services/guardrails/guardrails_behaviour.gleamGuardrail
services/logger/logger_behaviour.gleamLogger
services/context/context.gleamContextBuilder
services/tokens/tokens.gleamTokenEstimator
services/notifications/dnd_behaviour.gleamDndChecker
services/storage/cron_db.gleamCronStore
plugins/hooks/context_compressor/context_compressor_behaviour.gleamCompressorHook
plugins/hooks/tool_guardrails/tool_guardrails_behaviour.gleamGuardrailHook
plugins/hooks/reflection/reflection.gleamReflectionHook

Note

The LLMClient is the only behaviour without a dedicated companion *_behaviour.gleam module. Its constructor llm_client() lives directly in services/api/openai/completions.gleam -- the same module that implements the chat logic.

Built with Gleam on the BEAM/Erlang VM.