Dependency Injection in GALA

GALA has no injection container, no annotations and no implicit parameters. A dependency is an ordinary constructor parameter, wired once in one place. Everything a piece of code uses appears in its signature, and every value it receives is visible where it is built.

This guide shows the pattern and how to test it. All the listings come from examples/dependency_injection.gala, which runs as part of the test suite.

Table of Contents

  1. The pattern
  2. Ports: what a service needs
  3. Services: dependencies as constructor parameters
  4. The composition root
  5. Testing with interface literals
  6. Interface or function?
  7. Dependencies and concurrency
  8. Dependencies and Go
  9. What GALA does not do

The pattern

  1. Ports. Declare what a service needs as interfaces: a repository, a clock, a logger.
  2. Services. A service is a struct whose fields are its dependencies. An optional dependency has a default.
  3. Adapters. Production implementations are ordinary structs with methods.
  4. Composition root. main builds the adapters and passes them to the services. It is the one place that names concrete implementations.
  5. Tests build the same services with fakes: interface literals, swapped in with Copy.

Ports: what a service needs

A port is an interface that says what the service needs, in the service’s own terms:

type UserRepo interface {
    Find(id int) Option[User]
}

type Clock interface {
    Year() int
}

type Logger interface {
    Info(msg string)
}

Keep ports small. A service that needs one query from a repository should not depend on the repository’s twenty methods; a small interface is also a short fake.

Services: dependencies as constructor parameters

A service is a shorthand struct. Its field list is its constructor, so a missing dependency is a compile error (GALA-E0045), and an optional one takes its default:

// An optional dependency gets a default.
func NoopLogger() Logger = Logger(Info = (msg) => {})

struct Greeter(repo UserRepo, clock Clock, log Logger = NoopLogger())

func (g Greeter) Greet(id int) string = g.repo.Find(id) match {
    case Some(u) => {
        g.log.Info(s"greeting ${u.Name}")
        s"Hello, ${u.Name} (${g.clock.Year()})"
    }
    case None() => s"no user $id"
}

NoopLogger builds its value with an interface literal: a value of an interface made from one function per method, without declaring a struct for it.

Adapters are ordinary structs that implement the ports:

struct MemRepo(users HashMap[int, User])
func (r MemRepo) Find(id int) Option[User] = r.users.Get(id)

struct FixedClock(year int)
func (c FixedClock) Year() int = c.year

The composition root

main wires everything, with named arguments so each dependency is labeled:

func main() {
    // Composition root: the one place that names concrete implementations.
    val greeter = Greeter(
        repo = MemRepo(users = HashMapOf((1, User(1, "Ada")))),
        clock = FixedClock(2026),
        log = PrefixLogger("[info]"),
    )
    Println(greeter.Greet(1))
    Println(greeter.Greet(2))

In a larger application, each layer gets a constructor function that takes the lower layer’s services and returns its own, and main calls them in order. The wiring stays plain code: a reader follows it with “go to definition”, and the compiler checks every step.

Testing with interface literals

A test builds the service with fakes. An interface literal is a fake in one line: its lambdas take their parameter types from the interface’s methods, and the compiler checks that every method is given (GALA-E0078). Copy replaces only the dependencies a test cares about:

    // In a test: fakes are interface literals, and Copy swaps one dependency.
    val fakeRepo = UserRepo(Find = (id) => Some(User(id, s"user$id")))
    val testGreeter = greeter.Copy(repo = fakeRepo, clock = Clock(Year = () => 2030))
    Println(testGreeter.Greet(7))

A fake can record how it was used, because a literal’s functions close over local state:

    // A recording fake checks how a dependency was used.
    var messages = ListOf[string]()
    val recording = greeter.Copy(log = Logger(Info = (msg) => { messages = messages.Append(msg) }))
    recording.Greet(1)
    Println(s"logged: $messages")

With the test framework, the same fakes go into a test function:

package main

import . "martianoff/gala/test"

struct User(Id int, Name string)

type UserRepo interface {
    Find(id int) Option[User]
}

struct Greeter(repo UserRepo)

func (g Greeter) Greet(id int) string = g.repo.Find(id) match {
    case Some(u) => s"Hello, ${u.Name}"
    case None() => s"no user $id"
}

func TestGreetUnknownUser(t T) T {
    val greeter = Greeter(repo = UserRepo(Find = (id) => None()))
    return Eq(t, greeter.Greet(9), "no user 9")
}

No mocking library and no generated mocks: a fake is GALA code, checked like any other.

Interface or function?

A dependency with a single operation can be a function-typed field instead of an interface:

package main

struct Report(now func() int)

func (r Report) Header() string = s"Report for ${r.now()}"

func main() {
    val report = Report(now = () => 2026)
    Println(report.Header())
}

Prefer an interface when the dependency has several related operations, when it has a production adapter with state of its own, or when Go code implements it. Prefer a function when it is one operation, such as a clock or an ID generator. Both are faked the same way: with a lambda.

Dependencies and concurrency

A dependency that is a function or an interface may hold mutable state: an interface literal can close over a var. The data-race check therefore does not let a Future body capture one (GALA-E0037). Call the dependency first, and send the plain value into the Future:

package main

import . "martianoff/gala/concurrent"

type Clock interface {
    Year() int
}

func main() {
    val clock = Clock(Year = () => 2026)
    val year = clock.Year()
    val next = Future(() => year + 1)
    Println(next.Await())
}

Dependencies and Go

What GALA does not do

GALA leaves out the implicit mechanisms other languages use for this:

Explicit parameters cost a few more words at the composition root and in tests. In return every dependency is visible, checked by the compiler, and easy to replace.