dscope is a powerful and flexible dependency injection library for Go, designed to promote clean architecture, enhance testability, and manage dependencies with ease. It emphasizes immutability, lazy initialization, and type safety.
Managing dependencies in larger Go applications can become complex. dscope offers several advantages:
- Type-Safe Dependencies: Leverages Go's type system to ensure that dependencies are resolved correctly at compile time or with clear runtime panics if a type is missing. Generic functions like
Get[T](scope)provide compile-time type checking for retrievals. - Define and Depend on Interfaces (or Concrete Types): While you can register and request concrete types directly,
dscopefully supports defining providers that return interfaces and requesting dependencies via those interfaces, promoting loose coupling.
A Scope is an immutable container holding definitions for various types. It's the central piece from which you resolve dependencies. The Universe is the initial empty scope.
Definitions are functions or pointers that tell a scope how to create or provide an instance of a type.
- Provider Functions: Functions that return one or more values. Their arguments are themselves resolved as dependencies from the scope.
func NewMyService(db Database) MyService { /* ... */ } func NewConfigAndLogger() (Config, Logger) { /* ... */ }
- Pointer Values: Pointers to existing instances can also be used as definitions. The pointed-at value will be provided.
cfg := &MyConfig{Value: "example"} scope := dscope.New(cfg) // MyConfig is now available
Fork is the primary mechanism for creating new scopes. When you Fork an existing scope, you create a new scope that contains every definition of the original, with the new definitions layered on top. The two scopes are independent branches: you can add new definitions or override existing ones in the new scope without affecting the original.
baseScope := dscope.New(func() int { return 42 })
branch1 := baseScope.Fork(func() string { return "hello" }) // same int as base, adds string
branch2 := baseScope.Fork(func() int { return 100 }) // overrides intReset creates a new scope in which every provider result is recomputed lazily on the next access. The original scope is unaffected.
calls := 0
scope := dscope.New(func() int {
calls++
return calls
})
fmt.Println(dscope.Get[int](scope)) // Output: 1
fmt.Println(dscope.Get[int](scope)) // Output: 1 (result is cached)
resetScope := scope.Reset()
fmt.Println(dscope.Get[int](resetScope)) // Output: 2 (recomputed)dscope.Reset (type Reset func() Scope) is also provided as a built-in dependency bound to the current scope's Reset method, so it can be injected into providers, similar to dscope.Fork:
reset := scope.Get[dscope.Reset]()
// reset() returns a reset scope of the current scope
resetScope := reset()You can create a new scope from scratch using dscope.New() (which is equivalent to dscope.Universe.Fork()).
package main
import (
"fmt"
"github.com/reusee/dscope"
)
// Define some types
type Greeter string
type Message string
// Define provider functions
func provideGreeter() Greeter {
return "Hello"
}
func provideMessage(g Greeter) Message {
return Message(fmt.Sprintf("%s, dscope!", g))
}
func main() {
scope := dscope.New(
provideGreeter,
provideMessage,
)
// Scope is now configured
}You can retrieve values from the scope using scope.GetType(reflect.Type), the generic dscope.Get[T](scope), or scope.Assign(pointers...).
-
dscope.Get[T](scope)(Recommended for type safety):msg := dscope.Get[Message](scope) fmt.Println(msg) // Output: Hello, dscope!
-
scope.TryGet[T]()(Non-panicking lookup): returns the value and whether the type is defined in the scope, instead of panicking on a missing type.if msg, ok := scope.TryGet[Message](); ok { fmt.Println(msg) // Output: Hello, dscope! }
-
scope.Assign(pointers...):var m Message var g Greeter scope.Assign(&m, &g) fmt.Println(g, m) // Output: Hello Hello, dscope!
-
scope.GetType(reflect.Type)/scope.TryGetType(reflect.Type)(reflection-based lookup; GetType panics on a missing type):import "reflect" // ... msgVal, ok := scope.TryGetType(reflect.TypeOf(Message(""))) if ok { fmt.Println(msgVal.Interface().(Message)) }
A computation is a provider: fork the computing function and get its result type. Fork resolves the function's parameters from the scope, and the returned values become types of the new scope.
type Salutation string
func provideSalutation() Salutation {
return "Greetings"
}
scope = scope.Fork(provideSalutation) // Add Salutation to the scope
finalMsg := scope.Fork(func(s Salutation, m Message) string {
return fmt.Sprintf("%s! %s", s, m)
}).Get[string]()
fmt.Println(finalMsg) // Output: Greetings! Hello, dscope!Forking creates a new scope that contains the original's definitions, allowing you to add or override definitions.
baseScope := dscope.New(func() int { return 10 })
// Fork 1: Add a new type
branch1 := baseScope.Fork(func(i int) string {
return fmt.Sprintf("Number: %d", i)
})
fmt.Println(dscope.Get[string](branch1)) // Output: Number: 10
// Fork 2: Override an existing type
branch2 := baseScope.Fork(func() int { return 20 })
fmt.Println(dscope.Get[int](branch2)) // Output: 20
// Original scope is unaffected
fmt.Println(dscope.Get[int](baseScope)) // Output: 10Modules help organize definitions. You can embed dscope.Module in your structs and then use dscope.Methods(moduleInstances...) to add all exported methods of those instances (and their embedded modules) as providers to the scope.
type DatabaseModule struct {
dscope.Module
}
func (dbm *DatabaseModule) ProvideDBConnection() string { // Becomes a provider
return "db_connection_string"
}
type ServiceModule struct {
dscope.Module
DBDep DatabaseModule // Embedded module's methods will also be added
}
func (sm *ServiceModule) ProvideMyService(dbConn string) string { // Becomes a provider
return "service_using_" + dbConn
}
func main() {
// Using concrete instance
scope := dscope.New(
dscope.Methods(new(ServiceModule))...,
)
service := dscope.Get[string](scope) // Will try to get "service_using_db_connection_string"
fmt.Println(service)
}Output:
service_using_db_connection_string
A struct that embeds dscope.Module can also be passed directly to New or Fork: the scope expands it into its methods automatically.
dscope can inject dependencies into the fields of a struct.
-
Using
dscope:"."ordscope:"inject"tag:type MyStruct struct { Dep1 Greeter `dscope:"."` // or dscope:"inject" Dep2 Message `dscope:"."` } scope := dscope.New(provideGreeter, provideMessage) var myInstance MyStruct // The scope provides InjectStruct; retrieve it and call it on the target: scope.InjectStruct(&myInstance) fmt.Printf("Injected: Greeter='%s', Message='%s'\n", myInstance.Dep1, myInstance.Dep2) // Output: Injected: Greeter='Hello', Message='Hello, dscope!'
-
Using
dscope.Inject[T]for lazy field injection: Fields of typedscope.Inject[T]are populated with a function that, when called, resolvesTfrom the scope. This is useful for optional dependencies or dependencies needed much later.type AnotherStruct struct { LazyGreeter dscope.Inject[Greeter] RegularMsg Message `dscope:"."` } scope := dscope.New(provideGreeter, provideMessage) var anotherInstance AnotherStruct scope.InjectStruct(&anotherInstance) fmt.Printf("Regular Message: %s\n", anotherInstance.RegularMsg) // LazyGreeter is not resolved yet. // To get the greeter: actualGreeter := anotherInstance.LazyGreeter() fmt.Printf("Lazy Greeter: %s\n", actualGreeter) // Output: // Regular Message: Hello, dscope! // Lazy Greeter: Hello
This covers the core features and usage patterns of dscope. Its design promotes modularity and testability in Go applications.