The Promise of Convention Over Configuration
When you write a new service class in a .NET application, the traditional workflow goes like this: create the class, define its interface, implement the interface, and then remember to add services.AddTransient<IService, Service>() to your startup code. Forget that last step, and your application fails at runtime with a cryptic dependency resolution error. Automatic service discovery in C# with Needlr eliminates that manual registration step entirely by scanning your assemblies and registering types based on simple conventions.
Needlr is a dependency injection library that discovers your types automatically. Instead of writing registration code, you configure how scanning should work, and Needlr handles the rest. This article explains the mechanics: what gets discovered, how types are matched to interfaces, what conventions Needlr follows, and how to exclude types from automatic registration when you need manual control. If you are new to dependency injection concepts, the guide on what is inversion of control provides the foundational context.
This article focuses specifically on the discovery and registration mechanics. Topics like choosing between source generation and reflection, configuring assembly scanning, and using advanced features like decorators are covered in other articles. Here we are concerned with understanding what happens when Needlr scans your codebase and how you can influence that process.
The Core Discovery Convention
Needlr follows a simple but powerful convention: every concrete class is registered as itself, and if that class implements one or more interfaces, it is also registered against each of those interfaces. This means that for most services, you write the class and its interface, and Needlr handles the wiring automatically.
Consider a typical service implementation:
// Define the interface
public interface IOrderProcessor
{
Task ProcessOrderAsync(Order order);
}
// Implement the interface
public class OrderProcessor : IOrderProcessor
{
private readonly ILogger<OrderProcessor> _logger;
// Constructor injection - Needlr will resolve ILogger automatically
public OrderProcessor(ILogger<OrderProcessor> logger)
{
_logger = logger;
}
public async Task ProcessOrderAsync(Order order)
{
_logger.LogInformation("Processing order {OrderId}", order.Id);
// Implementation details...
await Task.CompletedTask;
}
}
When Needlr scans your assembly, it discovers OrderProcessor and registers it in two ways:
- As
OrderProcessoritself (so you can injectOrderProcessordirectly) - As
IOrderProcessor(so you can inject the interface, which is the recommended approach)
This dual registration happens automatically. You do not need to write any registration code. Needlr uses the same IServiceCollection in C# that the built-in Microsoft container uses, so the registration follows the same patterns you would use manually.
What Types Get Discovered
Needlr scans assemblies for concrete classes that can be instantiated. It looks for types that:
- Are public or internal (depending on your assembly scanning configuration)
- Are not abstract
- Are not static
- Have at least one public constructor (or a parameterless constructor)
Abstract classes, static classes, and interfaces themselves are not registered. Only concrete implementations are discovered and registered.
Here is an example that shows what gets discovered and what does not:
// This interface is NOT registered (interfaces are not concrete types)
public interface IDataRepository
{
Task<Data> GetByIdAsync(int id);
}
// This abstract class is NOT registered (cannot be instantiated)
public abstract class BaseRepository
{
protected abstract Task<Data> LoadAsync(int id);
}
// This concrete class IS registered as both SqlRepository and IDataRepository
public class SqlRepository : BaseRepository, IDataRepository
{
public async Task<Data> GetByIdAsync(int id)
{
// Implementation
return await Task.FromResult(new Data());
}
protected override async Task<Data> LoadAsync(int id)
{
return await GetByIdAsync(id);
}
}
// This static class is NOT registered (static classes cannot be instantiated)
public static class DataValidator
{
public static bool IsValid(Data data) => data != null;
}
In this example, only SqlRepository gets registered. It is registered as SqlRepository itself and as IDataRepository. The abstract base class and the static utility class are ignored by the scanner.
Service Lifetime and Registration
By default, Needlr registers discovered types with a singleton lifetime. This means a single instance is created and reused for the lifetime of the application. This is a sensible default for most services, but you can configure different lifetimes through Needlr's type filtering and registration options.
The registration that Needlr performs is equivalent to calling:
services.AddSingleton<SqlRepository>();
services.AddSingleton<IDataRepository, SqlRepository>();
If you need transient or scoped lifetimes, you can use the [Transient] or [Scoped] attributes on your classes, configure Needlr to apply different lifetimes based on type filters, or use manual registration for specific types. The article on service lifetimes covers this in detail.
Excluding Types from Auto-Discovery
Sometimes you do not want a type to be automatically registered. Perhaps it is a data transfer object that should not be in the container, or you need to register it manually with specific configuration. Needlr provides the [DoNotAutoRegister] attribute to exclude types from automatic discovery.
Here is how to use it:
using NexusLabs.Needlr;
// This class will NOT be automatically registered
[DoNotAutoRegister]
public class ManuallyConfiguredService : IService
{
public void DoWork()
{
// Implementation
}
}
// This class WILL be automatically registered
public class AutoRegisteredService : IService
{
public void DoWork()
{
// Implementation
}
}
When you mark a class with [DoNotAutoRegister], Needlr skips it during scanning. You can then register it manually in your startup code or through a plugin if you need custom configuration:
// Manual registration with custom configuration
services.AddSingleton<IService>(sp =>
{
var instance = new ManuallyConfiguredService();
// Configure the instance...
return instance;
});
This pattern is useful when you need to:
- Register a service with a factory function
- Apply conditional registration logic
- Register multiple implementations of the same interface with different keys
- Control the exact lifetime and configuration
Multiple Interface Implementations
When a class implements multiple interfaces, Needlr registers it against all of them. This is useful for services that fulfill multiple roles:
public interface IEmailSender
{
Task SendEmailAsync(string to, string subject, string body);
}
public interface INotificationService
{
Task NotifyAsync(string recipient, string message);
}
// This class implements both interfaces
public class EmailNotificationService : IEmailSender, INotificationService
{
public async Task SendEmailAsync(string to, string subject, string body)
{
// Send email implementation
await Task.CompletedTask;
}
public async Task NotifyAsync(string recipient, string message)
{
// Delegate to email sending
await SendEmailAsync(recipient, "Notification", message);
}
}
Needlr registers EmailNotificationService as:
EmailNotificationService(the concrete type)IEmailSenderINotificationService
This means you can inject either interface, and both resolve to the same instance (depending on the service lifetime). This is particularly useful when you have services that implement multiple related interfaces and want a single implementation to handle all of them.
Internal Types and Assembly Visibility
By default, Needlr scans for public types. If you want to include internal types, you need to configure the assembly provider to include them. This is useful when you have internal service implementations that should be registered but are not part of your public API.
The assembly scanning configuration controls which types are visible to the scanner. The article on assembly scanning covers this in detail, but the key point is that Needlr respects .NET visibility rules: public types are always discoverable, and internal types are only discoverable if you explicitly configure the scanner to include them.
How Discovery Integrates with Source Generation and Reflection
The discovery convention is the same regardless of whether you use source generation or reflection. The difference is when the discovery happens, not what gets discovered.
With source generation, the Roslyn source generator analyzes your code at compile time and emits registration code. The discovery logic runs during the build, and the resulting registration calls are compiled into your assembly.
With reflection, Needlr loads your assemblies at runtime and uses reflection APIs to inspect types. The discovery logic runs when you call .Scan() on your service collection, typically during application startup.
Both approaches produce the same registrations. The choice between them is about performance, AOT compatibility, and whether you need dynamic type loading. The article comparing source generation vs reflection in Needlr covers the tradeoffs in detail.
Common Patterns and Best Practices
Understanding automatic discovery helps you structure your code to work well with Needlr. Here are some patterns that work particularly well:
Single Responsibility Interfaces: Create focused interfaces that represent a single responsibility. Needlr will register implementations against all interfaces they implement, so having clear, single-purpose interfaces makes your code more maintainable.
Convention-Based Naming: While not required, following naming conventions like I{ServiceName} for interfaces and {ServiceName} for implementations makes your code more discoverable by humans, even though Needlr does not rely on naming.
Explicit Exclusion: Use [DoNotAutoRegister] liberally for types that should not be in the container. It is better to be explicit about exclusions than to rely on implicit behavior.
Layered Registration: Combine automatic discovery with manual registration. Let Needlr handle the majority of your services automatically, and manually register the exceptions that need special configuration.
Troubleshooting Discovery Issues
If a type is not being registered as expected, check these common issues:
- Type is abstract or static: Only concrete, instantiable types are registered.
- Type is marked with
[DoNotAutoRegister]: Remove the attribute if you want automatic registration. - Type is not in a scanned assembly: Verify that the assembly containing the type is included in your scanning configuration.
- Type is internal and internal scanning is disabled: Configure the scanner to include internal types if needed.
- Type has no public constructor: Needlr requires at least one public constructor (or a parameterless constructor) to instantiate the type.
Most discovery issues come down to one of these causes. The key is understanding what Needlr looks for and ensuring your types meet those criteria.
Real-World Example: E-Commerce Service Discovery
Let's look at a more complete example that shows automatic discovery in a realistic scenario:
// Domain interfaces
public interface IProductRepository
{
Task<Product> GetByIdAsync(int id);
Task<IEnumerable<Product>> GetAllAsync();
}
public interface IOrderRepository
{
Task<Order> GetByIdAsync(int id);
Task SaveAsync(Order order);
}
public interface IPaymentProcessor
{
Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request);
}
// Implementations
public class SqlProductRepository : IProductRepository
{
private readonly IDbConnection _connection;
public SqlProductRepository(IDbConnection connection)
{
_connection = connection;
}
public async Task<Product> GetByIdAsync(int id)
{
// Database query implementation
return await Task.FromResult(new Product { Id = id });
}
public async Task<IEnumerable<Product>> GetAllAsync()
{
// Database query implementation
return await Task.FromResult(Enumerable.Empty<Product>());
}
}
public class SqlOrderRepository : IOrderRepository
{
private readonly IDbConnection _connection;
public SqlOrderRepository(IDbConnection connection)
{
_connection = connection;
}
public async Task<Order> GetByIdAsync(int id)
{
// Database query implementation
return await Task.FromResult(new Order { Id = id });
}
public async Task SaveAsync(Order order)
{
// Database save implementation
await Task.CompletedTask;
}
}
public class StripePaymentProcessor : IPaymentProcessor
{
private readonly IConfiguration _configuration;
public StripePaymentProcessor(IConfiguration configuration)
{
_configuration = configuration;
}
public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request)
{
// Stripe API integration
return await Task.FromResult(new PaymentResult { Success = true });
}
}
// Service that uses the repositories
public class OrderService
{
private readonly IOrderRepository _orderRepository;
private readonly IPaymentProcessor _paymentProcessor;
public OrderService(
IOrderRepository orderRepository,
IPaymentProcessor paymentProcessor)
{
_orderRepository = orderRepository;
_paymentProcessor = paymentProcessor;
}
public async Task<Order> CreateOrderAsync(OrderRequest request)
{
var order = new Order { /* ... */ };
await _orderRepository.SaveAsync(order);
return order;
}
}
When Needlr scans this code, it automatically registers:
SqlProductRepositoryasSqlProductRepositoryandIProductRepositorySqlOrderRepositoryasSqlOrderRepositoryandIOrderRepositoryStripePaymentProcessorasStripePaymentProcessorandIPaymentProcessorOrderServiceasOrderService
All of these registrations happen automatically. You do not need to write any registration code. When OrderService is resolved, Needlr automatically injects the registered implementations of IOrderRepository and IPaymentProcessor.
Integration with Existing DI Patterns
Needlr's automatic discovery works alongside existing dependency injection patterns. If you are migrating from manual registration or another DI container, you can use automatic discovery for new code while keeping manual registration for legacy code.
For example, if you are using Autofac for dependency injection, you might have existing manual registrations. You can gradually migrate to Needlr by letting it discover new services automatically while maintaining manual registrations for services that need special configuration.
The key is that Needlr uses the standard IServiceCollection interface, so it integrates seamlessly with the built-in Microsoft container and any code that works with IServiceCollection.
Conclusion
Automatic service discovery is the core value proposition of Needlr. By following simple conventions, Needlr eliminates the boilerplate of manual service registration while giving you control when you need it. Understanding how discovery works helps you structure your code to take full advantage of automatic registration while knowing when to exclude types or use manual registration.
The convention is straightforward: concrete classes are registered as themselves and as any interfaces they implement. This covers the majority of use cases automatically. For the exceptions, the [DoNotAutoRegister] attribute gives you explicit control. Combined with Needlr's fluent configuration API, you get the best of both worlds: convention-based defaults with the flexibility to customize when needed.
Frequently Asked Questions
How does Needlr decide which types to register?
Needlr registers all concrete, non-abstract, non-static classes that have at least one public constructor. If a class implements interfaces, it is registered as both the concrete type and each interface it implements.
Can I control the service lifetime for automatically discovered types?
Yes, you can configure Needlr to apply different lifetimes based on type filters. By default, types are registered with singleton lifetime, but you can use the [Transient] or [Scoped] attributes to override the default, or configure different lifetimes through Needlr's configuration options.
What happens if a class implements multiple interfaces?
Needlr registers the class against all interfaces it implements. Each interface resolves to the same instance (depending on service lifetime), so you can inject any of the interfaces and get the same implementation.
How do I exclude a type from automatic registration?
Use the [DoNotAutoRegister] attribute on the class. This tells Needlr to skip that type during scanning, allowing you to register it manually with custom configuration if needed.
Does Needlr discover internal types?
By default, Needlr only discovers public types. You can configure the assembly scanner to include internal types if needed, which is useful when you have internal service implementations.
What if I need to register a type with a factory function?
Mark the type with [DoNotAutoRegister] and register it manually using services.AddSingleton<IService>(sp => new Service(/* ... */)) or similar factory-based registration methods.
Can I use automatic discovery with existing manual registrations?
Yes, Needlr works alongside manual registrations. You can use automatic discovery for new code while maintaining manual registrations for services that need special configuration or are part of legacy code.
