Tutorial: Context System
Verum's context system replaces the usual soup of global state, thread-locals, and dependency-injection frameworks with a typed, explicit, compile-time-checked mechanism.
This tutorial takes a function written in the "implicit globals" style and refactors it step by step. By the end you'll have a clean function whose dependencies are all in the signature — and tests that don't need any mocking framework.
Time: 30 minutes.
Prerequisites: Hello, World.
The starting point — a familiar mess
// BAD CODE — illustrative only
static mut GLOBAL_LOGGER: Maybe<Shared<Logger>> = Maybe.None;
static mut GLOBAL_DB: Maybe<Shared<Database>> = Maybe.None;
fn order_total(order_id: Int) -> Float {
// implicit globals:
let logger = unsafe { GLOBAL_LOGGER.clone().unwrap() };
let db = unsafe { GLOBAL_DB.clone().unwrap() };
logger.info(f"computing total for order {order_id}");
let order = db.find_order(order_id).unwrap();
let total = order.items.iter().map(|i| i.price * i.quantity).sum();
logger.info(f"order {order_id} total = {total}");
total
}
Problems:
static mut— implicit state, hidden from the signature, hostile to testing.unsafeto access it — the compiler has no idea what's happening..unwrap()— if the globals aren't initialised, the function panics at runtime.- Testing requires a global reset — every test might clobber state.
Let's fix it.
Step 1 — Identify the dependencies
Two: Logger and Database. Make them contexts:
context Logger {
fn info(&self, msg: Text);
fn warn(&self, msg: Text);
fn error(&self, msg: Text);
}
context Database {
fn find_order(&self, id: Int) -> Maybe<Order>;
fn save(&self, order: &Order) -> Result<(), DbError>;
}
context is a Verum keyword for a DI-injectable type. It acts as
both a protocol (implementations satisfy the methods) and a context
(functions request it).
Step 2 — Declare the dependencies in the signature
fn order_total(order_id: Int) -> Result<Float, Error>
using [Database, Logger]
{
Logger.info(f"computing total for order {order_id}");
let order = Database.find_order(order_id)
.ok_or(Error.OrderNotFound(order_id))?;
let total = order.items.iter()
.map(|i| i.price * i.quantity)
.sum();
Logger.info(f"order {order_id} total = {total}");
Result.Ok(total)
}
No globals. No unsafe. No .unwrap(). The function signature
now tells the whole truth about what the function needs.
Step 3 — Implement the contexts
type ConsoleLogger is { prefix: Text };
implement Logger for ConsoleLogger {
fn info(&self, msg: Text) {
print(f"[{self.prefix} INFO] {msg}");
}
fn warn(&self, msg: Text) {
print(f"[{self.prefix} WARN] {msg}");
}
fn error(&self, msg: Text) {
print(f"[{self.prefix} ERR] {msg}");
}
}
type MemoryDatabase is {
orders: Shared<Mutex<Map<Int, Order>>>,
};
implement Database for MemoryDatabase {
fn find_order(&self, id: Int) -> Maybe<Order> {
self.orders.lock().await.get(&id).cloned()
}
fn save(&self, order: &Order) -> Result<(), DbError> {
self.orders.lock().await.insert(order.id, order.clone());
Result.Ok(())
}
}
Two concrete implementations — one that logs to the console, one
that stores orders in memory. We could just as easily implement
Logger for a file logger, a syslog handler, or /dev/null.
Step 4 — Provide at the entry point
fn main() using [IO] {
let logger: Logger = ConsoleLogger { prefix: "app" };
let db: Database = MemoryDatabase {
orders: Shared.new(Mutex.new(Map.new())),
};
provide Logger = logger in
provide Database = db in {
seed_test_data();
match order_total(42) {
Result.Ok(total) => print(f"total: {total}"),
Result.Err(e) => print(f"error: {e}"),
}
}
}
provide Context = value in { ... } injects the context into the
block's scope. Inside, any function that declares using [Context]
finds the provided value.
Step 5 — Tests, no mocking framework
// tests/order_test.vr
mount my_project.*;
type TestLog is {
messages: Shared<Mutex<List<Text>>>,
};
implement Logger for TestLog {
fn info(&self, msg: Text) {
self.messages.lock().await.push(f"INFO: {msg}");
}
fn warn(&self, msg: Text) { }
fn error(&self, msg: Text) { }
}
@test
fn test_order_total() {
let log = TestLog { messages: Shared.new(Mutex.new(List.new())) };
let db = MemoryDatabase.seeded_with(vec![
Order { id: 42, items: vec![
Item { price: 10.0, quantity: 2 },
Item { price: 5.0, quantity: 3 },
]},
]);
provide Logger = log.clone() in
provide Database = db in {
let total = order_total(42).unwrap();
assert_eq(total, 35.0);
}
let messages = log.messages.lock().await.clone();
assert_eq(messages.len(), 2);
assert!(messages[0].contains("computing total"));
}
No when(x).thenReturn(y). No @MockBean. No test fixture
inheritance hell. Just ordinary values and provide.
Step 6 — Advanced: negative contexts for purity proofs
Pure functions can declare what they refuse to use:
using Pure = [!IO, !State<_>, !Random];
fn sum(xs: &List<Int>) -> Int using Pure {
xs.iter().sum()
}
A caller that provides IO to sum is rejected at compile time.
This is how Verum encodes "pure code" without making purity a
second type system — pure is just "absent IO, State, Random".
Step 7 — Advanced: conditional contexts
Opt-in features via compile-time flags:
fn fast_path(x: &Data)
using [Database,
Cache if cfg.enable_cache,
Metrics if cfg.metrics]
{
if cfg.enable_cache {
if let Maybe.Some(v) = Cache.get(x.key) { return v; }
}
let result = Database.query(x);
if cfg.metrics {
Metrics.increment("db_hits");
}
result
}
Build without --features cache and the Cache requirement
disappears; build with it, and callers must provide a Cache.
Step 8 — Advanced: multiple instances
Need two of the same context (e.g. primary + replica database)?
fn replicate(order: &Order)
using [Database as primary, Database as replica]
{
primary.save(order);
replica.save(order);
}
// Provider:
provide Database as primary = primary_db in
provide Database as replica = replica_db in {
replicate(&order);
}
Aliases disambiguate; each alias is its own name in the callee.
What you learned
- How to extract implicit state into typed contexts.
- How
using [...]makes dependencies part of the signature. - How
provideinjects concrete implementations. - How the context system makes tests trivial — just provide different values.
- How negative contexts (
!IO) encode purity. - How conditional contexts (
X if cfg.flag) give feature-flag DI. - How aliased contexts allow multiple instances.
Where to go next
- language/context-system — the normative reference.
- language/capability-types —
Database with [Read]type-level capability attenuation. stdlib/context— theProvider,Scope, andContextErrortypes.- tutorials/async-pipeline — context propagation across async boundaries.
- tutorials/http-service — a real service built context-first.