Navigating Complex Dependency Graphs
As applications evolve, so does the complexity of their dependency graphs. A simple "register and resolve" pattern is often sufficient for small projects, but production-grade systems frequently encounter scenarios that require more granular control over the resolution process.
You might have a service that is expensive to initialize and only needed in rare edge cases. You might need to aggregate results from multiple implementations of the same interface. Or perhaps you need to override a specific dependency at runtime for a specialized task.
In this fifth part of our series, we dive into Advanced Dependency Resolution Techniques. Building on our knowledge of Part 4: Advanced Registration Patterns in Go with Parsley, we will explore how Parsley handles lazy loading, service lists, and manual dependency provision.
1. Lazy Proxies: Deferring Heavy Initialization
In a standard DI container, resolving a service usually triggers the activation of its entire dependency tree. For resource-intensive services—such as those establishing multiple network connections or performing heavy disk I/O—this can lead to unnecessary overhead if the service isn't actually used during a specific execution path.
Parsley solves this with Lazy Proxies.
How it Works
A lazy proxy acts as a lightweight placeholder. When you resolve a lazy service, Parsley returns a features.Lazy[T] instance instead of the actual service. The real service is only activated when you explicitly call its Value(ctx) method.
// Register a service with a lazy proxy
features.RegisterLazy[Greeter](registry, NewGreeter, types.LifetimeTransient)
// Resolve the proxy
lazy, _ := resolving.ResolveRequiredService[features.Lazy[Greeter]](ctx, resolver)
// The actual NewGreeter constructor is only called here:
greeter := lazy.Value(ctx)
greeter.SayHello("John")
Once activated, the proxy caches the instance. Subsequent calls to Value() return the same object, ensuring consistent behavior while optimizing resource usage.
2. Service Lists: Managing Multiple Implementations
In modular architectures, it is common to have multiple implementations of a single interface. Examples include:
- A data aggregator that fetches from multiple storage backends.
- A validation pipeline that runs several independent checks.
- A plugin system where different modules contribute to a core process.
While you can resolve these services individually by name (as seen in Part 4), Parsley's RegisterList[T] provides a more ergonomic way to inject all implementations as a single slice.
Practical Example: The Aggregator Pattern
Suppose we have multiple DataService implementations. We can group them into a list and inject them into an aggregator.
func main() {
registry := registration.NewServiceRegistry()
// Register individual implementations
registry.Register(NewLocalDataService, types.LifetimeTransient)
registry.Register(NewRemoteDataService, types.LifetimeTransient)
// Group all DataService registrations into a list
features.RegisterList[DataService](registry)
// Register the aggregator that expects a []DataService slice
registry.Register(newAggregator, types.LifetimeTransient)
// ...
}
type aggregator struct {
services []DataService
}
func newAggregator(services []DataService) *aggregator {
return &aggregator{services: services}
}
By using RegisterList, the aggregator is automatically provided with every registered implementation of DataService. This allows you to add new implementations to your application without modifying the aggregator's code—a perfect example of the Open-Closed Principle.
3. Dynamic Overrides with ResolveWithOptions
Sometimes, you need to provide a specific instance to the resolver that wasn't registered in the container, or you want to temporarily override a registered dependency for a single resolution call. This is particularly useful for passing runtime configurations or injecting mock objects during testing.
The ResolveWithOptions method allows you to pass "hints" to the resolver via the WithInstance option.
// Create a specific transport instance at runtime
customTransport := &http.Transport{ ... }
// Resolve a client, but force it to use our custom transport instance
resolveType := types.MakeServiceType[*Client]()
instance, _ := resolver.ResolveWithOptions(ctx, resolveType,
resolving.WithInstance[*http.Transport](customTransport))
client := instance.(*Client)
This technique ensures that the resolved Client uses the provided customTransport, even if a different transport was previously registered in the ServiceRegistry.
Operational Considerations
Performance vs. Complexity
Lazy proxies improve startup time and reduce memory footprint for unused services. However, they add a layer of indirection. Use them for truly "heavy" services rather than every dependency.
Slice Injection and Order
When using RegisterList, the order of services in the slice typically matches the order of registration. If your application logic depends on a specific order (e.g., a middleware chain), ensure your registration sequence reflects this.
Tradeoffs and Limitations
-
Lazy Proxy Indirection: Every access to the service through a lazy proxy involves a call to
Value(ctx). While the overhead is minimal after activation, it is a non-zero cost compared to direct injection. -
Type Safety in Options:
ResolveWithOptionsreturns ananytype, requiring a runtime type assertion. Additionally,WithInstancemust match the exact type expected by the constructor. If a constructor expects an interface and you provide a concrete pointer, the resolution may fail if the types are not perfectly aligned with Parsley's internal reflection. -
Service List Exclusivity:
RegisterListis primarily designed for injecting all implementations. If you only need a subset of implementations, you should continue using Named Services or custom factory functions.
Summary
Advanced resolution techniques provide the flexibility needed to handle the realities of production software.
- Lazy Proxies defer resource consumption until necessary.
- Service Lists enable powerful aggregation and plugin patterns.
- Dynamic Overrides give you precise control over dependency injection at runtime.
In the next part of this series, we will look at Reliability and Validation, exploring how Parsley's built-in validator can catch configuration errors before your application even starts.
Next Steps
- Identify a resource-intensive service in your app and experiment with
RegisterLazy. - Use
RegisterListto implement a simple plugin or strategy pattern. - Check the Service Lists documentation for more advanced use cases.
Top comments (0)