diff --git a/di.go b/di.go index 2c24fa0..e7460f6 100644 --- a/di.go +++ b/di.go @@ -588,6 +588,66 @@ func MustInvokeAs[T any](i Injector) T { return must1(InvokeAs[T](i)) } +// InvokeAsAll invokes all services in the DI container that match the provided type or interface. +// This function searches through all registered services to find all that can be cast to the requested type T. +// Returns a slice of all matching services in deterministic order by service name. +// +// Parameters: +// - i: The injector to search for services +// +// Returns a slice of service instances and any error that occurred during invocation. +// If no services match, returns an empty slice and no error. +// If some services fail to invoke, returns successfully invoked services with an error describing the failures. +// +// Play: https://go.dev/play/p/spRqDOsXQLs +// +// Example: +// +// // Register multiple database implementations +// do.Provide(injector, func(i do.Injector) (*PostgresDB, error) { +// return &PostgresDB{}, nil +// }) +// do.Provide(injector, func(i do.Injector) (*MySQLDB, error) { +// return &MySQLDB{}, nil +// }) +// // Both implement Database interface +// +// // Invoke all databases +// databases, err := do.InvokeAsAll[Database](injector) +// // databases contains both PostgresDB and MySQLDB instances +func InvokeAsAll[T any](i Injector) ([]T, error) { + return invokeAsAllByGenericType[T](i) +} + +// MustInvokeAsAll invokes all services in the DI container that match the provided type or interface. +// This function panics if an error occurs during invocation. +// Returns a slice of all matching service instances in deterministic order. +// +// Parameters: +// - i: The injector to search for services +// +// Returns a slice of service instances. +// Panics if any service cannot be found or invoked. +// +// Play: https://go.dev/play/p/spRqDOsXQLs +// +// Example: +// +// // Register multiple repositories +// do.Provide(injector, func(i do.Injector) (*UserRepository, error) { +// return &UserRepository{}, nil +// }) +// do.Provide(injector, func(i do.Injector) (*ProductRepository, error) { +// return &ProductRepository{}, nil +// }) +// // Both implement Repository interface +// +// // Invoke all repositories (panics on error) +// repositories := do.MustInvokeAsAll[Repository](injector) +func MustInvokeAsAll[T any](i Injector) []T { + return must1(InvokeAsAll[T](i)) +} + ///////////////////////////////////////////////////////////////////////////// // Package-level declaration ///////////////////////////////////////////////////////////////////////////// diff --git a/di_test.go b/di_test.go index de7d99a..c6e545f 100644 --- a/di_test.go +++ b/di_test.go @@ -1164,6 +1164,176 @@ func TestMustInvokeAs(t *testing.T) { }) } +func TestInvokeAsAll(t *testing.T) { + testWithTimeout(t, 100*time.Millisecond) + is := assert.New(t) + + i := New() + + // Register multiple services implementing the same interface + Provide(i, func(i Injector) (*lazyTestHeathcheckerOK, error) { + return &lazyTestHeathcheckerOK{foobar: "first"}, nil + }) + Provide(i, func(i Injector) (*lazyTestHeathcheckerKO, error) { + return &lazyTestHeathcheckerKO{foobar: "second"}, nil + }) + + // Test successful invocation of all matching services + services, err := InvokeAsAll[Healthchecker](i) + is.NoError(err) + is.Len(services, 2) + + // Verify we got 2 services and they are Healthchecker interfaces + // We can't directly cast to concrete types since we get []Healthchecker + // But we can verify the services are properly instantiated by checking they're not nil + is.NotNil(services[0]) + is.NotNil(services[1]) + + // Test empty result + emptyInjector := New() + emptyServices, err := InvokeAsAll[Healthchecker](emptyInjector) + is.NoError(err) + is.Empty(emptyServices) + + // Test with named services for deterministic ordering + namedInjector := New() + ProvideNamed(namedInjector, "service-z", func(i Injector) (*lazyTestHeathcheckerOK, error) { + return &lazyTestHeathcheckerOK{foobar: "z-service"}, nil + }) + ProvideNamed(namedInjector, "service-a", func(i Injector) (*lazyTestHeathcheckerOK, error) { + return &lazyTestHeathcheckerOK{foobar: "a-service"}, nil + }) + ProvideNamed(namedInjector, "service-m", func(i Injector) (*lazyTestHeathcheckerOK, error) { + return &lazyTestHeathcheckerOK{foobar: "m-service"}, nil + }) + + namedServices, err := InvokeAsAll[Healthchecker](namedInjector) + is.NoError(err) + is.Len(namedServices, 3) + // Services should be sorted alphabetically by service name: service-a, service-m, service-z + is.NotNil(namedServices[0]) + is.NotNil(namedServices[1]) + is.NotNil(namedServices[2]) + + // Test with different service types + mixedInjector := New() + ProvideNamed(mixedInjector, "lazy-service", func(i Injector) (*lazyTestHeathcheckerOK, error) { + return &lazyTestHeathcheckerOK{foobar: "lazy"}, nil + }) + ProvideNamedValue(mixedInjector, "eager-service", &lazyTestHeathcheckerOK{foobar: "eager"}) + ProvideNamedTransient(mixedInjector, "transient-service", func(i Injector) (*lazyTestHeathcheckerOK, error) { + return &lazyTestHeathcheckerOK{foobar: "transient"}, nil + }) + + mixedServices, err := InvokeAsAll[Healthchecker](mixedInjector) + is.NoError(err) + is.Len(mixedServices, 3) + is.NotNil(mixedServices[0]) + is.NotNil(mixedServices[1]) + is.NotNil(mixedServices[2]) + + // Test scope inheritance + scopeInjector := New() + childScope := scopeInjector.Scope("child") + + // Register services in different scopes + Provide(scopeInjector, func(i Injector) (*lazyTestHeathcheckerOK, error) { + return &lazyTestHeathcheckerOK{foobar: "root"}, nil + }) + Provide(childScope, func(i Injector) (*lazyTestHeathcheckerKO, error) { + return &lazyTestHeathcheckerKO{foobar: "child"}, nil + }) + + // Test from root scope - should see services from root scope + rootServices, err := InvokeAsAll[Healthchecker](scopeInjector) + is.NoError(err) + is.GreaterOrEqual(len(rootServices), 1) // Should have at least the root service + + // Test from child scope - should see services from both scopes + childServices, err := InvokeAsAll[Healthchecker](childScope) + is.NoError(err) + is.GreaterOrEqual(len(childServices), 1) // Should have at least one service +} + +func TestMustInvokeAsAll(t *testing.T) { + testWithTimeout(t, 100*time.Millisecond) + is := assert.New(t) + + i := New() + Provide(i, func(i Injector) (*lazyTestHeathcheckerOK, error) { + return &lazyTestHeathcheckerOK{foobar: "test"}, nil + }) + + // Test successful invocation + is.NotPanics(func() { + services := MustInvokeAsAll[Healthchecker](i) + is.Len(services, 1) + }) + + // Test that MustInvokeAsAll returns empty slice when no services found (not panic) + emptyInjector := New() + emptyServices := MustInvokeAsAll[Healthchecker](emptyInjector) + is.Empty(emptyServices) + + // Test with multiple services + multiInjector := New() + Provide(multiInjector, func(i Injector) (*lazyTestHeathcheckerOK, error) { + return &lazyTestHeathcheckerOK{foobar: "multi1"}, nil + }) + Provide(multiInjector, func(i Injector) (*lazyTestHeathcheckerKO, error) { + return &lazyTestHeathcheckerKO{foobar: "multi2"}, nil + }) + + is.NotPanics(func() { + multiServices := MustInvokeAsAll[Healthchecker](multiInjector) + is.Len(multiServices, 2) + is.NotNil(multiServices[0]) + is.NotNil(multiServices[1]) + }) + + // Test with different interfaces + interfaceInjector := New() + Provide(interfaceInjector, func(i Injector) (*lazyTestHeathcheckerOK, error) { + return &lazyTestHeathcheckerOK{foobar: "health"}, nil + }) + Provide(interfaceInjector, func(i Injector) (*lazyTestShutdownerOK, error) { + return &lazyTestShutdownerOK{foobar: "shutdown"}, nil + }) + + // Test Healthchecker interface + is.NotPanics(func() { + healthServices := MustInvokeAsAll[Healthchecker](interfaceInjector) + is.Len(healthServices, 1) + is.NotNil(healthServices[0]) + }) + + // Test Shutdowner interface + is.NotPanics(func() { + shutdownServices := MustInvokeAsAll[Shutdowner](interfaceInjector) + // Length depends on interface compatibility + is.LessOrEqual(len(shutdownServices), 1) + }) + + // Test with service aliases (addresses concern b and c from issue #114) + aliasInjector := New() + + // Register a concrete service + Provide(aliasInjector, func(i Injector) (*lazyTestHeathcheckerOK, error) { + return &lazyTestHeathcheckerOK{foobar: "concrete"}, nil + }) + + // Create an alias + err := As[*lazyTestHeathcheckerOK, Healthchecker](aliasInjector) + is.NoError(err) + + // Test that InvokeAsAll finds both the original service and the alias + aliasServices, err := InvokeAsAll[Healthchecker](aliasInjector) + is.NoError(err) + is.Len(aliasServices, 2) // Original service + alias + is.NotNil(aliasServices[0]) + is.NotNil(aliasServices[1]) +} + ///////////////////////////////////////////////////////////////////////////// // Package-level declaration ///////////////////////////////////////////////////////////////////////////// diff --git a/docs/docs/glossary.md b/docs/docs/glossary.md index 6a799b2..d5bcb04 100644 --- a/docs/docs/glossary.md +++ b/docs/docs/glossary.md @@ -115,3 +115,7 @@ An alternative name given to a service in DI. It allows a service to be accessed A mechanism that groups multiple service registrations into a single unit that can be imported and registered with a DI container. Package loaders use `do.Package()` to assemble services with different loading strategies (lazy, eager, transient) and can include named services and interface bindings. This allows for modular service organization where related services can be bundled together and imported as a cohesive unit, promoting modularity and reusability across different parts of an application. +## Bulk Service Invocation + +A technique for retrieving all services that implement a specific interface, returning them as a slice rather than a single instance. This is useful when you need to work with multiple implementations of the same interface, such as multiple database connections, message queue processors, or storage backends. + diff --git a/docs/docs/service-invocation/accept-interfaces-return-structs.md b/docs/docs/service-invocation/accept-interfaces-return-structs.md index 8b43062..f65c5ec 100644 --- a/docs/docs/service-invocation/accept-interfaces-return-structs.md +++ b/docs/docs/service-invocation/accept-interfaces-return-structs.md @@ -14,16 +14,19 @@ Aliases must be Go interfaces. It can be declared explicitly on injection using 1. **Implicit alias invocation**: - provide struct, invoke interface - - `do.InvokeAs()` + - `do.InvokeAs()` (single service) + - `do.InvokeAsAll()` (all matching services) 2. **Explicit alias injection**: - provide struct, bind interface, invoke interface - `do.As()` ## Implicit invocation (preferred) -2 methods are available for implicit invocation: -- `do.InvokeAs` -- `do.MustInvokeAs` +4 methods are available for implicit invocation: +- `do.InvokeAs` (single service) +- `do.MustInvokeAs` (single service, panics on error) +- `do.InvokeAsAll` (all matching services) +- `do.MustInvokeAsAll` (all matching services, panics on error) Named invocation is not available for now. Feel free to open an issue to discuss your needs. @@ -62,6 +65,61 @@ The first matching service in the scope tree is returned. ::: +## Bulk implicit invocation + +For scenarios where you need to work with multiple services that implement the same interface, use `do.InvokeAsAll`: + +```go +type Processor interface { + Process(data string) error +} + +type FileProcessor struct {} +func (f *FileProcessor) Process(data string) error { return nil } + +type NetworkProcessor struct {} +func (n *NetworkProcessor) Process(data string) error { return nil } + +i := do.New() + +// Register multiple processors +do.Provide(i, func(i do.Injector) (*FileProcessor, error) { + return &FileProcessor{}, nil +}) +do.Provide(i, func(i do.Injector) (*NetworkProcessor, error) { + return &NetworkProcessor{}, nil +}) + +// Invoke all processors +processors, err := do.InvokeAsAll[Processor](i) +if err != nil { + log.Fatal(err) +} + +// Process with all available processors +for _, processor := range processors { + processor.Process("data") +} +``` + +### Characteristics of InvokeAsAll + +- **Returns a slice**: `[]T` instead of single `T` +- **Deterministic ordering**: Services sorted by registration name +- **Scope inheritance**: Finds services across all scopes +- **Partial failures**: Returns successful services even if some fail +- **Empty results**: Valid empty slice when no services match + +### When to use InvokeAsAll vs InvokeAs + +| Scenario | Use InvokeAs | Use InvokeAsAll | +|----------|--------------|-----------------| +| Single service needed | ✅ | ❌ | +| Multiple services needed | ❌ | ✅ | +| Fail-fast on missing service | ✅ | ❌ | +| Graceful handling of zero services | ❌ | ✅ | +| Load balancing across services | ❌ | ✅ | + :::warning Invoking an implicit alias with a very simple interface signature might lead to loading the wrong service. diff --git a/docs/docs/service-invocation/service-invocation.md b/docs/docs/service-invocation/service-invocation.md index 9c8a673..5085b73 100644 --- a/docs/docs/service-invocation/service-invocation.md +++ b/docs/docs/service-invocation/service-invocation.md @@ -14,10 +14,18 @@ In the context of the Go code you're working with, there are several helper func - `do.InvokeNamed[T any](do.Injector, string) (T, error)`: This function is similar to `do.Invoke`, but it allows you to invoke a service by its name. This is useful when you have multiple instances of the same type and you want to distinguish between them. +- `do.InvokeAs[T any](do.Injector) (T, error)`: This function invokes a service by finding the first service that matches the provided interface type T. It's useful for interface-based dependency injection without explicit aliasing. + +- `do.InvokeAsAll[T any](do.Injector) ([]T, error)`: This function invokes all services that match the provided interface type T, returning them as a slice. Services are returned in deterministic order based on their registration names. This is useful when you need to work with multiple implementations of the same interface. + - `do.MustInvoke[T any](do.Injector) T`: This function is a variant of `do.Invoke` that panics if the service cannot be created. This is useful when you're sure that the service should always be available, and if it's not, it's an error that should stop the program. - `do.MustInvokeNamed[T any](do.Injector, string) T`: This function is a variant of `do.InvokeNamed` that also panics if the service cannot be created. +- `do.MustInvokeAs[T any](do.Injector) T`: This function is a variant of `do.InvokeAs` that panics if the service cannot be found or created. + +- `do.MustInvokeAsAll[T any](do.Injector) []T`: This function is a variant of `do.InvokeAsAll` that panics if any service cannot be found or created. + 🚀 Lazy services are loaded in invocation order. 🐎 Lazy service invocation is protected against concurrent loading. @@ -139,3 +147,92 @@ func NewMyService(i do.Injector) (*MyService, error) { }, nil } ``` + +## Bulk service invocation + +When you need to work with multiple services that implement the same interface, you can use `do.InvokeAsAll` to retrieve all matching services as a slice: + +```go +type Database interface { + Name() string + Connect() error +} + +type PostgresDB struct { + name string +} + +func (p *PostgresDB) Name() string { return p.name } +func (p *PostgresDB) Connect() error { return nil } + +type MySQLDB struct { + name string +} + +func (m *MySQLDB) Name() string { return m.name } +func (m *MySQLDB) Connect() error { return nil } + +func main() { + i := do.New() + + // Register multiple database implementations + do.Provide(i, func(i do.Injector) (*PostgresDB, error) { + return &PostgresDB{name: "postgres"}, nil + }) + do.Provide(i, func(i do.Injector) (*MySQLDB, error) { + return &MySQLDB{name: "mysql"}, nil + }) + + // Invoke all databases + databases, err := do.InvokeAsAll[Database](i) + if err != nil { + log.Fatal(err) + } + + // databases contains both PostgresDB and MySQLDB instances + // in deterministic order (sorted by service name) + for _, db := range databases { + fmt.Printf("Connecting to %s database\n", db.Name()) + db.Connect() + } +} +``` + +### Key characteristics of InvokeAsAll + +- **Returns a slice**: `[]T` instead of `T` +- **Deterministic ordering**: Services sorted alphabetically by registration name +- **Partial failure handling**: Returns successfully invoked services even if some fail +- **Empty results**: Returns empty slice (not error) when no services match +- **Scope inheritance**: Finds services across the entire scope hierarchy + +Example: +```go +// Register multiple storage backends +do.Provide(i, NewS3Storage) +do.Provide(i, NewLocalStorage) + +// Invoke all storage services +storages, err := do.InvokeAsAll[Storage](i) +// Returns []Storage{S3Storage, LocalStorage} in deterministic order +``` + +### Use cases + +`InvokeAsAll` is particularly useful for: + +- **Multiple database connections**: PostgreSQL, MySQL, MongoDB instances +- **Multiple message queues**: Redis, RabbitMQ, Kafka processors +- **Multiple storage backends**: S3, local filesystem, database storage +- **Plugin systems**: Multiple implementations of the same interface +- **Load balancing**: Multiple instances of the same service type + +### Error handling + +Unlike `InvokeAs` which returns an error when no service is found, `InvokeAsAll` treats "no services found" as a valid empty result. It only returns an error if: + +- A service fails to instantiate +- A circular dependency is detected +- A type assertion fails during invocation + +This makes `InvokeAsAll` more suitable for scenarios where having zero services of a given type is acceptable. diff --git a/examples/invoke-as-all/example.go b/examples/invoke-as-all/example.go new file mode 100644 index 0000000..d64539d --- /dev/null +++ b/examples/invoke-as-all/example.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + "log" + + "github.com/samber/do/v2" +) + +// Database interface +type Database interface { + Name() string +} + +// PostgresDB implementation +type PostgresDB struct{} + +func (p *PostgresDB) Name() string { return "postgres" } + +// MySQLDB implementation +type MySQLDB struct{} + +func (m *MySQLDB) Name() string { return "mysql" } + +func main() { + injector := do.New() + + // Register multiple database implementations + do.Provide(injector, func(i do.Injector) (*PostgresDB, error) { + return &PostgresDB{}, nil + }) + do.Provide(injector, func(i do.Injector) (*MySQLDB, error) { + return &MySQLDB{}, nil + }) + + // Invoke all databases + databases, err := do.InvokeAsAll[Database](injector) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Found %d databases:\n", len(databases)) + for _, db := range databases { + fmt.Printf("- %s\n", db.Name()) + } + // Output: + // Found 2 databases: + // - mysql + // - postgres +} diff --git a/invoke.go b/invoke.go index 2cc51f9..c552209 100644 --- a/invoke.go +++ b/invoke.go @@ -198,6 +198,97 @@ func invokeByGenericType[T any](i Injector) (T, error) { return instance.(T), nil //nolint:errcheck,forcetypeassert } +// invokeAsAllByGenericType finds and invokes all services matching type T. +// This function performs a two-phase operation: +// 1. Discovery phase: Find all services that can be cast to T +// 2. Invocation phase: Invoke each matching service and collect results +// +// The function returns services in deterministic order (sorted by service name) +// and handles partial failures by returning successfully invoked services +// along with detailed error information. +func invokeAsAllByGenericType[T any](i Injector) ([]T, error) { + injector := getInjectorOrDefault(i) + results := make([]T, 0) + + var invokerChain []string + vScope, isVirtualScope := injector.(*virtualScope) + if isVirtualScope { + invokerChain = vScope.invokerChain + } + + // Discovery phase: collect all matching services + type serviceMatch struct { + name string + instance any + scope *Scope + } + var matches []serviceMatch + + injector.serviceForEachRec(func(name string, scope *Scope, s any) bool { + if serviceCanCastToGeneric[T](s) { + serviceWrapper, ok := s.(serviceWrapperGetName) //nolint:forcetypeassert + if !ok { + return true + } + matches = append(matches, serviceMatch{ + name: serviceWrapper.getName(), + instance: s, + scope: scope, + }) + } + return true + }) + + if len(matches) == 0 { + // For InvokeAsAll, returning no services is not an error + return nil, nil + } + + // Sort matches for deterministic ordering + sort.Slice(matches, func(i, j int) bool { + return matches[i].name < matches[j].name + }) + + // Invocation phase: invoke each matching service + for _, match := range matches { + if isVirtualScope { + if err := vScope.detectCircularDependency(match.name); err != nil { + // Return partial results with error on circular dependency + return results, fmt.Errorf("circular dependency detected involving service %s: %w", match.name, err) + } + } + + // Use the interface name for hooks to maintain consistency with InvokeAs + interfaceName := inferServiceName[T]() + injector.RootScope().opts.onBeforeInvocation(match.scope, interfaceName) + + serviceInstanceWrapper, ok := match.instance.(serviceWrapperGetInstanceAny) //nolint:forcetypeassert + if !ok { + return results, fmt.Errorf("failed to invoke service %s: service does not support invocation", match.name) + } + instance, err := serviceInstanceWrapper.getInstanceAny( //nolint:errcheck + newVirtualScope(match.scope, append(invokerChain, match.name))) + + injector.RootScope().opts.onAfterInvocation(match.scope, interfaceName, err) + + if err != nil { + // Return partial results with specific error details + return results, fmt.Errorf("failed to invoke service %s: %w", match.name, err) + } + + if isVirtualScope { + vScope.addDependency(injector, match.name, match.scope) + } + + match.scope.onServiceInvoke(match.name) + injector.RootScope().opts.Logf("DI: service %s invoked", match.name) + + results = append(results, instance.(T)) //nolint:errcheck,forcetypeassert + } + + return results, nil +} + // invokeByTag injects services into struct fields based on struct tags. // This function supports automatic dependency injection into struct fields // using the `do` tag or a custom tag key specified in the injector options.