Files
lux/docs/tutorials/dependency-injection.md
Brandon Lucas 44f88afcf8 docs: add comprehensive language documentation
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>
2026-02-13 17:43:41 -05:00

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

  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

// 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.