# 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 } 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.