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>
381 lines
10 KiB
Markdown
381 lines
10 KiB
Markdown
# 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:
|
|
|
|
```javascript
|
|
// 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:
|
|
|
|
```lux
|
|
// 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:
|
|
|
|
```lux
|
|
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:
|
|
|
|
```lux
|
|
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:
|
|
|
|
```lux
|
|
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:
|
|
|
|
```lux
|
|
// 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:
|
|
|
|
```lux
|
|
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:
|
|
|
|
```lux
|
|
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
|
|
|
|
1. **No mocking libraries needed** - Just write a different handler
|
|
2. **Type-safe** - The compiler ensures all effects are handled
|
|
3. **Explicit dependencies** - You can see what a function needs
|
|
4. **Easy to test** - Swap handlers, no reflection or magic
|
|
5. **Flexible** - Same code, different environments
|
|
|
|
## Complete Example
|
|
|
|
```lux
|
|
// 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](state-machines.md) - Model state transitions with custom effects.
|