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.
main builds the adapters and passes them to the
services. It is the one place that names concrete implementations.Copy.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.
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
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.
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.
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.
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())
}
io.Writer or
fmt.Stringer, and a test fakes it with an interface literal:
io.Writer(Write = (p) => Success(p.Size())). A method with a Go result
list, such as Write’s (int, error), takes a lambda returning a Try.std.Immutable. To let Go build one, export a GALA constructor function
that takes the dependencies (see the Go Interop guide).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.