Building Extensible Applications
Modern applications often need to be extensible. You might want to allow third-party modules, support feature flags that enable or disable entire subsystems, or organize your codebase into loosely coupled modules that can be developed independently. Plugin architecture with Needlr in .NET provides a clean way to achieve this extensibility by automatically discovering and executing plugin code that configures your dependency injection container and application behavior.
Needlr includes a plugin system that automatically discovers classes implementing IServiceCollectionPlugin or IWebApplicationPlugin and invokes their configuration methods. This allows you to organize your application into modules where each module is responsible for its own service registration and configuration. This article explains how the plugin system works, how to create plugins for both console and web applications, and how plugins integrate with Needlr's automatic service discovery.
If you are new to plugin architectures, the guide on plugin architecture design pattern provides foundational concepts. For developers familiar with modular application design, this article shows how Needlr's plugin system simplifies the implementation while maintaining flexibility.
This article focuses specifically on Needlr's plugin system. Topics like automatic service discovery, the Syringe API, and web application setup are covered in other articles. Here we are concerned with building modular, extensible applications using plugins.
Understanding Needlr's Plugin System
Needlr provides two plugin interfaces:
IServiceCollectionPlugin: For configuring services in the dependency injection containerIWebApplicationPlugin: For configuring web-specific features like endpoints and middleware
Both plugin types are automatically discovered by Needlr during scanning, just like regular services. When Needlr builds your service collection or web application, it finds all plugin implementations and invokes their configuration methods in a deterministic order.
Here is a simple example of a service collection plugin:
using Microsoft.Extensions.DependencyInjection;
using NexusLabs.Needlr;
// Plugin that configures email services
internal sealed class EmailPlugin : IServiceCollectionPlugin
{
public void Configure(ServiceCollectionPluginOptions options)
{
// Register email-specific services
options.Services.AddSingleton<IEmailConfiguration>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
return new EmailConfiguration
{
SmtpServer = config["Email:SmtpServer"],
Port = int.Parse(config["Email:Port"] ?? "587")
};
});
options.Services.AddTransient<IEmailSender, SmtpEmailSender>();
}
}
// Services that get automatically discovered and registered
public interface IEmailSender
{
Task SendAsync(string to, string subject, string body);
}
public class SmtpEmailSender : IEmailSender
{
private readonly IEmailConfiguration _config;
public SmtpEmailSender(IEmailConfiguration config)
{
_config = config;
}
public async Task SendAsync(string to, string subject, string body)
{
// SMTP implementation
await Task.CompletedTask;
}
}
The EmailPlugin is automatically discovered by Needlr and its Configure method is called during service collection building. This allows you to group related service registrations into logical modules.
Service Collection Plugins
Service collection plugins are used to configure services in the dependency injection container. They are useful for:
- Grouping related service registrations
- Applying conditional registration logic
- Registering services with factory functions
- Configuring complex service graphs
Here is a more complete example that shows multiple plugins working together:
using Microsoft.Extensions.DependencyInjection;
using NexusLabs.Needlr;
// Database plugin
internal sealed class DatabasePlugin : IServiceCollectionPlugin
{
public void Configure(ServiceCollectionPluginOptions options)
{
options.Services.AddSingleton<IDbConnectionFactory>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var connectionString = config.GetConnectionString("DefaultConnection");
return new SqlConnectionFactory(connectionString);
});
options.Services.AddScoped<IDbConnection>(sp =>
{
var factory = sp.GetRequiredService<IDbConnectionFactory>();
return factory.CreateConnection();
});
}
}
// Repository plugin
internal sealed class RepositoryPlugin : IServiceCollectionPlugin
{
public void Configure(ServiceCollectionPluginOptions options)
{
// Repositories are automatically discovered, but we can add
// additional configuration here if needed
options.Services.AddScoped<IUnitOfWork, UnitOfWork>();
}
}
// Service layer plugin
internal sealed class ServiceLayerPlugin : IServiceCollectionPlugin
{
public void Configure(ServiceCollectionPluginOptions options)
{
// Business services are automatically discovered
// This plugin can add cross-cutting concerns like caching
options.Services.AddMemoryCache();
options.Services.Decorate<IProductService, CachedProductService>();
}
}
// Services that get automatically discovered
public interface IProductRepository
{
Task<Product> GetByIdAsync(int id);
}
public class ProductRepository : IProductRepository
{
private readonly IDbConnection _connection;
public ProductRepository(IDbConnection connection)
{
_connection = connection;
}
public async Task<Product> GetByIdAsync(int id)
{
// Database query implementation
return await Task.FromResult(new Product { Id = id });
}
}
public interface IProductService
{
Task<Product> GetProductAsync(int id);
}
public class ProductService : IProductService
{
private readonly IProductRepository _repository;
public ProductService(IProductRepository repository)
{
_repository = repository;
}
public async Task<Product> GetProductAsync(int id)
{
return await _repository.GetByIdAsync(id);
}
}
In this example, three plugins work together to configure different layers of the application. The DatabasePlugin sets up database connections, the RepositoryPlugin configures the repository layer, and the ServiceLayerPlugin adds cross-cutting concerns. Each plugin is responsible for its own domain, making the application easier to understand and maintain.
Web Application Plugins
Web application plugins are used to configure web-specific features like endpoints, middleware, and routing. They are invoked after the WebApplication is built, giving them access to the full application pipeline.
Here is an example of a web application plugin:
using NexusLabs.Needlr;
using NexusLabs.Needlr.AspNet;
// Plugin that configures API endpoints
internal sealed class ApiPlugin : IWebApplicationPlugin
{
public void Configure(WebApplicationPluginOptions options)
{
var app = options.WebApplication;
// Configure API routing
app.MapGroup("/api/v1")
.MapProductsEndpoints()
.MapOrdersEndpoints();
// Add API-specific middleware
app.Use(async (context, next) =>
{
context.Response.Headers.Add("X-API-Version", "1.0");
await next();
});
}
}
// Extension methods for endpoint grouping
public static class ProductsEndpoints
{
public static RouteGroupBuilder MapProductsEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/products", (IProductService service) =>
{
return Results.Ok(service.GetAllProducts());
});
group.MapGet("/products/{id:int}", (int id, IProductService service) =>
{
var product = service.GetProductById(id);
return product != null
? Results.Ok(product)
: Results.NotFound();
});
return group;
}
}
public static class OrdersEndpoints
{
public static RouteGroupBuilder MapOrdersEndpoints(this RouteGroupBuilder group)
{
group.MapPost("/orders", async (CreateOrderRequest request, IOrderService service) =>
{
var order = await service.CreateOrderAsync(request);
return Results.Created($"/api/v1/orders/{order.Id}", order);
});
return group;
}
}
The ApiPlugin is automatically discovered and its Configure method is called after the WebApplication is built. This allows you to organize web-specific configuration into modules, keeping your main startup code clean.
Combining Plugins with Automatic Discovery
Plugins work seamlessly with Needlr's automatic service discovery. Services are discovered and registered automatically, and plugins can add additional configuration, decorators, or conditional logic.
Here is an example that shows both patterns working together:
using Microsoft.Extensions.DependencyInjection;
using NexusLabs.Needlr;
// Plugin that adds logging decorators
internal sealed class LoggingPlugin : IServiceCollectionPlugin
{
public void Configure(ServiceCollectionPluginOptions options)
{
// Services are automatically discovered
// This plugin adds logging decorators to specific services
options.Services.Decorate<IOrderService, LoggingOrderService>();
options.Services.Decorate<IPaymentService, LoggingPaymentService>();
}
}
// Automatically discovered services
public interface IOrderService
{
Task<Order> CreateOrderAsync(OrderRequest request);
}
public class OrderService : IOrderService
{
public async Task<Order> CreateOrderAsync(OrderRequest request)
{
// Implementation
return await Task.FromResult(new Order());
}
}
// Decorator added by plugin
public class LoggingOrderService : IOrderService
{
private readonly IOrderService _inner;
private readonly ILogger<LoggingOrderService> _logger;
public LoggingOrderService(
IOrderService inner,
ILogger<LoggingOrderService> logger)
{
_inner = inner;
_logger = logger;
}
public async Task<Order> CreateOrderAsync(OrderRequest request)
{
_logger.LogInformation("Creating order for customer {CustomerId}", request.CustomerId);
var order = await _inner.CreateOrderAsync(request);
_logger.LogInformation("Order {OrderId} created successfully", order.Id);
return order;
}
}
In this example, OrderService is automatically discovered and registered. The LoggingPlugin then adds a logging decorator, wrapping the original service with cross-cutting logging concerns. This pattern allows you to keep business logic separate from infrastructure concerns.
Plugin Discovery and Execution Order
Needlr discovers plugins automatically during assembly scanning, just like regular services. Plugins are executed in a deterministic order based on their discovery order, which is typically the order in which assemblies are loaded.
If you need to control plugin execution order explicitly, you can use the [Order] attribute or configure plugin ordering through Needlr's configuration options. However, in most cases, the default discovery order is sufficient.
Here is an example that shows explicit ordering:
using NexusLabs.Needlr;
// Plugin that must run first
[Order(1)]
internal sealed class ConfigurationPlugin : IServiceCollectionPlugin
{
public void Configure(ServiceCollectionPluginOptions options)
{
// Set up configuration first
options.Services.AddSingleton<IConfiguration>(sp =>
{
// Configuration setup
return new ConfigurationBuilder().Build();
});
}
}
// Plugin that depends on configuration
[Order(2)]
internal sealed class DatabasePlugin : IServiceCollectionPlugin
{
public void Configure(ServiceCollectionPluginOptions options)
{
// Can safely access IConfiguration here
options.Services.AddSingleton<IDbConnection>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var connectionString = config.GetConnectionString("Default");
return new SqlConnection(connectionString);
});
}
}
The [Order] attribute ensures that ConfigurationPlugin runs before DatabasePlugin, allowing the database plugin to safely access configuration services.
Real-World Example: Modular E-Commerce Application
Let's look at a complete example that demonstrates a modular application structure:
using NexusLabs.Needlr.AspNet;
using NexusLabs.Needlr.Injection;
using NexusLabs.Needlr.Injection.SourceGen;
var webApplication = new Syringe()
.UsingSourceGen()
.ForWebApplication()
.BuildWebApplication();
await webApplication.RunAsync();
// Infrastructure Plugin
internal sealed class InfrastructurePlugin : IServiceCollectionPlugin
{
public void Configure(ServiceCollectionPluginOptions options)
{
options.Services.AddSingleton<ICache, MemoryCache>();
options.Services.AddSingleton<ILoggerFactory, LoggerFactory>();
}
}
// Catalog Plugin
internal sealed class CatalogPlugin : IWebApplicationPlugin
{
public void Configure(WebApplicationPluginOptions options)
{
var app = options.WebApplication;
app.MapGet("/api/catalog/products", (IProductService service) =>
{
return Results.Ok(service.GetAllProducts());
});
app.MapGet("/api/catalog/products/{id:int}", (int id, IProductService service) =>
{
var product = service.GetProductById(id);
return product != null
? Results.Ok(product)
: Results.NotFound();
});
}
}
// Order Plugin
internal sealed class OrderPlugin : IWebApplicationPlugin
{
public void Configure(WebApplicationPluginOptions options)
{
var app = options.WebApplication;
app.MapPost("/api/orders", async (CreateOrderRequest request, IOrderService service) =>
{
var order = await service.CreateOrderAsync(request);
return Results.Created($"/api/orders/{order.Id}", order);
});
app.MapGet("/api/orders/{id:guid}", async (Guid id, IOrderService service) =>
{
var order = await service.GetOrderByIdAsync(id);
return order != null
? Results.Ok(order)
: Results.NotFound();
});
}
}
// Automatically discovered services
public interface IProductService
{
IEnumerable<Product> GetAllProducts();
Product? GetProductById(int id);
}
public class ProductService : IProductService
{
private readonly IProductRepository _repository;
private readonly ICache _cache;
public ProductService(IProductRepository repository, ICache cache)
{
_repository = repository;
_cache = cache;
}
public IEnumerable<Product> GetAllProducts()
{
return _cache.GetOrSet("products:all", () => _repository.GetAll());
}
public Product? GetProductById(int id)
{
return _cache.GetOrSet($"products:{id}", () => _repository.GetById(id));
}
}
public interface IOrderService
{
Task<Order> CreateOrderAsync(CreateOrderRequest request);
Task<Order?> GetOrderByIdAsync(Guid id);
}
public class OrderService : IOrderService
{
private readonly IOrderRepository _repository;
private readonly IPaymentService _paymentService;
public OrderService(
IOrderRepository repository,
IPaymentService paymentService)
{
_repository = repository;
_paymentService = paymentService;
}
public async Task<Order> CreateOrderAsync(CreateOrderRequest request)
{
var order = new Order { Id = Guid.NewGuid() };
await _paymentService.ProcessPaymentAsync(order.Total);
await _repository.SaveAsync(order);
return order;
}
public async Task<Order?> GetOrderByIdAsync(Guid id)
{
return await _repository.GetByIdAsync(id);
}
}
In this example, the application is organized into three plugins:
InfrastructurePlugin: Configures cross-cutting infrastructure servicesCatalogPlugin: Configures product catalog endpointsOrderPlugin: Configures order management endpoints
Each plugin is responsible for its own domain, making the application easier to understand, test, and maintain. Services are automatically discovered and registered, and plugins add the web-specific configuration.
Benefits of Plugin Architecture
Using plugins with Needlr provides several benefits:
Modularity: Each plugin encapsulates a feature or subsystem, making the codebase easier to understand and maintain.
Extensibility: New features can be added by creating new plugins without modifying existing code.
Testability: Plugins can be tested independently, and you can create test plugins that configure services differently for testing.
Team Collaboration: Different teams can work on different plugins independently, reducing merge conflicts and coordination overhead.
Feature Flags: Plugins can be conditionally loaded based on configuration, enabling feature flags at the module level.
Comparison with Traditional Plugin Patterns
Traditional plugin architectures often require:
- Manual plugin registration
- Custom plugin discovery mechanisms
- Complex loading and unloading logic
- Explicit plugin interfaces
Needlr's plugin system simplifies this by:
- Automatic plugin discovery (plugins are discovered just like services)
- Standard interfaces (
IServiceCollectionPlugin,IWebApplicationPlugin) - Integration with dependency injection (plugins can inject services)
- No custom loading logic needed
This makes plugin development as simple as implementing an interface and letting Needlr handle the rest.
Conclusion
Needlr's plugin system provides a clean, simple way to build modular, extensible applications. By automatically discovering plugins and invoking their configuration methods, Needlr eliminates the boilerplate of traditional plugin architectures while maintaining flexibility. Whether you are building a modular monolith, supporting third-party extensions, or organizing your codebase into feature modules, plugins provide a powerful way to structure your application.
The combination of automatic service discovery and plugin-based configuration gives you the best of both worlds: convention-based defaults with the ability to customize and extend when needed. This makes Needlr particularly well-suited for applications that need to grow and evolve over time.
Frequently Asked Questions
How are plugins discovered by Needlr?
Plugins are discovered automatically during assembly scanning, just like regular services. Any class implementing IServiceCollectionPlugin or IWebApplicationPlugin is automatically discovered and executed.
Can I control the order in which plugins execute?
Yes, you can use the [Order] attribute to control plugin execution order. Plugins with lower order values execute before plugins with higher values.
Do plugins work with both source generation and reflection?
Yes, plugins work with both discovery strategies. The plugin discovery and execution happen regardless of whether you use source generation or reflection for service discovery.
Can I conditionally load plugins based on configuration?
Yes, you can use [DoNotAutoRegister] on plugin classes and register them conditionally based on configuration. Alternatively, plugins can check configuration internally and skip registration if conditions are not met.
How do plugins integrate with automatic service discovery?
Plugins work seamlessly with automatic service discovery. Services are discovered and registered automatically, and plugins can add additional configuration, decorators, or conditional logic on top of the automatically discovered services.
Can I create plugins for console applications?
Yes, IServiceCollectionPlugin works with both console and web applications. IWebApplicationPlugin is specific to web applications and provides access to the WebApplication instance.
How do I test applications that use plugins?
You can create test plugins that configure services differently for testing, or you can bypass plugins entirely in test scenarios by using manual service registration. Needlr's plugin system does not interfere with standard testing practices.
