Documentation structure inspired by Rust Book, Elm Guide, and others: Guide (10 chapters): - Introduction and setup - Basic types (Int, String, Bool, List, Option, Result) - Functions (closures, higher-order, composition) - Data types (ADTs, pattern matching, records) - Effects (the core innovation) - Handlers (patterns and techniques) - Modules (imports, exports, organization) - Error handling (Fail, Option, Result) - Standard library reference - Advanced topics (traits, generics, optimization) Reference: - Complete syntax reference Tutorials: - Calculator (parsing, evaluation, REPL) - Dependency injection (testing with effects) - Project ideas (16 projects by difficulty) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
10 KiB
10 KiB
Tutorial: Dependency Injection with Effects
Learn how effects provide natural dependency injection for testing and flexibility.
What You'll Learn
- Using effects for dependencies
- Swapping handlers for testing
- The "ports and adapters" pattern
The Problem
Imagine you're building a user registration system:
// Traditional approach (pseudo-code)
function registerUser(userData) {
const validated = validate(userData);
database.insert(validated); // Hard-coded dependency
emailService.send(validated.email, "Welcome!"); // Another one
logger.info("User registered"); // And another
return validated.id;
}
Testing this requires mocking database, emailService, and logger. In many languages, this means:
- Dependency injection frameworks
- Mock libraries
- Complex test setup
The Lux Way
In Lux, dependencies are effects:
// Define what we need (not how it works)
effect Database {
fn insert(table: String, data: String): Int
fn query(sql: String): List<String>
}
effect Email {
fn send(to: String, subject: String, body: String): Unit
}
effect Logger {
fn info(message: String): Unit
fn error(message: String): Unit
}
Now our business logic declares its dependencies:
type User = { name: String, email: String }
fn registerUser(user: User): Int with {Database, Email, Logger} = {
Logger.info("Registering user: " + user.name)
// Validate
if user.name == "" then {
Logger.error("Empty name")
Fail.fail("Name required")
} else ()
if user.email == "" then {
Logger.error("Empty email")
Fail.fail("Email required")
} else ()
// Insert into database
let userId = Database.insert("users", userToJson(user))
Logger.info("Created user with ID: " + toString(userId))
// Send welcome email
Email.send(user.email, "Welcome!", "Thanks for registering, " + user.name)
Logger.info("Sent welcome email")
userId
}
fn userToJson(user: User): String =
"{\"name\":\"" + user.name + "\",\"email\":\"" + user.email + "\"}"
Production Handlers
For production, we implement real handlers:
handler postgresDb: Database {
fn insert(table, data) = {
let result = Http.post("http://db-service/insert", data)
// Parse ID from response
resume(parseId(result))
}
fn query(sql) = {
let result = Http.post("http://db-service/query", sql)
resume(parseRows(result))
}
}
handler smtpEmail: Email {
fn send(to, subject, body) = {
Http.post("http://email-service/send", formatEmail(to, subject, body))
resume(())
}
}
handler consoleLogger: Logger {
fn info(msg) = {
Console.print("[INFO] " + msg)
resume(())
}
fn error(msg) = {
Console.print("[ERROR] " + msg)
resume(())
}
}
Production code:
fn main(): Unit with {Console, Http} = {
let user = User { name: "Alice", email: "alice@example.com" }
let result = run registerUser(user) with {
Database = postgresDb,
Email = smtpEmail,
Logger = consoleLogger
}
Console.print("Registered user ID: " + toString(result))
}
Test Handlers
For testing, we swap in mock handlers:
// Mock database that stores in memory
handler mockDb: Database {
fn insert(table, data) = {
let id = State.get()
State.put(id + 1)
Console.print("[MOCK DB] Inserted into " + table + ": " + data)
resume(id)
}
fn query(sql) = {
Console.print("[MOCK DB] Query: " + sql)
resume(["mock", "data"])
}
}
// Mock email that just logs
handler mockEmail: Email {
fn send(to, subject, body) = {
Console.print("[MOCK EMAIL] To: " + to + ", Subject: " + subject)
resume(())
}
}
// Silent logger for tests
handler silentLogger: Logger {
fn info(msg) = resume(())
fn error(msg) = resume(())
}
// Collecting logger for assertions
handler collectingLogger: Logger {
fn info(msg) = {
State.put(State.get() + "[INFO] " + msg + "\n")
resume(())
}
fn error(msg) = {
State.put(State.get() + "[ERROR] " + msg + "\n")
resume(())
}
}
Test code:
fn testRegisterUser(): Unit with {Console} = {
let user = User { name: "Test", email: "test@test.com" }
// Run with mocks
let (userId, logs) = run {
let id = run {
run registerUser(user) with {
Database = mockDb,
Email = mockEmail,
Logger = collectingLogger
}
} with { State = 1 } // Database ID counter
let logs = State.get()
(id, logs)
} with { State = "" } // Log accumulator
// Assertions
Console.print("User ID: " + toString(userId))
Console.print("Logs:\n" + logs)
if userId == 1 then
Console.print("✓ User ID is correct")
else
Console.print("✗ User ID is wrong")
if String.contains(logs, "Registering user") then
Console.print("✓ Registration was logged")
else
Console.print("✗ Registration was not logged")
}
Testing Failure Cases
Test error handling by using handlers that fail:
handler failingDb: Database {
fn insert(table, data) = {
Fail.fail("Database connection failed")
}
fn query(sql) = resume([])
}
fn testDatabaseFailure(): Unit with {Console} = {
let user = User { name: "Test", email: "test@test.com" }
let result = run {
run registerUser(user) with {
Database = failingDb,
Email = mockEmail,
Logger = silentLogger
}
} with {}
Console.print("Test: Database failure is handled")
// The Fail effect should have triggered
}
The "Ports and Adapters" Pattern
This is also known as hexagonal architecture:
┌─────────────────────┐
│ Business Logic │
│ (Pure + Effects) │
└─────────────────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
│ Database │ │ Email │ │ Logger │
│ Effect │ │ Effect │ │ Effect │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
┌─────────┼─────────┐ │ ┌─────┼─────┐
│ │ │ │ │ │
┌───┴───┐ ┌───┴───┐ │ ┌───┴───┐ ┌───┴───┐ ┌─────┴─────┐
│Postgres│ │MockDB │ │ │ SMTP │ │Console│ │ Collector │
└───────┘ └───────┘ │ └───────┘ └───────┘ └───────────┘
│
┌─────┴─────┐
│ MockEmail │
└───────────┘
- Business logic is pure (plus declared effects)
- Effects are the ports (interfaces)
- Handlers are the adapters (implementations)
Benefits
- No mocking libraries needed - Just write a different handler
- Type-safe - The compiler ensures all effects are handled
- Explicit dependencies - You can see what a function needs
- Easy to test - Swap handlers, no reflection or magic
- Flexible - Same code, different environments
Complete Example
// main.lux
// === Effects (Ports) ===
effect Database {
fn insert(table: String, data: String): Int
}
effect Email {
fn send(to: String, body: String): Unit
}
// === Business Logic ===
type User = { name: String, email: String }
fn registerUser(user: User): Int with {Database, Email, Fail} = {
if user.name == "" then Fail.fail("Name required") else ()
let id = Database.insert("users", user.name)
Email.send(user.email, "Welcome!")
id
}
// === Production Handlers (Adapters) ===
handler prodDb: Database {
fn insert(table, data) = {
Console.print("[DB] INSERT INTO " + table)
resume(42) // Would be real ID
}
}
handler prodEmail: Email {
fn send(to, body) = {
Console.print("[EMAIL] Sending to " + to)
resume(())
}
}
// === Test Handlers ===
handler testDb: Database {
fn insert(table, data) = resume(1)
}
handler testEmail: Email {
fn send(to, body) = resume(())
}
// === Running ===
fn production(): Unit with {Console} = {
let user = User { name: "Alice", email: "alice@example.com" }
let id = run registerUser(user) with {
Database = prodDb,
Email = prodEmail
}
Console.print("Created user: " + toString(id))
}
fn test(): Unit with {Console} = {
let user = User { name: "Test", email: "test@test.com" }
let id = run registerUser(user) with {
Database = testDb,
Email = testEmail
}
if id == 1 then Console.print("✓ Test passed")
else Console.print("✗ Test failed")
}
fn main(): Unit with {Console} = {
Console.print("=== Production ===")
production()
Console.print("\n=== Test ===")
test()
}
let output = run main() with {}
What You Learned
- Effects define what you need, handlers define how
- Swap handlers for testing without changing business logic
- No dependency injection framework needed
- Type system ensures all dependencies are satisfied
Next Tutorial
State Machines - Model state transitions with custom effects.