Introduction
What is SimplEnteiner?
SimplEnteiner is a small, self-contained Dependency Injection (DI) framework for .NET. It provides a fluent binding API (similar in spirit to Ninject/Autofac), scoped/hierarchical containers, decorator support, convention-based auto-registration, reachability validation, and a JSON-based configuration import/export mechanism — all implemented from scratch on top of plain reflection and System.Linq.Expressions, with no dependency on any third-party IoC container.
The library also ships an adapter that plugs a DIContainer instance into Microsoft.Extensions.DependencyInjection (IServiceCollection / IServiceProvider), so it can act as a drop-in replacement for the built-in ASP.NET Core / Generic Host service provider.
Source root: SimplEnteiner/
Solution file: SimplEnteiner.sln
Problem Domain
Most .NET applications need some form of Inversion of Control container to:
- Decouple interfaces from implementations.
- Manage object lifetimes (transient / singleton / scoped / cached).
- Compose object graphs automatically via constructor/property/field injection.
- Support cross-cutting concerns via the decorator pattern.
- Validate, at startup, that the whole dependency graph can actually be resolved (fail fast instead of failing at run time, deep in a call stack).
SimplEnteiner focuses on exactly these problems, staying deliberately small and dependency-light (netstandard2.1, only two NuGet dependencies), so it can be embedded anywhere from ASP.NET Core services to Unity-style game engines (the code base contains explicit comments about IL2CPP compatibility, see TypeAnalyzes.GetFactoryMethod).
Target Audience
- .NET library/application developers who want a lightweight, embeddable DI container without pulling in a large third-party dependency.
- Developers who need hierarchical scopes (root container → child scopes → grandchild scopes) with proper singleton/scoped/cached lifetime semantics.
- Teams that want compile-time-like safety for their dependency graphs via
Build()/AnalyzeReachabilityvalidation before the application starts serving requests. - Consumers of
Microsoft.Extensions.DependencyInjection-based hosting (ASP.NET Core, Generic Host, Worker Services) who want to replace the built-in container with SimplEnteiner while keeping the sameIServiceProvider/IServiceScopeabstractions. - Engine/runtime authors (e.g., game engines) who need a reflection-based container that can also work without JIT compilation of expression trees (fallback path in
GetFactoryMethod).
Technology Stack
| Aspect | Value |
|---|---|
| Main library target framework | netstandard2.1 (see SimplEnteiner.csproj) |
| Test project target framework | net8.0 (see SimplEnteinerTests.csproj) |
| Language | C# (nullable-annotated in parts, e.g. TypeAnalyzes.cs) |
| Supported OS | Any OS supported by a netstandard2.1-compatible runtime (Windows, Linux, macOS) — no OS-specific APIs are used |
| Direct NuGet dependencies | Microsoft.Extensions.DependencyInjection 10.0.7, System.Text.Json 10.0.7 |
| Test framework | xUnit 2.5.3 |
| Documentation | mdBook (this book), Mermaid diagrams |
Because the core library targets netstandard2.1, it can be consumed from .NET 6/7/8/9/10 applications, as well as from any other runtime implementing netstandard2.1 (e.g. Mono, Unity with the appropriate API compatibility level).
Key Features and Differentiators
- Fluent, staged binding API —
Bind<T>().To<Impl>().AsSingle().WithId(...).WhenInjectedInto<T>().WithArguments(...).Apply(). The staged builder (see Architecture → Design Patterns) enforces a valid call order at run time via a small internal state machine. - Hierarchical scopes —
IScope.CreateScope()creates a child scope that inherits parent registrations, with correct singleton (root-only), scoped (per-scope) and cached (per-resolution-context) lifetime semantics. - Four lifetimes:
Transient,Singleton,Scoped,Cached(seeLifeTime). - Decorators with explicit ordering, including open-generic decorators for open-generic services.
- Conditional bindings —
WithId(...)andWhenInjectedInto<T>()allow multiple registrations of the same interface, resolved by consumer type or an explicit id (via[Id]attribute on constructor parameters/fields/properties). - Convention-based / assembly scanning binding via
BindConvention(...)andIConventionBuilder(filter by namespace, attribute, predicate; bind to interfaces and/or self). - Constructor, field, property and method injection, with an explicit
[Inject]attribute,[Id]-attribute-based disambiguation, and support forIEnumerable<T>,Lazy<T>, andFunc<T>"meta" dependencies. - Cyclic-dependency detection (
TypeAnalyzes.HasCyclicDependencies) and reachability analysis (Registry.AnalyzeReachability/ReachabilityAnalyzer) that can be run at containerBuild()time to fail fast on invalid graphs. - Object lifecycle hooks:
IInitializable,IAsyncInitializable,IStartable, plusOnActivation/OnReleasecallbacks per binding. - JSON export/import of the whole binding configuration (
DIContainer.ExportConfiguration()/ImportConfiguration()), enabling serialization of a container's registration graph. - First-class
Microsoft.Extensions.DependencyInjectioninterop viaAddSimplEnteiner(...),SimplEnteinerServiceProvider, andSimplEnteinerServiceScope. - Heavily cached reflection layer (
TypeAnalyzes) with a rich set of general-purpose type-introspection extension methods (open/closed generics, generic constraint satisfaction, attribute scanning, etc.) that is reusable independently of the container itself. - No third-party IoC dependency — the only external packages are
Microsoft.Extensions.DependencyInjection(for interoperability, not for actual container logic) andSystem.Text.Json(for the serialization feature).
Continue to Architecture and Design for a deep dive into how these pieces fit together, or jump straight to the API Reference for concrete usage.
Architecture Overview
High-Level Diagram
flowchart TB
subgraph Consumer
App[Application Code]
end
subgraph SimplEnteiner Core
DIContainer["DIContainer<br/>(IScope, IBindingTarget)"]
Scope["Scope<br/>(IScope, IBindingTarget)"]
Binder["BindingBuilder<br/>+ Fluent Binding API"]
Registry["Registry<br/>(IRegistry)"]
Resolver["Resolver<br/>(IResolver)"]
ConventionBuilder["ConventionBuilder<br/>(IConventionBuilder)"]
CleanupService["CleanupService<br/>(disposal tracking)"]
RepositoryService["RepositoryService<br/>(singleton store)"]
TypeAnalyzes["TypeAnalyzes<br/>(reflection utilities)"]
ReachabilityAnalyzer["ReachabilityAnalyzer"]
end
subgraph MS.DI Integration
Extensions["Extensions.AddSimplEnteiner"]
SEProvider["SimplEnteinerServiceProvider"]
SEScope["SimplEnteinerServiceScope"]
end
App -->|Bind/Decorate/Resolve| DIContainer
DIContainer -->|delegates to root| Scope
Scope -->|creates children| Scope
DIContainer --> Binder
Binder -->|Register| Scope
Scope --> Registry
Scope --> Resolver
Resolver --> Registry
Resolver --> TypeAnalyzes
Scope --> CleanupService
Scope --> RepositoryService
DIContainer --> ConventionBuilder
Registry --> ReachabilityAnalyzer
App -->|AddSimplEnteiner| Extensions
Extensions --> DIContainer
Extensions --> SEProvider
SEProvider --> Scope
SEProvider --> SEScope
Layers
SimplEnteiner is organized into the following logical layers, all living under SimplEnteiner/Core:
- Binder layer (
Core/Binder) — the public fluent API surface (Bind<T>(),Decorate<T>()) and the internal staged state machine (BindingBuilder,BuilderStages) that guarantees bindings are configured in a valid order and only registered once. - Registration layer (
Core/RegistrationService) — theRegistrythat stores exact, open-generic, conditional, and decorator registrations, plus the validation logic that ensures a registration's dependencies are resolvable and its constraints are satisfied. - Resolution layer (
Core/ResolverService) — theResolver, which walks the constructor/member dependency graph, creates instances via compiled factory delegates, applies lifetime-specific caching/storage, and wraps instances with decorators. - Scope layer (
Core/ScopeFeature) —Scope/IScope, the hierarchical container implementation that owns aRegistry, a singleton repository, scoped instance dictionary, child scopes, and drives disposal. - Lifecycle layer (
Core/Lifecycle) — lifetime enum, initialization/startup interfaces (IInitializable,IAsyncInitializable,IStartable), and theCleanupServicethat tracksIDisposable/IAsyncDisposableinstances for correct teardown. - Convention layer (
Core/ConventionBinding) — assembly-scanning, predicate/attribute/namespace-filtered auto-registration (ConventionBuilder). - Configuration/Serialization layer (
Core/Configuration) — DTOs (ScopeConfig,BindingConfig,DecoratorConfig) used to serialize/deserialize a container's registration graph to/from JSON. - Reflection toolkit (
SimplEnteiner.TypeAnalyzes, top-level namespaceSimplEnteiner) — a large, independently reusable set ofType/ConstructorInfo/MemberInfoextension methods with a lazily-initialized, thread-safe, process-wide cache of loadable types. - Analysis layer (
SimplEnteiner.Analysis) —ReachabilityAnalyzer, used to compute the set of types reachable from a set of "root" service types, used both byRegistry.AnalyzeReachabilityand directly by consumers. - Integration layer (
SimplEnteiner.Integrations.MS_DI) — adapters (SimplEnteinerServiceProvider,SimplEnteinerServiceScope,Extensions.AddSimplEnteiner) that expose aDIContainer/IScopeas a standardIServiceProvider/IServiceScopeFactory.
Root Container vs. Scope
DIContainer (public entry point, Core/DIContainer.cs) is a thin façade over an internal root Scope. It implements both IScope (so it can be used anywhere an IScope is expected) and the internal IBindingTarget interface (used to receive registrations produced by the fluent binder). All actual bookkeeping — registry, singleton store, children, cleanup — lives in the Scope it wraps.
classDiagram
class IScope {
<<interface>>
+IScope Parent
+IRegistry Registry
+bool IsRoot
+Resolve~T~() T
+ResolveAsync~T~() Task~T~
+CreateScope() IScope
+Build()
}
class IBinder {
<<interface>>
+Bind~T~() IBindingTo~T~
+Decorate~T~() IBindingDecorate~T~
+BindConvention(Action)
}
class DIContainer {
-Scope _rootScope
-List~BindingBuilder~ _pendingBindings
-IResolver _resolver
+ExportConfiguration() string
+ImportConfiguration(string)
+AnalyzeReachability(roots, injectAttr)
}
class Scope {
-IRegistry _registry
-IRepositoryService _singletons
-Dictionary~Type,object~ _scopedInstances
-List~IScope~ _childrens
-ICleanupService _cleanupService
+Install(IInstaller)
+Build()
}
IScope <|-- IBinder
IScope <|.. DIContainer
IScope <|.. Scope
DIContainer o-- Scope : wraps root
Scope o-- Scope : children
Continue to Design Patterns for the rationale behind this structure, or to Namespace Structure for a file/namespace map.
Design Patterns Used
SimplEnteiner makes deliberate use of several classic design patterns. This page documents where each pattern is applied and why.
Fluent Builder + Finite State Machine
Where: Core/Binder/BindingBuilder.cs, Core/Binder/BuilderStages
Each call to Bind<T>() creates a BindingBuilder, which is driven by an internal BuilderStateMachine through five ordered stages:
stateDiagram-v2
[*] --> InitialStage
InitialStage --> ImplementationStage : SetImplementation / SetFactoryMethod / SetInstance / AddDecorator
ImplementationStage --> LifetimeStage : SetLifetime
LifetimeStage --> OptionsStage : SetCondition / SetId
OptionsStage --> FinalStage : ExecuteAllStages()
FinalStage --> [*]
Each Stage (InitialStage, ImplementationStage, LifetimeStage, OptionsStage, FinalStage) has a numeric Id used to detect illegal backward transitions (BindingBuilder.ThrowIfCantTransit<T>), e.g. calling SetLifetime twice throws InvalidOperationException("Lifetime already set!"). When a stage is skipped (e.g. the caller does not explicitly call AsTransient()), BuilderStateMachine.ExecuteTo<TStage>() executes the default behavior of every skipped stage (see LifetimeStage.OnExecuteBinding which defaults to LifeTime.Transient, and ImplementationStage.OnExecuteBinding which defaults implementation to ToSelf()).
This guarantees that every BindingBuilder, regardless of how many fluent calls were made, ends up fully configured before being registered into the Registry.
The public fluent surface itself (IBindingTo → IBindingLifetime → IBindingOptions) is a classic step builder / fluent interface pattern that narrows the set of valid next calls at each stage — see API Reference → Binder.
Facade
Where: Core/DIContainer.cs
DIContainer is a facade over Scope: nearly every public member simply forwards to _rootScope. This keeps the public entry point stable and simple while all the actual logic (registration, resolution, disposal, hierarchical scoping) is implemented once in Scope and reused for both the root container and every child scope (which are plain Scope instances, not DIContainer instances).
Composite (Scope Tree)
Where: Core/ScopeFeature/Scope.cs
Scopes form a tree: Scope.CreateScope() creates a child Scope with the same ScopeCreationConfig (shared IResolver, IScopeFactory, IRegistryFactory, singleton repository) but its own Registry and scoped-instance dictionary. Operations like Build(), AnalyzeReachability(...), and GetAllExactRegistration() recursively traverse this tree (parent-first or child-first depending on the operation), which is the essence of the Composite pattern.
Strategy
Where: IResolver, IRegistryFactory, IScopeFactory (all injected via ScopeCreationConfig)
The concrete resolution algorithm (Resolver), the registry storage implementation (RegistryFactory → Registry), and the child-scope creation strategy (DefaultScopeFactory) are all pluggable via interfaces injected into ScopeCreationConfig. Although DIContainer currently wires up only the default implementations (ConfigureConfig in DIContainer.cs), the seams exist for consumers or tests to substitute alternative strategies.
Decorator
Where: Core/Binder/Interfaces/IBindingDecorate.cs, Core/RegistrationService/DecoratorRegistration.cs, Resolver.ResolveDecorators
The library implements the GoF Decorator pattern as a first-class DI feature: Decorate<TService>().With<TDecorator>(order).AsScoped() registers a decorator that the Resolver wraps around the base implementation at resolution time, in ascending Order. Multiple decorators can be stacked; open-generic decorators are supported for open-generic services (interfaceType.IsGenericTypeDefinition).
Factory Method / Compiled Expression Factories
Where: TypeAnalyzes.GetFactoryMethod(ConstructorInfo)
Instead of calling Activator.CreateInstance (which is comparatively slow and reflection-heavy on every resolution), SimplEnteiner compiles a Expression.Lambda<Func<object[], object>> once per constructor and caches/reuses the compiled delegate. This is a Factory Method pattern implemented via expression trees, with a defensive try/catch fallback to constructor.Invoke(args) for environments where JIT-compiling expression trees is unavailable (e.g., IL2CPP/AOT platforms), as explicitly called out in the XML doc comment.
Specification / Predicate Composition
Where: Core/ConventionBinding/Implementations/ConventionBuilder.cs
IConventionBuilder.If(Func<Type,bool>), .InNamespace(...), .WithAttribute<T>() compose a set of predicates that are AND-combined (_ifPredicates.Any(p => p(type) == false)) to filter candidate types before binding them — a lightweight Specification-pattern style composition.
Template Method (Lifecycle Hooks)
Where: Core/Lifecycle/IInitializable.cs, IAsyncInitializable, IStartable, and InterfaceInvoker
Consumers implement well-known lifecycle interfaces on their own classes; the Resolver (for IInitializable/IAsyncInitializable) and Scope.Start() (for IStartable, singleton-only) invoke them automatically at the appropriate point in an instance's life — a Template-Method-flavored extension point.
Serializer / Data Transfer Object
Where: Core/Configuration (ScopeConfig, BindingConfig, DecoratorConfig) and the nested Scope.Serializer / DIContainer.Serializer classes
Registrations are converted to/from plain serializable DTOs using System.Text.Json, decoupling the live object graph (Registration, DecoratorRegistration, which hold live Func<object[],object> delegates and Type references) from a wire/storage format (assembly-qualified type name strings, JSON-encoded instances/arguments). See Core Functionality → Serialization.
Adapter
Where: Integrations/MS_DI/SimplEnteinerServiceProvider.cs, SimplEnteinerServiceScope.cs
SimplEnteinerServiceProvider adapts IScope to the Microsoft.Extensions.DependencyInjection abstractions IServiceProvider, ISupportRequiredService, and IServiceScopeFactory. This lets any framework or library coded against the standard MS.DI abstractions consume a SimplEnteiner container transparently. See Architecture → DI Integration.
Continue to Namespace Structure.
Namespace Structure and Module Organisation
All production code lives under the SimplEnteiner project. The table below maps namespaces to folders and summarizes their responsibility.
| Namespace | Folder | Responsibility |
|---|---|---|
SimplEnteiner | SimplEnteiner/ (root, TypeAnalyzes.cs, TypeAnalyzes.TypeCondition.cs) | Standalone reflection/type-introspection toolkit (TypeAnalyzes static partial class, TypeCondition flags enum, CircularDependencyException). Usable independently of the DI container. |
SimplEnteiner.Analysis | Analysis/ | ReachabilityAnalyzer — BFS-based reachability computation over a dependency graph. |
SimplEnteiner.Core | Core/ | DIContainer (public entry point), Delegates.cs (ResolverFunc), Constants.cs, BindExample.cs (internal usage examples, not part of the public API contract). |
SimplEnteiner.Core.Attributes | Core/Attributes/ | InjectAttribute, IdAttribute — mark constructors/members for injection and disambiguate conditional/id-based bindings. |
SimplEnteiner.Core.Binder | Core/Binder/ | BindingBuilder — mutable, staged binding descriptor. |
SimplEnteiner.Core.Binder.BuilderStages | Core/Binder/BuilderStages/ | Stage, InitialStage, ImplementationStage, LifetimeStage, OptionsStage, FinalStage, BuilderStateMachine — internal staged validation engine (all internal). |
SimplEnteiner.Core.Binder.Interfaces | Core/Binder/Interfaces/ | Public fluent contracts: IBinder, IBindingTo(<T>), IBindingLifetime(<T>), IBindingOptions(<T>), IBindingDecorate(<T>), IBindingDecorateLifetime(<T>), and the internal IBindingTarget. |
SimplEnteiner.Core.Binder.Implementations | Core/Binder/Implementations/ | Internal concrete implementations of the interfaces above: BindingTo(<T>), BindingLifetime(<T>), BindingOptions(<T>), BindingDecorate(<T>), BindingDecorateLifetime(<T>). |
SimplEnteiner.Core.RegistrationService | Core/RegistrationService/ | IRegistry/Registry (registration storage & validation), Registration, DecoratorRegistration. |
SimplEnteiner.Core.RegistrationService.Factory | Core/RegistrationService/Factory/ | IRegistryFactory/RegistryFactory — pluggable Registry creation strategy. |
SimplEnteiner.Core.ResolverService | Core/ResolverService/ | IResolver/Resolver — the resolution algorithm (constructor/member injection, generic wrappers, decorators, lifetime storage). |
SimplEnteiner.Core.ScopeFeature | Core/ScopeFeature/ | IScope/Scope (hierarchical container), ScopeCreationConfig, ConditionalKey, ResolutionContext (internal per-resolve state). |
SimplEnteiner.Core.ScopeFactory | Core/ScopeFactory/ | IScopeFactory/DefaultScopeFactory — pluggable child-Scope creation strategy. |
SimplEnteiner.Core.Lifecycle | Core/Lifecycle/ | LifeTime enum, IInitializable, IAsyncInitializable, IStartable, internal ICleanupService/CleanupService, internal IInterfaceInvoker/InterfaceInvoker. |
SimplEnteiner.Core.RepositoryService | Core/RepositoryService/ | IRepositoryService/RepositoryService — dictionary-like store for singleton instances with disposal tracking, wrapping a Dictionary<Type,object> behind numerous collection interfaces. |
SimplEnteiner.Core.ConventionBinding.Interfaces | Core/ConventionBinding/Interfaces/ | IConventionBuilder — fluent contract for assembly-scanning based auto-registration. |
SimplEnteiner.Core.ConventionBinding.Implementations | Core/ConventionBinding/Implementations/ | ConventionBuilder, internal ConventionBindType flags enum. |
SimplEnteiner.Core.InstallerService.Interfaces | Core/InstallerService/Interfaces/ | IInstaller — a unit of registration logic that can be discovered and invoked via ScanAndInstall. |
SimplEnteiner.Core.Configuration | Core/Configuration/ | Internal serializable DTOs: ScopeConfig, BindingConfig, DecoratorConfig, used by the export/import feature. |
SimplEnteiner.Utilities | Utilities/ | Cross-cutting extension methods: ThrowExtensions (argument guarding), ListExtensions (binary-search insertion for decorator ordering), ContainerExtensions (ScanAndInstall). |
SimplEnteiner.Integrations.MS_DI | Integrations/MS_DI/ | Extensions.AddSimplEnteiner, SimplEnteinerServiceProvider, SimplEnteinerServiceScope — Microsoft.Extensions.DependencyInjection adapter layer. |
Visibility Conventions
- Types that are part of the intended public extension surface (fluent binder interfaces,
DIContainer,IScope, lifecycle interfaces, attributes,TypeAnalyzes) arepublic. - Types that are implementation details consumers should not depend on directly (
Registry,Resolver,RepositoryService,CleanupService,InterfaceInvoker, allBinder.ImplementationsandBinder.BuilderStagesclasses) are markedinternal. - A small number of types are
publicbecause they need to flow through public interface members even though direct construction by consumers is unusual:Registration,DecoratorRegistration,BindingBuilder,ConventionBuilder.
Test Project Structure
The SimplEnteinerTests project mirrors this structure with an xUnit test per feature area, plus a TestTypes/ folder containing purpose-built fixture types (interfaces, classes, structs, enums, attributes, delegates) used across many tests without polluting production code. See Testing and Quality Assurance for details.
Continue to Dependency Injection Integration.
Dependency Injection Integration
SimplEnteiner is itself a DI container, but it also plugs into Microsoft.Extensions.DependencyInjection (MS.DI) so it can be used as the backing container for ASP.NET Core, Generic Host, or any framework that consumes IServiceProvider/IServiceScopeFactory.
Source: Integrations/MS_DI/
Registration Entry Point
using Microsoft.Extensions.DependencyInjection;
using SimplEnteiner.Core;
using SimplEnteiner.Integrations.MS_DI;
IServiceCollection services = new ServiceCollection();
services.AddSimplEnteiner(container =>
{
container.Bind<IGreeter>().To<Greeter>().AsSingle().Apply();
container.Bind<IClock>().To<SystemClock>().AsScoped().Apply();
});
Extensions.AddSimplEnteiner (see Extensions.cs):
public static IServiceCollection AddSimplEnteiner(this IServiceCollection services, Action<DIContainer> configure)
{
DIContainer container = new DIContainer();
configure.ThrowIfArgumentNull().Invoke(container);
container.Build();
services.AddSingleton<IServiceProvider>(new SimplEnteinerServiceProvider(container));
services.AddSingleton<IServiceScopeFactory>(new SimplEnteinerServiceProvider(container));
return services;
}
Behavior notes:
- A brand-new
DIContaineris created, configured synchronously via theconfigurecallback, then immediatelyBuild()-ed — meaning all validation (dependency graph resolvability, generic constraint checks) happens before returning fromAddSimplEnteiner, so misconfiguration surfaces immediately at startup rather than lazily at first resolution. - Both
IServiceProviderandIServiceScopeFactoryare registered as singletons pointing to the sameSimplEnteinerServiceProviderinstance (which implements both interfaces plusISupportRequiredService). - This call does not remove or interact with any services already registered directly on the
IServiceCollectionviaservices.AddSingleton<T>()etc. — it purely adds anIServiceProvider/IServiceScopeFactorypair. Whether the host actually uses this custom provider depends on the hosting infra (e.g.UseServiceProviderFactoryin ASP.NET Core) — SimplEnteiner does not attempt to hijackIServiceCollectioninternals.
SimplEnteinerServiceProvider
SimplEnteinerServiceProvider.cs implements three MS.DI interfaces over a wrapped IScope:
public class SimplEnteinerServiceProvider : IServiceProvider, ISupportRequiredService, IServiceScopeFactory
{
public object GetService(Type serviceType) => _container.Resolve(serviceType);
public object GetRequiredService(Type serviceType) =>
_container.Resolve(serviceType) ?? throw new InvalidOperationException($"Service {serviceType} not registered.");
public IServiceScope CreateScope() => new SimplEnteinerServiceScope(_container.CreateScope());
}
GetServicesimply forwards toIScope.Resolve(Type). Any exception raised inside the SimplEnteiner resolution pipeline (e.g. "No binding found for X") propagates unchanged — MS.DI'sGetServicecontract nominally expectsnullfor unresolvable optional services, so callers relying on gracefulnullreturns for unregistered types should be aware SimplEnteiner throws instead for types that are not concrete classes and have no binding (see Resolution Workflow).CreateScope()delegates toIScope.CreateScope(), producing a genuine hierarchical child scope (not just an MS.DI-style logical scope) — singletons remain shared with the root, scoped services get fresh instances per created scope, exactly matching SimplEnteiner's own scope semantics.
SimplEnteinerServiceScope
SimplEnteinerServiceScope.cs wraps a child IScope inside a new SimplEnteinerServiceProvider and exposes it via the standard IServiceScope.ServiceProvider property. Disposing the IServiceScope disposes the underlying provider (if disposable), which in turn is expected to be released by the owning scope's lifetime management.
Integration Diagram
sequenceDiagram
participant Host as ASP.NET Core / Generic Host
participant Ext as Extensions.AddSimplEnteiner
participant Container as DIContainer
participant Provider as SimplEnteinerServiceProvider
participant Scope as IScope (child)
Host->>Ext: services.AddSimplEnteiner(configure)
Ext->>Container: new DIContainer()
Ext->>Container: configure(container)
Ext->>Container: Build()
Ext->>Provider: new SimplEnteinerServiceProvider(container)
Ext->>Host: services.AddSingleton<IServiceProvider>(Provider)
Ext->>Host: services.AddSingleton<IServiceScopeFactory>(Provider)
Host->>Provider: CreateScope()
Provider->>Container: CreateScope()
Container-->>Provider: child IScope
Provider->>Scope: wrap in SimplEnteinerServiceScope
Host->>Provider: GetService(typeof(IMyService))
Provider->>Container: Resolve(typeof(IMyService))
Container-->>Provider: instance
Provider-->>Host: instance
Limitations of the Current Integration
- There is no automatic translation of services already registered on the
IServiceCollection(viaAddSingleton,AddScoped, etc.) into SimplEnteiner bindings — all bindings must be configured explicitly inside theconfigurecallback passed toAddSimplEnteiner. IServiceProviderIsServiceand other newer MS.DI diagnostic interfaces are not implemented.- Keyed services (
IServiceCollectionkeyed registration APIs) are not directly mapped; SimplEnteiner's ownWithId(...)/[Id]mechanism is a related but separate concept (see API Reference → Attributes and Delegates).
Continue to API Reference → Container and Scope.
Container and Scope
DIContainer
Namespace: SimplEnteiner.Core
Source: Core/DIContainer.cs
public class DIContainer : IScope, IBindingTarget
DIContainer is the top-level, public entry point of the library. It owns a root Scope and delegates virtually every member to it. Create one instance per application (or per logical composition root).
Construction
public DIContainer();
Creates an empty root container, wiring up default implementations for IResolver, IScopeFactory, IRegistryFactory, and IRepositoryService (see ConfigureConfig in the source).
Binding Members
| Member | Signature | Description |
|---|---|---|
Bind<TService>() | IBindingTo<TService> Bind<TService>() | Starts a fluent binding for TService. See Binder API. |
Bind(Type) | IBindingTo Bind(Type serviceType) | Non-generic overload. Throws ArgumentNullException if serviceType is null. |
Decorate<TService>() | IBindingDecorate<TService> Decorate<TService>() | Starts a fluent decorator registration. See Decorators. |
Decorate(Type) | IBindingDecorate Decorate(Type interfaceType) | Non-generic overload. |
BindConvention(Action<IConventionBuilder>) | void BindConvention(Action<IConventionBuilder> configure) | Configures and immediately executes assembly-scanning based registration. See Convention-Based Binding. |
Install(IInstaller) | virtual void Install(IInstaller installer) | Invokes installer.Install(this) — a simple modularization hook (see IInstaller). |
Bindings created via Bind<T>()/Bind(Type) are added to an internal pending list (_pendingBindings) and are not immediately visible to the registry — they are executed (staged pipeline run to completion) and registered either:
- Eagerly, when
.Apply()is called on the resultingIBindingOptions, or - In batch, when
Build()is called (which flushes any bindings that were never explicitlyApply()-ed).
Resolution Members
| Member | Signature |
|---|---|
Resolve<TService>() | TService Resolve<TService>() |
Resolve(Type) | object Resolve(Type type) |
Resolve<T>(object id) | T Resolve<T>(object id) |
Resolve(Type, object) | object Resolve(Type interfaceType, object id) |
ResolveAsync<TService>() | Task<TService> ResolveAsync<TService>() |
ResolveAsync(Type) | Task<object> ResolveAsync(Type type) |
ResolveAsync<T>(object id) | Task<T> ResolveAsync<T>(object id) |
ResolveAsync(Type, object) | Task<object> ResolveAsync(Type interfaceType, object id) |
The id-overloads resolve a conditional binding registered with WithId(id) (see Attributes and Delegates). The Async overloads resolve synchronously and then, if the resulting instance implements IAsyncInitializable, await its InitializeAsync() method before returning. See Resolution Workflow.
Lifecycle / Scope Members
| Member | Signature | Description |
|---|---|---|
Build() | void Build() | Flushes pending bindings, validates the whole registry (Registry.ValidateAll), and invokes IStartable.Start() on all eagerly-instantiated singletons (and recursively on all child scopes). Must be called before resolving from a freshly configured container in most real usage (though Resolve can technically work on partially-built containers for ad-hoc/test scenarios). |
CreateScope() | IScope CreateScope() | Creates and returns a new child Scope. |
GetChildrens() / GetChildrens(List<IScope>) | — | Enumerates direct child scopes. |
RemoveChildren(IScope child) | void RemoveChildren(IScope child) | Detaches a child scope. |
Parent | IScope Parent { get; } | Always null for the root DIContainer. |
IsRoot | bool IsRoot { get; } | Always true for DIContainer. |
Registry | IRegistry Registry { get; } | Exposes the root scope's registry (read access to ExactBindings, OpenGenericBindings, ConditionalBindings, DecoratorBindings). |
Dispose() / DisposeAsync() | — | Disposes the root scope: clears scoped instances, disposes all tracked singletons, disposes the cleanup service. |
Configuration Import/Export
public string ExportConfiguration();
public void ImportConfiguration(string jsonConfiguration);
Serializes/deserializes the entire registration graph (exact, open-generic, conditional, decorator bindings, and all child scopes recursively) to/from JSON. ImportConfiguration disposes the current root scope, replaces it with a freshly configured one populated from the DTO graph, and calls Build(). See Serialization for the DTO shapes and caveats (e.g., factory delegates and live instances are not round-tripped faithfully for non-trivial types).
Reachability Analysis
public void AnalyzeReachability(IEnumerable<Type> roots, Type injectAttribute);
Delegates to the root scope's Registry.AnalyzeReachability, throwing InvalidOperationException if there are unreachable registrations or missing bindings for non-concrete reachable types. See Reachability Analysis and Validation.
IScope / Scope
Namespace: SimplEnteiner.Core.ScopeFeature
Source: Core/ScopeFeature/IScope.cs, Core/ScopeFeature/Scope.cs
public interface IScope : IDisposable, IBinder, IAsyncDisposable
{
IScope Parent { get; }
IRegistry Registry { get; }
bool IsRoot { get; }
IScope[] GetChildrens();
void GetChildrens(List<IScope> results);
IScope CreateScope();
void RemoveChildren(IScope child);
object Resolve(Type type);
T Resolve<T>();
Task<object> ResolveAsync(Type type);
Task<T> ResolveAsync<T>();
object Resolve(Type type, object id);
T Resolve<T>(object id);
Task<object> ResolveAsync(Type type, object id);
Task<T> ResolveAsync<T>(object id);
void Install(IInstaller installer);
void Build();
}
IScope extends IBinder (see Binder API), so every scope — root or child — exposes the same Bind/Decorate/BindConvention fluent surface as DIContainer.
Scope is the concrete, publicly-visible implementation class (used directly for child scopes; DIContainer wraps a root Scope internally). Key internal state:
IRegistry _registry— this scope's own bindings (parent registrations are consulted separately by walkingParentchains).IRepositoryService _singletons— shared with the root scope across the whole tree (singletons live process/container-wide, not per-scope).Dictionary<Type, object> _scopedInstances— local to this scope;Scoped-lifetime instances are stored here and never shared with parent/children.List<IScope> _childrens— direct children, used for cascadingBuild(),AnalyzeReachability, and disposal notification.ICleanupService _cleanupService— tracksTransient/Cacheddisposables created within this scope for disposal when the scope itself is disposed.
Creating and Disposing Child Scopes
using IScope child = container.CreateScope();
var service = child.Resolve<IMyScopedService>();
// child.Dispose() at end of `using` releases Scoped + Transient/Cached instances created in this scope
Disposing a non-root scope calls Parent.RemoveChildren(this) to detach itself from the tree; disposing the root scope instead clears and disposes the (tree-wide) singleton repository.
Internal Members (not part of the public contract)
Scope exposes several internal members used by Resolver/Registry/DIContainer but not intended for direct external use: AddRegister, GetAllExactRegistration, GetAllOpenGenericRegistration, FindExactRegistration, FindOpenGenericRegistration, FindConditionalRegistration, GetSingleton, GetScoped, GetDecoratorRegistrations, StoreSingleton, StoreScoped, TrackDisposable, InitializeFromDto, ValidateAll, Start. These are documented here for maintainers; see the source file for full signatures.
ScopeCreationConfig
Namespace: SimplEnteiner.Core.ScopeFeature
Source: Core/ScopeFeature/ScopeCreationConfig.cs
A small mutable configuration object shared by every scope in a tree, holding the pluggable strategy instances:
public sealed class ScopeCreationConfig
{
public IResolver Resolver { get; set; }
public IRegistryFactory RegistryFactory { get; set; }
public IScopeFactory ScopeFactory { get; set; }
public IRepositoryService SingletonRepository { get; set; }
}
All setters throw ArgumentNullException (via ThrowIfArgumentNull) if assigned null. DIContainer populates this once (in ConfigureConfig) with the built-in default implementations and passes the same instance down to every child scope, which is how singletons remain shared tree-wide while registries stay independent per-scope.
Continue to Binder API.
Fluent Binder API
Namespace: SimplEnteiner.Core.Binder.Interfaces (public contracts), SimplEnteiner.Core.Binder.Implementations (internal implementations)
Sources: Core/Binder/Interfaces/, Core/Binder/Implementations/
This is the primary public API surface used to register services. Every step returns a narrower interface, guiding the caller through: target → implementation → lifetime → options → apply.
flowchart LR
A["IBinder.Bind<T>()"] --> B["IBindingTo<T>"]
B -->|To/ToSelf/ToMethod/ToInstance| C["IBindingLifetime<T>"]
C -->|AsSingle/AsTransient/AsCached/AsScoped| D["IBindingOptions<T>"]
D -->|WithArguments/WhenInjectedInto/WithId/OnActivation/OnRelease| D
D -->|Apply| E[Registered in Registry]
A2["IBinder.Decorate<T>()"] --> F["IBindingDecorate<T>"]
F -->|With<TDecorator>order| G["IBindingDecorateLifetime<T>"]
G -->|AsSingle/AsTransient/AsCached/AsScoped| H[Registered as decorator]
IBinder
public interface IBinder
{
IBindingTo<T> Bind<T>();
IBindingTo Bind(Type interfaceType);
IBindingDecorate<TService> Decorate<TService>();
IBindingDecorate Decorate(Type interfaceType);
void BindConvention(Action<IConventionBuilder> configure);
}
Implemented by DIContainer and Scope. This is the interface consumers primarily interact with — the entry point of every binding.
IBindingTo<TInterface> / IBindingTo
public interface IBindingTo<TInterface>
{
IBindingLifetime<TInterface> To<TImplementation>() where TImplementation : TInterface;
IBindingLifetime<TInterface> ToSelf();
IBindingLifetime<TInterface> ToMethod(Func<TInterface> factory);
IBindingLifetime<TInterface> ToInstance(TInterface instance);
}
| Method | Behavior |
|---|---|
To<TImplementation>() | Binds TInterface to a concrete TImplementation. Compile-time constrained: TImplementation : TInterface. |
ToSelf() | Binds TInterface to itself as implementation (only valid/useful when TInterface is itself instantiable, e.g. binding a concrete class to itself). |
ToMethod(Func<TInterface>) | Binds to a factory delegate invoked on every resolution requiring a new instance (subject to lifetime caching). Throws ArgumentNullException if factory is null. |
ToInstance(TInterface instance) | Binds to an already-constructed instance. Internally forces the registration's lifetime to Singleton regardless of a later .AsX() call (see Registry.CreateRegistration: if (instance != null) lifeTime = LifeTime.Singleton;). Throws ArgumentNullException if instance is null. |
container.Bind<IGreeter>().To<Greeter>().AsSingle().Apply();
container.Bind<IClock>().ToMethod(() => new SystemClock(DateTimeOffset.UtcNow)).AsTransient().Apply();
container.Bind<IConfig>().ToInstance(new AppConfig { Env = "prod" }).AsSingle().Apply(); // instance forces Singleton anyway
The non-generic IBindingTo mirrors all four members using Type/object instead of generics — useful for reflection-driven or convention-based registration.
IBindingLifetime<TInterface> / IBindingLifetime
public interface IBindingLifetime<TInterface>
{
IBindingOptions<TInterface> AsSingle();
IBindingOptions<TInterface> AsTransient();
IBindingOptions<TInterface> AsCached();
IBindingOptions<TInterface> AsScoped();
}
Maps directly to the four LifeTime values. See Scopes, Lifetimes and Disposal for exact semantics of each.
IBindingOptions<TInterface> / IBindingOptions
public interface IBindingOptions<TInterface>
{
IBindingOptions<TInterface> WithArguments(params object[] args);
IBindingOptions<TInterface> WhenInjectedInto<T>();
IBindingOptions<TInterface> WithId(object id);
IBindingOptions<TInterface> OnActivation(Action<TInterface> onActivation);
IBindingOptions<TInterface> OnRelease(Action<TInterface> onRelease);
void Apply();
}
| Method | Behavior |
|---|---|
WithArguments(params object[] args) | Supplies extra constructor arguments that are matched by assignable type against the implementation's constructor parameters (see Resolver.ResolveConstructorWithArguments); parameters not satisfied by an argument are resolved normally from the container. |
WhenInjectedInto<T>() | Registers this binding as conditional: it is only used when the interface is being injected as a dependency of T (matched via ResolutionContext.RequestType during constructor/member parameter resolution). Mutually exclusive in practice with WithId — internally both route into the same ConditionalBindings dictionary keyed by (InterfaceType, Id ?? ConditionType). |
WithId(object id) | Registers this binding as conditional, keyed by an explicit id object instead of a consumer type. Resolved via Resolve<T>(id)/Resolve(Type, id) or automatically when a constructor parameter/field/property is marked with [Id(id)] (see Attributes and Delegates). |
OnActivation(Action<TInterface>) | Callback invoked immediately after the instance is constructed and member-injected (before being returned to the caller). Runs on every activation (i.e. every time a new instance is created, not on cache/singleton/scoped hits). |
OnRelease(Action<TInterface>) | Callback invoked when the instance is disposed by the owning CleanupService/RepositoryService (invoked before Dispose()/DisposeAsync() is called on the instance itself, if it implements IDisposable/IAsyncDisposable). |
Apply() | Finalizes the staged builder (BindingBuilder.ExecuteAllStages()) and registers it into the target's Registry. Required unless Build() is called later (which flushes any un-applied pending bindings automatically). |
container.Bind<IRepository>()
.To<SqlRepository>()
.AsScoped()
.WithArguments("Server=.;Database=App;")
.OnActivation(r => Console.WriteLine($"{r.GetType().Name} activated"))
.OnRelease(r => Console.WriteLine($"{r.GetType().Name} released"))
.Apply();
IBindingDecorate<TInterface> / IBindingDecorate
public interface IBindingDecorate<TInterface>
{
IBindingDecorateLifetime<TInterface> With<TImplementation>(int? order = null) where TImplementation : TInterface;
}
Starts a decorator registration for TInterface. order controls the wrapping order when multiple decorators target the same interface (lower order wraps first / closer to the base implementation — decorators are applied in ascending Order, see Registry.AddDecorator's binary-insert-by-order logic). If order is omitted, it defaults to 0 for the first decorator and previous.Order + 1 for subsequent ones.
For the non-generic IBindingDecorate.With(Type implementation, int? order = null), BindingDecorate.Validate enforces that implementation is actually compatible with the target interface (handles exact, closed-generic, and open-generic-definition interface shapes), throwing InvalidOperationException otherwise.
IBindingDecorateLifetime<TInterface> / IBindingDecorateLifetime
public interface IBindingDecorateLifetime<TInterface>
{
void AsSingle();
void AsTransient();
void AsCached();
void AsScoped();
}
Unlike the main binding pipeline, each of these methods implicitly applies the registration (Apply() is called internally right after SetLifetime) — there is no separate .Apply() step for decorators. See Decorators for full semantics including generic decorator resolution.
container.Decorate<IGreeter>().With<LoggingGreeterDecorator>(order: 0).AsTransient();
container.Decorate<IGreeter>().With<CachingGreeterDecorator>(order: 1).AsSingle();
BindingBuilder (internal engine, public class)
Namespace: SimplEnteiner.Core.Binder
Source: Core/Binder/BindingBuilder.cs
While consumers never construct a BindingBuilder directly (it is created internally by Bind<T>()/Decorate<T>()), it is a public class because it flows through internal-visible integration points and is useful to understand for advanced scenarios (custom IScopeFactory/IRegistryFactory implementations, diagnostics). Key public members:
public Type InterfaceType { get; }
public Type ImplementationType { get; }
public Func<object> FactoryMethod { get; }
public object Instance { get; }
public LifeTime LifeTime { get; }
public List<object> Arguments { get; }
public Type ConditionType { get; }
public object Id { get; }
public int? Order { get; }
public Action<object> OnActivation { get; }
public Action<object> OnRelease { get; }
public bool IsComplete { get; } // true once the FinalStage has been reached
public bool IsRegistered { get; } // true once registered in a Registry (prevents double-registration)
Its Set* methods (SetImplementation, SetFactoryMethod, SetInstance, SetLifetime, SetCondition, SetId, SetOnActivation, SetOnRelease, AddDecorator) are the low-level operations that the fluent Binder.Implementations classes call; each transition is validated against the internal BuilderStateMachine (see Design Patterns → Fluent Builder + Finite State Machine) and throws InvalidOperationException if called out of order (e.g., setting the implementation twice).
Continue to Registration and Resolution.
Registration and Resolution
IRegistry / Registry
Namespace: SimplEnteiner.Core.RegistrationService
Source: Core/RegistrationService/IRegistry.cs, Core/RegistrationService/Registry.cs
public interface IRegistry
{
IReadOnlyDictionary<ConditionalKey, Registration> ConditionalBindings { get; }
IReadOnlyDictionary<Type, List<DecoratorRegistration>> DecoratorBindings { get; }
IReadOnlyDictionary<Type, Registration> ExactBindings { get; }
IReadOnlyDictionary<Type, Registration> OpenGenericBindings { get; }
void Add(BindingBuilder bindingBuilder);
void AddConditionalRegistration(Type key, object id, Registration value);
void AddDecorator(DecoratorRegistration registration);
void AddExactRegistration(Type key, Registration value);
void AddOpenGenericRegistration(Type key, Registration value);
void AnalyzeReachability(IEnumerable<Type> roots, Type injectAttribute);
bool CanResolveAllDependencies(Type type, Type injectAttribute);
bool CanResolveGeneric(Type interfaceType);
void ValidateAll();
}
Every Scope owns exactly one IRegistry (created via IRegistryFactory). It is a per-scope store split into four buckets:
| Bucket | Key | Populated when |
|---|---|---|
ExactBindings | Type (interface/service type, non-open-generic, no condition/id) | Bind<T>()...Apply() for a closed type without WithId/WhenInjectedInto |
OpenGenericBindings | Type (open generic definition, e.g. typeof(IRepository<>)) | Bind(typeof(IRepository<>))...Apply() |
ConditionalBindings | ConditionalKey ((Type interfaceType, object id)) | WithId(id) or WhenInjectedInto<T>() (using the consumer Type as the id) |
DecoratorBindings | Type (interface being decorated) → List<DecoratorRegistration> | Decorate<T>().With<TDecorator>().AsX() |
Registry.Add(BindingBuilder)
Called by Scope/DIContainer when a binding's staged pipeline completes. It:
- Validates the builder (
Validate): the implementation must be a concrete, injectable class (IsConcreteClass(isIgnoreGeneratedType: true)), assignable/compatible with the interface (handling exact, open-generic-definition, and closed-generic-with-constraints cases), and must expose a resolvable constructor (GetInjectableConstructor). - Builds a
Registration(compiling a factory delegate viaGetFactoryMethod, unless a customFactoryMethodwas supplied). - Routes the registration into the correct bucket based on whether
ConditionType/Idis set, or whetherInterfaceType.IsGenericTypeDefinition.
Registry.ValidateAll()
Called from Scope.Build() → Scope.ValidateAll(). Iterates all ExactBindings and throws InvalidOperationException if any registered type's dependency graph cannot be fully resolved against the currently-known exact bindings (via TypeAnalyzes.CanResolveAllDependencies, which also detects cycles and throws TypeAnalyzes.CircularDependencyException if one is found). Also validates that every open-generic implementation is concrete and has an injectable constructor.
Registry.AnalyzeReachability(roots, injectAttribute)
Computes, via ReachabilityAnalyzer, the set of types reachable (via constructor/member dependencies) starting from roots. Then:
- Unreachable services — registered exact bindings that are never referenced from any root — are reported.
- Missing bindings — reachable, non-concrete types that have no exact binding — are reported.
If either list is non-empty, throws InvalidOperationException with a combined message. See Reachability Analysis and Validation.
Registration
Namespace: SimplEnteiner.Core.RegistrationService
Source: Core/RegistrationService/Registration.cs
public class Registration
{
public Registration(Type implementation, LifeTime lifetime, Func<object[], object> factory, object instance, object[] arguments = null);
public Type Implementation { get; }
public LifeTime Lifetime { get; }
public Func<object[], object> Factory { get; }
public object Instance { get; }
public object[] Arguments { get; }
public Action<object> OnActivation { get; set; }
public Action<object> OnRelease { get; set; }
}
An immutable (except for the two Action callbacks) descriptor of "how to make an instance": either a pre-existing Instance, or a Factory delegate to invoke with resolved constructor arguments.
DecoratorRegistration
Namespace: SimplEnteiner.Core.RegistrationService
Source: Core/RegistrationService/DecoratorRegistration.cs
public class DecoratorRegistration
{
public DecoratorRegistration(Type interfaceType, Type decoratorType, int? order, LifeTime lifetime, ConstructorInfo constructor, Func<object[], object> factory);
public Type InterfaceType { get; }
public Type DecoratorType { get; }
public int? Order { get; set; }
public LifeTime Lifetime { get; }
public ConstructorInfo Constructor { get; }
public Func<object[], object> Factory { get; }
}
See Decorators for how these are resolved and ordered.
IRegistryFactory / RegistryFactory
Source: Core/RegistrationService/Factory/IRegistryFactory.cs
public interface IRegistryFactory
{
IRegistry CreateRegistry();
}
Pluggable strategy for creating a fresh IRegistry per scope; RegistryFactory.CreateRegistry() simply returns new Registry().
IResolver / Resolver
Namespace: SimplEnteiner.Core.ResolverService
Source: Core/ResolverService/IResolver.cs, Core/ResolverService/Resolver.cs
public interface IResolver
{
object Resolve(Type interfaceType, Scope scope, object id = null);
T Resolve<T>(Scope scope, object id = null);
}
The Resolver is the algorithmic heart of the library. See Core Functionality → Resolution Workflow for a complete, step-by-step description of how a single Resolve call is handled, including:
- Generic wrapper resolution (
IEnumerable<T>,Lazy<T>,Func<T>). - Registration lookup order (conditional by id → conditional by consumer type → exact → closed-generic-from-open-generic → self-registration-if-concrete-class).
- Constructor/member injection, including
[Id]-attribute-based per-parameter conditional resolution. - Lifetime-specific instance storage/retrieval (
GetExistingInstance/StoreInstance). - Decorator wrapping (
ResolveDecorators).
ConditionalKey
Namespace: SimplEnteiner.Core.ScopeFeature
Source: Core/ScopeFeature/ConditionalKey.cs
public struct ConditionalKey
{
public Type interfaceType;
public object id;
// value-equality, deconstruction, and implicit conversions to/from (Type, object) tuples
}
A lightweight value-type key used by ConditionalBindings. Supports implicit conversion to/from (Type interfaceType, object id) tuples for ergonomic dictionary indexing (_conditionalBindings[(interfaceType, id)] = registration;).
ResolutionContext (internal)
Namespace: SimplEnteiner.Core.ScopeFeature
Source: Core/ScopeFeature/ResolutionContext.cs
Not part of the public API, but important to understand for maintainers: a short-lived, IDisposable, per-Resolve()-call object carrying:
CurrentScope— theScopethe resolution started from.Id— the currently active conditional id (temporarily swapped while resolving nested[Id]-attributed parameters/members).RequestType— the "who is asking" type, used forWhenInjectedInto<T>()conditional matching; temporarily swapped to the type currently being constructed while resolving its dependencies.CachedInstances— aConcurrentDictionary<Type, object>used to implement theCachedlifetime within a single top-levelResolve()call (see Scopes, Lifetimes and Disposal).
Continue to Lifecycle Interfaces and Enums.
Lifecycle Interfaces and Enums
Namespace: SimplEnteiner.Core.Lifecycle
Source: Core/Lifecycle/
LifeTime
public enum LifeTime
{
Transient = 0,
Singleton = 1,
Cached = 2,
Scoped = 3,
}
| Value | Storage | Sharing scope | Disposal |
|---|---|---|---|
Transient | Not cached — a new instance is created on every resolution. | None. | Tracked by the current scope's CleanupService and disposed when that scope is disposed. |
Singleton | Stored in the tree-wide shared IRepositoryService (owned by the root scope, shared by reference into every ScopeCreationConfig). | Entire scope tree (root + all descendants). | Disposed only when the root scope is disposed. |
Cached | Stored in the current top-level Resolve() call's ResolutionContext.CachedInstances. | Only within the object graph built during a single Resolve() invocation — i.e., if two different dependencies of the same root request both need the cached service, they get the same instance; two separate Resolve() calls get two different instances. | Tracked by the current scope's CleanupService. |
Scoped | Stored in the current scope's private _scopedInstances dictionary. | Only within the scope it was created in (not visible to sibling or child scopes; each child scope resolves its own instance). | Disposed when that specific scope is disposed. |
See Scopes, Lifetimes and Disposal for a full walk-through with diagrams.
IInitializable
public interface IInitializable
{
void Initialize();
}
Implement on any resolvable class to receive a synchronous callback right after construction and member injection, but before the instance is returned to the caller and before OnActivation fires. Invoked by Resolver.ResolveRegistration via the internal IInterfaceInvoker, for every newly-created instance (including decorator instances, see Resolver.CreateDecoratorInstance).
public class ReportGenerator : IInitializable
{
public void Initialize()
{
// Runs once, right after this instance is constructed.
}
}
IAsyncInitializable
public interface IAsyncInitializable
{
Task InitializeAsync();
}
Unlike IInitializable, this is not invoked automatically by the synchronous Resolve<T>()/Resolve(Type) path. It is only invoked when the caller explicitly resolves via ResolveAsync<T>() / ResolveAsync(Type) (on IScope/DIContainer/Scope), which resolves synchronously first and then awaits InitializeAsync():
public class DatabaseConnection : IAsyncInitializable
{
public async Task InitializeAsync()
{
await OpenConnectionAsync();
}
}
var db = await container.ResolveAsync<DatabaseConnection>();
IStartable
public interface IStartable
{
void Start();
}
Invoked once, only for singleton-lifetime exact registrations, during Scope.Build() → Scope.Start(). For every ExactBindings entry:
- If the registration already has a live
Instance(i.e. it was bound viaToInstance(...)),Start()is invoked on it directly. - Else if
Lifetime == LifeTime.Singleton, the service is eagerly resolved (which triggers construction, causing it to be stored in the singleton repository) and thenStart()is invoked. - Non-singleton registrations (
Transient,Scoped,Cached) are not eagerly instantiated and therefore never receive an automaticStart()call through this mechanism.
Build() recurses into child scopes (_childrens[i].Build()), so IStartable.Start() fires for singleton services registered at any level of the scope tree, in parent-then-children traversal order.
public class BackgroundWorker : IStartable
{
public void Start()
{
// Runs once when the container/scope tree is Build()-ed,
// only because this service is registered AsSingle().
}
}
container.Bind<BackgroundWorker>().ToSelf().AsSingle().Apply();
container.Build(); // BackgroundWorker.Start() is invoked here
IInterfaceInvoker (internal)
Source: Core/Lifecycle/IInterfaceInvoker.cs, InterfaceInvoker.cs
internal interface IInterfaceInvoker
{
void Invoke<T>(object instance);
Task InvokeAsync<T>(object instance);
}
Internal helper used by Scope/Resolver to dispatch to IInitializable.Initialize(), IStartable.Start(), and IAsyncInitializable.InitializeAsync() based on the generic type argument T — a small, closed-set visitor rather than a general-purpose reflection-based dispatcher.
ICleanupService / CleanupService (internal)
Source: Core/Lifecycle/ICleanupService.cs, CleanupService.cs
internal interface ICleanupService : IDisposable, IAsyncDisposable
{
void AddIfDisposable(object instance, Action<object> onRelease = null);
}
Tracks every created instance that implements IDisposable (regardless of lifetime, except Singleton, which is tracked separately by RepositoryService) inside a scope, together with its optional onRelease callback. Dispose()/DisposeAsync() invokes onRelease for each tracked instance and then disposes it (preferring IAsyncDisposable.DisposeAsync() in the async path when available), clearing the tracked list afterwards. Every Scope owns its own CleanupService instance.
Continue to Attributes and Delegates.
Attributes and Delegates
Namespace: SimplEnteiner.Core.Attributes, SimplEnteiner.Core
Source: Core/Attributes/, Core/Delegates.cs
InjectAttribute
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Constructor | AttributeTargets.Method)]
public sealed class InjectAttribute : Attribute
{
}
The marker attribute the container looks for (via Constants.InjectAttributeType = typeof(InjectAttribute)) to identify:
- Which constructor to use, when a class has more than one public constructor.
TypeAnalyzes.GetInjectableConstructorlooks for exactly one constructor marked[Inject]; if none is marked, it falls back to the public constructor with the most parameters; if more than one constructor is marked[Inject], anExceptionis thrown ("Multiple constructors with InjectAttribute attribute in {type}"). - Which fields/properties/methods to inject, via
TypeAnalyzes.GetInjectableMembers, which scans public and non-public instance fields, properties, and methods for the[Inject]attribute (AllowMultiple = falseis the default since it's not specified, butinherit: trueis used when checkingIsDefined).
public class OrderService
{
[Inject] private readonly ILogger _logger; // field injection
[Inject] public IClock Clock { get; set; } // property injection
public OrderService() { } // ignored (no [Inject], but a marked ctor exists below)
[Inject]
public OrderService(IRepository repository) // used, because it's marked
{
Repository = repository;
}
public IRepository Repository { get; }
[Inject]
public void Configure(IOptions options) // method injection — invoked once, with resolved arguments
{
// ...
}
}
Note: because InjectAttribute targets Constructor too, it can also be applied directly to a single-constructor class without effect (there's nothing to disambiguate), but it's harmless.
IdAttribute
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = false)]
public class IdAttribute : Attribute
{
public IdAttribute(object id);
public object Id { get; }
}
Applied to a constructor parameter, field, or property to request a specific conditional/id-based binding (registered via .WithId(id)) instead of the default binding for that type. Read by Resolver via GetCustomAttribute<IdAttribute>() on the relevant ParameterInfo/FieldInfo/PropertyInfo.
container.Bind<IStorage>().To<LocalStorage>().AsSingle().WithId("local").Apply();
container.Bind<IStorage>().To<CloudStorage>().AsSingle().WithId("cloud").Apply();
public class SyncJob
{
public SyncJob([Id("local")] IStorage source, [Id("cloud")] IStorage destination)
{
Source = source;
Destination = destination;
}
public IStorage Source { get; }
public IStorage Destination { get; }
}
Resolution for an [Id]-attributed member/parameter temporarily overrides ResolutionContext.Id for the duration of that single dependency's resolution (see Resolver.ResolveMember/ResolveConstructorWithArguments), then restores the previous value — so nested dependencies are unaffected unless they too specify their own [Id].
You can also resolve an id-based binding directly, without any attribute, via:
IStorage local = container.Resolve<IStorage>("local");
ResolverFunc Delegate
Source: Core/Delegates.cs
namespace SimplEnteiner.Core
{
public delegate object ResolverFunc(Type interfaceType, Scope scope, object id = null);
}
A public delegate type matching the signature of IResolver.Resolve(Type, Scope, object). It exists as a reusable delegate shape for consumers who want to pass around "a resolve operation" as a first-class value (e.g., in custom IResolver/IScopeFactory implementations), though the built-in Resolver class does not currently expose itself as a ResolverFunc instance directly.
CircularDependencyException
Source: TypeAnalyzes.cs (nested type)
public sealed class CircularDependencyException : Exception
{
public CircularDependencyException(IEnumerable<Type> circularPath);
public IReadOnlyList<Type> CircularPath { get; }
}
Thrown by TypeAnalyzes.CanResolveAllDependencies when HasCyclicDependencies detects a cycle in a type's dependency graph. The message includes the full cycle path (A -> B -> C -> A) for diagnostics, and CircularPath exposes the same list programmatically. See Error Handling and Logging for the full exception catalogue.
Continue to Convention-Based Binding.
Convention-Based Binding
Namespace: SimplEnteiner.Core.ConventionBinding.Interfaces / SimplEnteiner.Core.ConventionBinding.Implementations
Source: Core/ConventionBinding/Interfaces/IConventionBuilder.cs, Core/ConventionBinding/Implementations/ConventionBuilder.cs
Convention-based binding lets you register many services at once by scanning assemblies for types matching a set of filters, instead of writing an explicit Bind<T>() call for each one.
IConventionBuilder
public interface IConventionBuilder
{
IConventionBuilder If(Func<Type, bool> predicate);
IConventionBuilder FromAssembly(Assembly assembly);
IConventionBuilder FromAssemblies(params Assembly[] assemblies);
IConventionBuilder InNamespace(string namespacePrefix);
IConventionBuilder WithAttribute<TAttribute>() where TAttribute : Attribute;
IConventionBuilder BindInterfaces();
IConventionBuilder BindSelf();
IConventionBuilder As(LifeTime lifetime);
void Configure(Action<IBinder> registration);
}
| Method | Effect |
|---|---|
FromAssembly(Assembly) / FromAssemblies(params Assembly[]) | Restricts scanning to the given assemblies. If none are specified, all currently loaded AppDomain.CurrentDomain assemblies are scanned (ConventionBuilder.Build()). Calling FromAssembly twice with the same assembly throws ArgumentException. |
InNamespace(string prefix) | Only considers types whose Namespace starts with prefix (string.StartsWith, ordinal). Passing null throws ArgumentNullException. Default is string.Empty (matches every namespace, including null namespaces guarded internally). |
WithAttribute<TAttribute>() | Only considers types decorated with TAttribute (GetCustomAttribute, non-inherited lookup as coded). |
If(Func<Type,bool>) | Adds an arbitrary additional predicate; can be called multiple times — a candidate type must satisfy all registered predicates (AND-combined). |
BindInterfaces() | For each matched type, binds every interface it implements (type.GetInterfaces()) to that type. Flag can be combined with BindSelf(). |
BindSelf() | For each matched type, binds the type to itself (ToSelf()). |
As(LifeTime) | Sets the lifetime applied to all convention-generated bindings (default LifeTime.Transient). |
Configure(Action<IBinder>) | An escape hatch: an additional callback invoked with the owning scope after all convention-derived bindings have been applied, letting you add manual bindings alongside the scanned ones. |
Usage
container.BindConvention(convention =>
{
convention
.FromAssembly(typeof(Program).Assembly)
.InNamespace("MyApp.Services")
.WithAttribute<ServiceAttribute>()
.If(t => t.Name.EndsWith("Service"))
.BindInterfaces()
.BindSelf()
.As(LifeTime.Scoped);
});
DIContainer.BindConvention / Scope.BindConvention both:
- Create a new
ConventionBuilder(scope). - Invoke the caller's
configurecallback against it (throwsArgumentNullExceptionifconfigureisnull). - Call
builder.Build(), which performs the scan-and-bind (see below), then invokesConfigureBinderif one was set viaConfigure(...). - On
DIContainerspecifically, also flushes any bindings still pending in_pendingBindings(BuildPendings()), so convention bindings + directly-created pending bindings are all registered together.
Scanning Algorithm (ConventionBuilder.Build())
flowchart TD
A[Start Build] --> B{Assemblies specified?}
B -->|No| C[Use AppDomain.CurrentDomain.GetAssemblies]
B -->|Yes| D[Use specified assemblies]
C --> E[For each assembly: GetLoadableTypes]
D --> E
E --> F{Namespace starts with prefix?}
F -->|No| G[Skip type]
F -->|Yes| H{AttributeType set and missing?}
H -->|Yes| G
H -->|No| I{Any If predicate returns false?}
I -->|Yes| G
I -->|No| J[Add to typesForBind]
J --> K[For each type: Bind interfaces / self per BindType flags]
K --> L[Invoke ConfigureBinder if set]
Each qualifying type is bound with the configured LifeTime and immediately .Apply()-ed (see ConventionBuilder.BindLifetime), so convention bindings are registered eagerly during Build(), not deferred.
Interaction with Validation
Because convention binding can register interfaces without concrete implementations found (e.g., an interface with zero or multiple matching implementations gets zero or multiple exact registrations — the last one wins per Dictionary overwrite semantics in Registry.AddExactRegistration), it's recommended to always follow up with container.Build() (which runs Registry.ValidateAll()) to catch any resulting resolution gaps early — see Reachability Analysis and Validation.
IInstaller and ScanAndInstall (Modularization)
Source: Core/InstallerService/Interfaces/IInstaller.cs, Utilities/ContainerExtensions.cs
public interface IInstaller
{
void Install(IScope target);
}
An IInstaller is a reusable unit of registration logic — a natural companion to convention binding for splitting large registration graphs into modules:
public class LoggingInstaller : IInstaller
{
public void Install(IScope target)
{
target.Bind<ILogger>().To<ConsoleLogger>().AsSingle().Apply();
}
}
Two extension methods (ContainerExtensions.ScanAndInstall) discover and invoke every concrete IInstaller implementation found in the given (or all loaded) assemblies:
public static void ScanAndInstall(this DIContainer container, params Assembly[] assemblies);
public static void ScanAndInstall(this IScope scope, params Assembly[] assemblies);
Each discovered installer type is resolved from the container (container.Resolve(type)) before Install is invoked — meaning installer classes can themselves have injected dependencies (as long as those dependencies are already registered or are concrete classes). See the usage example in Core/BindExample.cs (ProgramExample.Main):
DIContainer container = new DIContainer();
container.ScanAndInstall();
container.Build();
Continue to TypeAnalyzes Reflection Toolkit.
TypeAnalyzes Reflection Toolkit
Namespace: SimplEnteiner (top-level, not under SimplEnteiner.Core)
Source: TypeAnalyzes.cs, TypeAnalyzes.TypeCondition.cs
public static partial class TypeAnalyzes
TypeAnalyzes is a static partial class providing the reflection primitives that power the whole container (constructor selection, dependency graph walking, generic constraint checking), but it is designed and documented well enough to be used independently as a general-purpose type-introspection library.
As the class's own XML doc states: "Provides convenient methods for scanning the Types. Has heavy lazy initializable static cache about types."
Caching Model
s_cachedDomainTypes— a lazily-populated, process-wideList<Type>of every loadable type from every assembly known to the cache. Populated on first use ofFindAllAssignableFrom(viaLoadAllDomainTypes()), which seeds it fromAppDomain.CurrentDomain.GetAssemblies().s_assembliesCache— theHashSet<Assembly>backing the above, to avoid re-scanning already-known assemblies.s_injectableConstructorsCache—ConcurrentDictionary<Type, ConcurrentDictionary<Type, ConstructorInfo>>, memoizingGetInjectableConstructorresults per(type, injectAttributeType)pair.- All cache access is guarded by a single
object s_lockfor the domain-types cache; the constructor cache uses lock-freeConcurrentDictionaryoperations. ClearCache()— public API to reset all caches (constructor cache, domain types, initialization flag). Useful in tests or after dynamically loading new assemblies you want re-scanned from scratch.AddAssemblies(IEnumerable<Assembly>)— public API to explicitly add assemblies to the domain cache incrementally without a full rescan, useful for assemblies loaded after the initial scan (e.g., plugins).
flowchart LR
A[First call to FindAllAssignableFrom] --> B{s_initialized?}
B -->|false| C[LoadAllDomainTypes: scan AppDomain assemblies]
C --> D[s_cachedDomainTypes populated]
B -->|true| D
D --> E[FindAllAssignableFromInternal iterates cache]
Method Catalogue
| Method | Signature | Purpose |
|---|---|---|
FindAllNonAbstractClassAssignableFrom | IEnumerable<Type> FindAllNonAbstractClassAssignableFrom(this Type type, bool isGenericType = false) | Shortcut for finding concrete, non-abstract classes assignable from type, optionally filtering to generic or non-generic types only. |
FindAllAssignableFrom(Type, TypeCondition) | IEnumerable<Type> FindAllAssignableFrom(this Type type, TypeCondition condition) | Finds all assignable types whose combined Type.Is* flag set (as TypeCondition) matches condition (bitwise AND containment). |
FindAllAssignableFrom(Type, params Func<Type,bool>[]) | IEnumerable<Type> FindAllAssignableFrom(this Type type, params Func<Type, bool>[] additionAndPredicates) | Most general overload: all assignable types (or all types matching an open generic definition) satisfying every supplied predicate (AND-combined). Throws ArgumentNullException if type is null. |
GetLoadableTypes | IEnumerable<Type> GetLoadableTypes(this Assembly assembly) | Safely enumerates every type (including nested types, recursively) in an assembly, swallowing ReflectionTypeLoadException by falling back to the successfully-loaded subset, and swallowing any other exception by returning empty. |
IsConcreteClass | bool IsConcreteClass(this Type type, bool isIgnoreGeneratedType = false) | True if type is a class, not abstract, not an interface, not an open generic definition, has at least one public instance constructor, and (optionally) is not compiler-generated. |
IsAssignableToGenericTypeDefinition | bool IsAssignableToGenericTypeDefinition(this Type type, Type genericTypeDefinition) | True if type implements/derives from the open generic definition genericTypeDefinition (walks interfaces and base-type chain). |
GetAssignableToGenericArguments | Type[] GetAssignableToGenericArguments(this Type type, Type genericTypeDefinition) | Returns the closed generic arguments type uses to satisfy genericTypeDefinition, or Type.EmptyTypes if not assignable. |
GetTypesWithAttribute<TAttribute> | IEnumerable<Type> GetTypesWithAttribute<TAttribute>(this Assembly assembly, bool isInherit = false) | All loadable types in assembly decorated with TAttribute. |
GetInjectableConstructor | ConstructorInfo? GetInjectableConstructor(this Type type, Type injectAttributeType) | Cached lookup: the single constructor marked with injectAttributeType, or (if none marked) the public constructor with the most parameters. Throws if multiple constructors are marked. Returns null if there are no public constructors. |
GetDependencyType | Type GetDependencyType(this ParameterInfo parameterInfo) | Equivalent to parameterInfo.ParameterType.GetUnderlyingDependencyType(). |
GetFactoryMethod | Func<object[], object> GetFactoryMethod(this ConstructorInfo constructor) | Compiles (via System.Linq.Expressions) a fast factory delegate for constructor; falls back to constructor.Invoke(args) on compilation failure (e.g., AOT/IL2CPP). |
GetInjectableMembers | IEnumerable<MemberInfo> GetInjectableMembers(this Type type, Type injectAttribute) | All public/non-public instance fields, properties, and methods marked with injectAttribute. |
MatchesGenericParameters | bool MatchesGenericParameters(this Type[] args, Type[] constraints) | True if every args[i] is assignable to constraints[i] and lengths match. |
GetAllDependencies | HashSet<Type> GetAllDependencies(this Type type, Type injectAttribute, Func<Type,Type> resolver = null) | Iteratively (stack-based, not recursive) walks the full transitive constructor + member dependency graph of type, returning every distinct dependency type encountered (excluding type itself). An optional resolver can remap a dependency type before it's added/traversed (e.g., interface → concrete implementation). |
HasCyclicDependencies | bool HasCyclicDependencies(this Type type, Type injectAttribute, out List<Type> cyclePath) | Recursive DFS cycle detection over the same dependency graph; cyclePath is populated with the offending cycle (ending back at type) when true is returned. |
AddAssemblies | void AddAssemblies(IEnumerable<Assembly> assemblies) | Incrementally registers additional assemblies into the domain-types cache. |
GetUnderlyingDependencyType | Type GetUnderlyingDependencyType(this Type type) | Unwraps T[], IEnumerable<T>, Lazy<T>, Func<T> down to T; returns type unchanged otherwise. |
SatisfiesOpenedGenericConstraints | bool SatisfiesOpenedGenericConstraints(this Type type, Type openDefinition) | True if type's generic arguments (for the first matching implemented open interface) satisfy that interface's own generic parameter constraints. |
SatisfiesClosedGenericConstraints | bool SatisfiesClosedGenericConstraints(this Type type, Type closedGenericDefinition) | True if type implements the exact closed generic closedGenericDefinition and its generic-parameter constraints are satisfied. Throws ArgumentException if closedGenericDefinition is not actually a closed generic type. |
GetMarkedConstructors | ConstructorInfo[] GetMarkedConstructors(this Type type, Type injectAttribute) | All constructors (not just public) marked with injectAttribute — unlike GetInjectableConstructor, does not throw on multiple matches and does not fall back to the greediest constructor. |
IsOptionalParameter | bool IsOptionalParameter(this ParameterInfo parameter) | True if the parameter is [Optional] or has a default value. |
GetMemberDependencyType | Type[] GetMemberDependencyType(this MemberInfo member) | For a FieldInfo/PropertyInfo, returns a single-element array with its type; for a MethodInfo, returns all parameter types; otherwise an empty array. |
CanResolveAllDependencies<T> | bool CanResolveAllDependencies<T>(this Type type, Type injectAttribute, IReadOnlyDictionary<Type,T> dependencyRegistryMap, Func<T,Type> selector, Func<Type,Type> resolver = null) | True if every transitive dependency of type is either a concrete class or present in dependencyRegistryMap. Throws CircularDependencyException if a cycle exists. |
ClearCache | void ClearCache() | Resets all static caches. |
TypeCondition Flags Enum
Source: TypeAnalyzes.TypeCondition.cs
[Flags]
public enum TypeCondition : long
{
None = 0,
Abstract = 1L << 0,
AnsiClass = 1L << 1,
Array = 1L << 2,
// ... 46 flags total, one per relevant System.Type.Is* boolean property
ConstructedGenericType = 1L << 45,
}
A comprehensive bit-flag mirror of nearly every System.Type.Is* boolean property (IsAbstract, IsClass, IsGenericType, IsPublic, IsSealed, IsValueType, IsNested*, etc. — see the full list in the source file and its XML doc comments), enabling composite queries like:
var candidates = typeof(IPlugin).FindAllAssignableFrom(TypeCondition.Class | TypeCondition.Public);
GetFlag(Type) (private) computes the applicable TypeCondition combination for a given Type by checking each Is* property once. Note that TypeCondition.Serializable is annotated [Obsolete] since it mirrors the now-deprecated Type.IsSerializable property.
Standalone Usage Example
Because TypeAnalyzes has no dependency on any other SimplEnteiner type besides the CircularDependencyException it defines, it can be used purely as a reflection utility library:
using SimplEnteiner;
IEnumerable<Type> plugins = typeof(IPlugin)
.FindAllNonAbstractClassAssignableFrom()
.Where(t => t.GetCustomAttribute<PluginAttribute>() != null);
foreach (Type pluginType in plugins)
{
var ctor = pluginType.GetInjectableConstructor(typeof(InjectAttribute));
var factory = ctor.GetFactoryMethod();
// ...
}
Continue to MS.DI Integration API.
MS.DI Integration API
Namespace: SimplEnteiner.Integrations.MS_DI
Source: Integrations/MS_DI/
This page is the API-reference companion to the architectural discussion in Architecture → Dependency Injection Integration. See that page for behavior/sequence diagrams; this page focuses on exact members and signatures.
Extensions.AddSimplEnteiner
public static class Extensions
{
public static IServiceCollection AddSimplEnteiner(this IServiceCollection services, Action<DIContainer> configure);
}
| Parameter | Description |
|---|---|
services | The IServiceCollection to augment with a SimplEnteiner-backed IServiceProvider/IServiceScopeFactory. |
configure | Callback invoked synchronously with a brand-new DIContainer before it is built. Throws ArgumentNullException if null (via ThrowIfArgumentNull). |
Returns the same services instance (fluent chaining). See source.
SimplEnteinerServiceProvider
public class SimplEnteinerServiceProvider : IServiceProvider, ISupportRequiredService, IServiceScopeFactory
{
public SimplEnteinerServiceProvider(IScope container);
public object GetService(Type serviceType);
public object GetRequiredService(Type serviceType);
public IServiceScope CreateScope();
}
| Member | Behavior |
|---|---|
GetService(Type) | _container.Resolve(serviceType) — propagates any exception thrown by SimplEnteiner's resolution pipeline (does not swallow to null). |
GetRequiredService(Type) | Same as GetService, but throws InvalidOperationException if the result is null (note: SimplEnteiner's own resolver already throws for unregistered non-concrete types, so this null-check mainly guards against services intentionally registered/resolved as null instance, an edge case). |
CreateScope() | _container.CreateScope() wrapped in a new SimplEnteinerServiceScope. |
Constructed directly around any IScope (not just the root DIContainer), so it can also be used to expose an arbitrary child scope as a standalone IServiceProvider.
SimplEnteinerServiceScope
public class SimplEnteinerServiceScope : IServiceScope
{
public SimplEnteinerServiceScope(IScope scope);
public IServiceProvider ServiceProvider { get; }
public void Dispose();
}
Wraps a child IScope in a fresh SimplEnteinerServiceProvider exposed via ServiceProvider. Dispose() disposes the ServiceProvider if it implements IDisposable (which SimplEnteinerServiceProvider currently does not implement explicitly — disposal of the underlying scope must currently be managed by disposing the IScope passed to the constructor directly, or by relying on parent-scope disposal cascading). Consumers who need deterministic disposal of the underlying IScope when the MS.DI IServiceScope is disposed should keep a reference to the IScope and dispose it explicitly, or track this as a potential enhancement (see Conclusion → Roadmap).
Full Example: ASP.NET Core Minimal API
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSimplEnteiner(container =>
{
container.Bind<IGreetingService>().To<GreetingService>().AsSingle().Apply();
});
var app = builder.Build();
app.MapGet("/hello", (IServiceProvider sp) =>
{
var greeter = (IGreetingService)sp.GetRequiredService(typeof(IGreetingService));
return greeter.Greet();
});
app.Run();
Note: Whether ASP.NET Core's own request pipeline actually resolves controllers/minimal-API handlers through the
IServiceProviderregistered byAddSimplEnteinerdepends on how/whether the host's default service provider factory is overridden (e.g. via a customIServiceProviderFactory<TContainerBuilder>), which SimplEnteiner does not currently supply out of the box.AddSimplEnteineronly adds the provider/factory as services within the default container — see the caveats in Architecture → DI Integration.
Continue to Core Functionality → Binding Workflow.
Binding Workflow
This page walks through what happens, step by step, when you call the fluent binder API, tying together the Design Patterns discussion with concrete code paths.
End-to-End Sequence
container.Bind<IGreeter>() // 1
.To<Greeter>() // 2
.AsSingle() // 3
.WithId("default") // 4
.WithArguments("Hello") // 4
.Apply(); // 5
sequenceDiagram
participant App
participant Container as DIContainer/Scope
participant Builder as BindingBuilder
participant SM as BuilderStateMachine
participant BindingTo
participant BindingLifetime
participant BindingOptions
participant Registry
App->>Container: Bind<IGreeter>()
Container->>Builder: new BindingBuilder(typeof(IGreeter))
Builder->>SM: CreateAndConfigureStateMachine()
Container->>Container: _pendingBindings.Add(builder)
Container-->>App: new BindingTo<IGreeter>(builder, this)
App->>BindingTo: To<Greeter>()
BindingTo->>Builder: SetImplementation(typeof(Greeter))
Builder->>SM: ChangeTo<ImplementationStage>()
BindingTo-->>App: new BindingLifetime<IGreeter>(builder, container)
App->>BindingLifetime: AsSingle()
BindingLifetime->>Builder: SetLifetime(LifeTime.Singleton)
Builder->>SM: ExecuteTo<ImplementationStage>() (no-op, already there)
Builder->>SM: ChangeTo<LifetimeStage>()
BindingLifetime-->>App: new BindingOptions<IGreeter>(builder, container)
App->>BindingOptions: WithId("default")
BindingOptions->>Builder: SetId("default")
Builder->>SM: ChangeTo<OptionsStage>()
App->>BindingOptions: WithArguments("Hello")
BindingOptions->>Builder: Arguments.AddRange(["Hello"])
App->>BindingOptions: Apply()
BindingOptions->>Container: Register(builder) (IBindingTarget.Register)
Container->>Builder: ExecuteAllStages() -> runs remaining stages up to FinalStage
Container->>Registry: Add(builder)
Registry->>Registry: Validate(builder)
Registry->>Registry: CreateRegistration(builder)
Registry->>Registry: AddConditionalRegistration(...) (because Id was set)
Container->>Builder: MarkRegistered()
Container->>Container: _pendingBindings.Remove(builder)
Key Steps in Detail
-
Bind<TService>()creates aBindingBuilderfortypeof(TService), adds it to the owning container/scope's internal_pendingBindingslist (thread-safe via alock), and returns anIBindingTo<TService>wrapping both the builder and the owningIBindingTarget(the container/scope, accessed only through the internal interface — never exposed publicly). -
Each fluent call (
To,AsSingle,WithId, ...) mutates the same underlyingBindingBuilderand advances (or validates against) itsBuilderStateMachine. Calling a setter for a stage that has already been passed throwsInvalidOperationException(e.g."Lifetime already set!"), preventing accidental double-configuration. -
Apply()(onIBindingOptions/IBindingOptions<T>) is the terminal step for a non-decorator binding. It calls the owning target's internalRegister(BindingBuilder):builder.ExecuteAllStages()runs any stages not yet explicitly configured (defaultingLifetimetoTransientandImplementationto self, if the caller skipped those steps entirely — though in practiceBind<T>().Apply()alone without.To(...)/.AsX(...)is unusual, this exists as a safety net for programmatic/generated binding code).Registry.Add(builder)validates and stores the resultingRegistrationin the appropriate bucket (ExactBindings,OpenGenericBindings, orConditionalBindings— see Registration and Resolution).builder.MarkRegistered()setsIsRegistered = true, and the builder is removed from_pendingBindings.
-
If
Apply()is never called for a givenBindingBuilder(a common pattern for decorator-less simple usage where a caller forgets it, or intentionally defers), the binding remains "pending." Callingcontainer.Build()(orBindConvention(...)onDIContainer) flushes all pending bindings viaBuildPendings(), registering each one exactly asApply()would, and clearing the pending list. This makesBuild()a safety net that guarantees no binding is silently lost, though relying on it instead of explicitApply()calls is discouraged for readability.Note:
Scope.Bind<T>()(used for child scopes, as opposed toDIContainer.Bind<T>()) does not maintain its own pending list — bindings created directly on a childScopemust be explicitly.Apply()-ed; onlyDIContainerperforms this pending/flush bookkeeping.
Decorator Binding Workflow
Decorators follow a very similar builder pipeline but through a separate Decorate<TService>() entry point and AddDecorator builder mutation, and — critically — every lifetime-setting call (AsSingle(), AsTransient(), etc.) on IBindingDecorateLifetime implicitly calls Apply() (via RegisterDecorator, routed to Registry.AddDecorator instead of Registry.Add). See Decorators for the full picture including resolution-time wrapping order.
Validation Timing
Note that binding-time validation (Registry.Add → Validate) checks structural correctness immediately (implementation is concrete, assignable, has an injectable constructor, satisfies generic constraints) — but it does not verify that the implementation's own dependencies are resolvable yet, because other bindings might not be registered at that point (the commented-out check in Registry.Add explicitly notes this: "Iterative validating maybe can invalid, if not all dependency registered in current time"). Full dependency-graph validation happens later, in Build() → ValidateAll(). See Core Functionality → Resolution Workflow and Reachability Analysis and Validation.
Continue to Resolution Workflow.
Resolution Workflow
This page describes exactly what happens inside Resolver when Resolve<T>()/Resolve(Type) is called — the most complex single algorithm in the library.
Top-Level Entry
public object Resolve(Type interfaceType, Scope scope, object id = null)
{
using ResolutionContext context = new ResolutionContext(scope, id);
return ResolveInternal(interfaceType, context);
}
A fresh ResolutionContext is created for every top-level Resolve call (this is what makes the Cached lifetime scoped to a single top-level resolution — its CachedInstances dictionary lives only as long as this using block).
Resolution Algorithm
flowchart TD
Start["ResolveInternal(interfaceType, context)"] --> IsGeneric{Is interfaceType generic?}
IsGeneric -->|IEnumerable<T>| Enum[ResolveAllEnumerable: find all ExactBindings assignable to T, resolve each]
IsGeneric -->|Lazy<T>| Lazy[CreateLazy: build Lazy<T> wrapping a compiled Func]
IsGeneric -->|Func<T>| Func[CreateFunc: compile Expression.Lambda calling Resolve<T> on demand]
IsGeneric -->|Other/No| Registration
Registration["GetRegistration(interfaceType, context)"] --> HasId{context.Id set?}
HasId -->|Yes| CondId[FindConditionalRegistration by id - throws if missing]
HasId -->|No| HasReq{context.RequestType set?}
HasReq -->|Yes| CondType[FindConditionalRegistration by RequestType - optional]
CondType -->|found| UseReg[Use registration]
CondType -->|not found| Exact
HasReq -->|No| Exact[FindExactRegistration]
Exact -->|found| UseReg
Exact -->|not found| IsClosedGeneric{Closed generic type?}
IsClosedGeneric -->|Yes| OpenGen[GetClosedGenericRegistration: find OpenGenericBindings, MakeGenericType, check SatisfiesOpenedGenericConstraints]
OpenGen -->|found| UseReg
OpenGen -->|not found| IsConcrete{IsConcreteClass?}
IsClosedGeneric -->|No| IsConcrete
IsConcrete -->|Yes| SelfReg[Create ad-hoc Transient Registration for the concrete type itself]
IsConcrete -->|No| NullReg[null]
UseReg --> ResolveReg["ResolveRegistration(registration, interfaceType, context)"]
SelfReg --> ResolveReg
NullReg --> ThrowEx["throw InvalidOperationException: No binding found for {interfaceType}"]
CondId -.throws if null.-> ThrowEx2["throw InvalidOperationException with id in message"]
ResolveReg --> Existing{Existing instance for lifetime?}
Existing -->|Yes: Singleton/Scoped/Cached hit| ReturnExisting[Return cached instance]
Existing -->|No| ResolveCtor[Resolve constructor parameters recursively]
ResolveCtor --> Invoke[Invoke compiled factory delegate]
Invoke --> InjectMembers[Inject fields/properties/methods marked Inject]
InjectMembers --> Store["StoreInstance per lifetime (Singleton/Scoped/Cached/Transient tracking)"]
Store --> InitCall["Invoke IInitializable.Initialize()"]
InitCall --> OnActivationCall[Invoke registration.OnActivation]
OnActivationCall --> ReturnNew[Return new instance]
Enum --> Decorators
Lazy --> Decorators
Func --> Decorators
ReturnExisting --> Decorators
ReturnNew --> Decorators
Decorators["ResolveDecorators(instance, interfaceType, context)"] --> Final["Return final (possibly decorated) instance"]
Registration Lookup Priority
Resolver.GetRegistration checks candidates in this exact order:
- Explicit id (
context.Id != null) — looked up viaFindConditionalRegistration(interfaceType, context.Id), walking the scope chain (this scope → parent → parent's parent → ...). ThrowsInvalidOperationExceptionimmediately if not found (no fallback for explicit ids). - Request-type condition (
context.RequestType != null, i.e. we're resolving a dependency of some other type) —FindConditionalRegistration(interfaceType, context.RequestType). If not found, falls through to the next step (no throw) — conditional-by-type is a soft preference, not a hard requirement. - Exact registration —
FindExactRegistration(interfaceType), walking the scope chain. - Closed-generic-from-open-generic — only if
interfaceTypeis a non-open generic type; looks for a matchingOpenGenericBindingsentry forinterfaceType.GetGenericTypeDefinition(), closes the registered open implementation viaMakeGenericType, and validatesSatisfiesOpenedGenericConstraintsbefore accepting it. - Self-registration fallback — if
interfaceType.IsConcreteClass(isIgnoreGeneratedType: true), an ad-hocTransientRegistrationis created on the fly (no explicitBindcall required to resolve a concrete, injectable class — a common convenience in many DI containers). - Otherwise,
nullis returned, and the caller (ResolveInternal) throwsInvalidOperationException("No binding found for {interfaceType}").
Generic Wrapper Resolution
Before the standard registration lookup runs at all, ResolveInternal special-cases three generic shapes (checked via interfaceType.GetGenericTypeDefinition()):
| Generic Shape | Behavior |
|---|---|
IEnumerable<T> | Enumerates all ExactBindings in the current scope whose key is assignable to T, resolves each one, and returns them as a List<T> (constructed reflectively via Activator.CreateInstance(typeof(List<>).MakeGenericType(elementType))). Note: only the current scope's own exact bindings are enumerated here (via context.CurrentScope.GetAllExactRegistration(), which actually does merge parent-scope bindings too — see Scope.GetAllExactRegistration). |
Lazy<T> | Builds a Lazy<T> wrapping a compiled Func<T> (see below), so the dependency is only actually resolved on first access to .Value. |
Func<T> | Compiles (once per resolution, not cached across calls) an Expression.Lambda that, when invoked, calls back into Resolver.Resolve(Type, Scope, object) for T against the original scope captured at expression-build time — enabling factory-style "resolve me a new one later" dependencies. |
Constructor and Member Injection
For a "normal" (non-wrapper) registration, ResolveRegistration:
- Checks for an existing instance appropriate to the registration's lifetime (
GetExistingInstance) — see Scopes, Lifetimes and Disposal for the exact per-lifetime lookup logic. If found, that instance is returned immediately (skipping construction, member injection,OnActivation, andIInitializableentirely — those only fire on new instantiation). - Otherwise, resolves the constructor's parameters:
- If the registration has explicit
Arguments(from.WithArguments(...)), usesResolveConstructorWithArguments, which greedily matches each supplied argument to the first constructor parameter it's type-assignable to (consumed once matched, so duplicates don't double-match), resolving any unmatched parameters normally (respecting[Id]attributes per-parameter). - Otherwise, uses
ResolveParameters, which resolves every parameter recursively viaResolveInternal, temporarily settingcontext.RequestTypeto the type being constructed (enablingWhenInjectedInto<T>()conditional matches for its dependencies) and swappingcontext.Idin/out per-parameter based on any[Id]attribute present.
- If the registration has explicit
- Invokes the registration's compiled
Factorydelegate with the resolved parameter array. - Injects members (
InjectMembers): iteratesGetInjectableMembersfor the implementation type, setting fields/properties via reflection and invoking[Inject]-marked methods with resolved parameters (same per-parameter[Id]handling as constructor parameters). - Stores the instance according to its lifetime (
StoreInstance). - Invokes
IInitializable.Initialize()if implemented. - Invokes
registration.OnActivation?.Invoke(instance)if a callback was registered via.OnActivation(...).
Decorator Wrapping
After the base instance (or a cache/singleton/scoped hit) is obtained, ResolveDecorators is always consulted (even for cache hits) — see Decorators for full detail on ordering, lifetime handling per-decorator, and open-generic decorator support.
Async Resolution
ResolveAsync (on Scope/DIContainer) is a thin async wrapper: it calls the synchronous Resolve first, then, if the result implements IAsyncInitializable, awaits its InitializeAsync() before returning the same instance. It does not change any part of the core synchronous resolution algorithm described above.
Continue to Scopes, Lifetimes and Disposal.
Scopes, Lifetimes and Disposal
Scope Tree Model
Every DIContainer owns exactly one root Scope. Calling CreateScope() on the root (or on any descendant) creates a new child Scope sharing the tree-wide ScopeCreationConfig (and therefore the same IResolver, IScopeFactory, IRegistryFactory, and — critically — the same IRepositoryService singleton store) but with its own Registry and _scopedInstances dictionary.
flowchart TB
Root["Root Scope (DIContainer)<br/>Registry: R0<br/>Singletons: SharedRepo (owns lifetime)<br/>Scoped: S0"]
ChildA["Child Scope A<br/>Registry: RA<br/>Scoped: SA"]
ChildB["Child Scope B<br/>Registry: RB<br/>Scoped: SB"]
GrandchildA1["Grandchild A1<br/>Registry: RA1<br/>Scoped: SA1"]
Root --> ChildA
Root --> ChildB
ChildA --> GrandchildA1
Root -.shares.-> SharedRepo[("Singleton Repository<br/>(tree-wide, root-owned)")]
ChildA -.shares.-> SharedRepo
ChildB -.shares.-> SharedRepo
GrandchildA1 -.shares.-> SharedRepo
Registration lookups (FindExactRegistration, FindOpenGenericRegistration, FindConditionalRegistration) always search from the current scope up towards the root, so a child scope can resolve anything registered in itself or any ancestor, but a parent scope cannot see registrations made only in a child.
Lifetime Semantics
| Lifetime | Where the instance is stored | Lookup key | Shared across | Example use case |
|---|---|---|---|---|
Transient | Not stored for reuse — a new instance every resolution. | n/a | Nothing. | Lightweight, stateless, or mutable-per-use objects (e.g. a command object, a DTO builder). |
Singleton | IRepositoryService (RepositoryService), owned by the root scope but referenced by every scope's ScopeCreationConfig.SingletonRepository. | Type (interface type). | The entire scope tree — root and all descendants resolve the same instance. | Configuration objects, caches, connection pools. |
Scoped | Each Scope's private _scopedInstances dictionary. | Type (interface type), scoped to this scope object instance. | Only within the exact scope that created it — child/parent/sibling scopes each get their own instance if they resolve the same type. | Per-request state (e.g., a "current user" context in a web request scope), a Unit-of-Work/DbContext per logical operation. |
Cached | The current top-level Resolve() call's ResolutionContext.CachedInstances (a ConcurrentDictionary<Type,object>) — see Registration and Resolution → ResolutionContext. | Type, scoped to a single Resolve() invocation's entire dependency graph. | Only across dependencies resolved within one top-level Resolve<T>() call — e.g. if A and B both depend on ICache, and both are being constructed while resolving some root X, they get the same ICache instance; a subsequent, separate Resolve<X>() call gets a new ICache instance. | "One instance per object graph" semantics — e.g., avoiding redundant expensive computations shared by multiple branches of a single request's dependency tree, without the long-lived stickiness of Scoped/Singleton. |
sequenceDiagram
participant Caller
participant Resolver
participant Ctx as ResolutionContext (per top-level Resolve call)
participant Scope
Caller->>Resolver: Resolve<Root>()
Resolver->>Ctx: new ResolutionContext(scope)
Resolver->>Resolver: build Root, needs A and B
Resolver->>Resolver: build A, needs ICache (Cached)
Resolver->>Ctx: CachedInstances[ICache] miss -> create, store
Resolver->>Resolver: build B, needs ICache (Cached)
Resolver->>Ctx: CachedInstances[ICache] hit -> reuse
Resolver-->>Caller: Root (with A and B sharing one ICache)
Ctx->>Ctx: Dispose() (context ends, cache discarded)
Caller->>Resolver: Resolve<Root>() again
Resolver->>Ctx: new ResolutionContext(scope) (fresh!)
Note over Ctx: New Cached instance created this time
Existing-Instance Lookup (Resolver.GetExistingInstance)
private object GetExistingInstance(Registration registration, Type interfaceType, ResolutionContext context)
{
return registration.Lifetime switch
{
LifeTime.Singleton => registration.Instance ?? context.CurrentScope.GetSingleton(interfaceType),
LifeTime.Scoped => context.CurrentScope.GetScoped(interfaceType),
LifeTime.Cached => context.CachedInstances.TryGetValue(interfaceType, out object instance) ? instance : null,
_ => null
};
}
Note that Transient always falls to _ => null — i.e. never returns an existing instance, guaranteeing a fresh instance every time, even within the same ResolutionContext.
For Singleton, if the binding was created via .ToInstance(...), registration.Instance is already populated and is returned directly without ever touching the scope's _singletons store (though RegistryS.CreateRegistration also forces Lifetime = Singleton for instance-bound registrations, so this path is consistent).
Disposal
Each Scope owns its own CleanupService, which tracks every IDisposable/IAsyncDisposable instance created for Transient, Cached, and (also) Scoped lifetimes within that scope — actually, Scoped instances are tracked via TrackDisposable when stored (StoreScoped calls TrackDisposable), so scoped disposables are cleaned up when their owning scope is disposed. Singleton disposables are tracked separately, by the shared IRepositoryService, and are only released when the root scope is disposed.
public void Dispose()
{
if (_disposed) return;
_disposed = true;
lock (_scopedLock) { _scopedInstances.Clear(); }
if (IsRoot)
{
lock (_singletons)
{
((IDictionary)_singletons).Clear();
_singletons.Dispose(); // disposes every tracked singleton
}
}
else
{
Parent.RemoveChildren(this); // detach from the tree
}
_cleanupService.Dispose(); // disposes every tracked Transient/Cached/Scoped disposable in THIS scope
}
Important nuances:
- Disposing a child scope does not touch the singleton repository at all (only the root scope's
Dispose()does), so singletons survive child-scope disposal, as expected. - Disposing a child scope detaches it from its parent's
_childrenslist but does not recursively dispose its own children — if you create nested scopes, you are responsible for disposing them in the right order (typically innermost-first, e.g. via nestedusingblocks) or accept that undisposed grandchildren simply become unreachable garbage (their tracked disposables will not be explicitly disposed unless the GC happens to trigger finalizers, which SimplEnteiner does not rely on). DisposeAsync()mirrorsDispose()but awaits_cleanupService.DisposeAsync(), which prefersIAsyncDisposable.DisposeAsync()overIDisposable.Dispose()per tracked instance when both are implemented.- The
OnReleasecallback (set via.OnRelease(...)) is invoked before the instance itself is disposed, for both the sync and async cleanup paths.
Practical Example
using DIContainer container = new DIContainer();
container.Bind<IAppConfig>().To<AppConfig>().AsSingle().Apply();
container.Bind<IRequestContext>().To<RequestContext>().AsScoped().Apply();
container.Bind<IGuidGenerator>().To<GuidGenerator>().AsCached().Apply();
container.Bind<ITransientWorker>().To<TransientWorker>().AsTransient().Apply();
container.Build();
using (IScope requestScope = container.CreateScope())
{
var ctx1 = requestScope.Resolve<IRequestContext>();
var ctx2 = requestScope.Resolve<IRequestContext>();
// ctx1 == ctx2 (same Scoped instance within this scope)
var config = requestScope.Resolve<IAppConfig>();
// config is the SAME instance as container.Resolve<IAppConfig>() (Singleton, tree-wide)
} // requestScope disposed here: IRequestContext instance (if IDisposable) is disposed; IAppConfig is NOT
using (IScope anotherScope = container.CreateScope())
{
var ctx3 = anotherScope.Resolve<IRequestContext>();
// ctx3 != ctx1 (different scope => different Scoped instance)
}
Continue to Decorators.
Decorators
SimplEnteiner implements the Decorator design pattern as a native binding kind, distinct from regular service bindings, with its own storage bucket (Registry.DecoratorBindings) and its own resolution step (Resolver.ResolveDecorators), executed after the base service instance/registration has been produced.
Registering Decorators
container.Bind<IGreeter>().To<Greeter>().AsSingle().Apply();
container.Decorate<IGreeter>().With<LoggingGreeterDecorator>(order: 0).AsTransient();
container.Decorate<IGreeter>().With<MetricsGreeterDecorator>(order: 1).AsSingle();
A decorator implementation must have a constructor parameter assignable from the decorated interface (IGreeter in the example above) — this is enforced by Registry.ValidateDecorator:
public class LoggingGreeterDecorator : IGreeter
{
private readonly IGreeter _inner;
private readonly ILogger _logger;
[Inject]
public LoggingGreeterDecorator(IGreeter inner, ILogger logger)
{
_inner = inner;
_logger = logger;
}
public string Greet()
{
_logger.Log("Greeting requested");
return _inner.Greet();
}
}
Unlike regular bindings, IBindingDecorateLifetime.AsSingle()/AsTransient()/AsCached()/AsScoped() implicitly call Apply() — there is no separate step. See Binder API → IBindingDecorateLifetime.
Ordering
Decorators for the same interface are kept, per scope, in a List<DecoratorRegistration> sorted by ascending Order (via ListExtensions.FindBinaryIndexMoreThan, a binary-search insertion). If order is omitted when calling .With<T>(order):
decoratorRegistration.Order ??= decorators.Count == 0 ? 0 : decorators[^1].Order + 1;
— i.e., the first unordered decorator gets 0, and each subsequent unordered decorator gets previous.Order + 1. Explicit orders can be interleaved with implicit ones as long as you're consistent about intent; ties are resolved by insertion position (binary search finds the first index whose existing order is not <= the new order).
Resolution-Time Wrapping
sequenceDiagram
participant Caller
participant Resolver
participant Scope
participant Base as Greeter (base impl)
participant D1 as LoggingGreeterDecorator (Order 0)
participant D2 as MetricsGreeterDecorator (Order 1)
Caller->>Resolver: Resolve<IGreeter>()
Resolver->>Resolver: ResolveRegistration -> new Base
Resolver->>Scope: GetDecoratorRegistrations(typeof(IGreeter))
Scope-->>Resolver: [D1(order0), D2(order1)]
Resolver->>D1: CreateDecoratorInstance(Base)
D1-->>Resolver: new LoggingGreeterDecorator(Base, logger)
Resolver->>D2: CreateDecoratorInstance(D1 instance)
D2-->>Resolver: new MetricsGreeterDecorator(D1instance, ...)
Resolver-->>Caller: D2 instance (outermost)
Resolver.ResolveDecorators iterates the ordered decorator list and, for each entry:
- If the decorator's own
LifetimeisSingletonorScoped, checks whether an instance for that decorator type already exists in the appropriate store (GetSingleton/GetScopedkeyed bydecorator.DecoratorType, not by the original interface type). If found, that existing decorator instance is used as-is (short-circuiting construction) — meaning singleton/scoped decorators are only ever constructed once, wrapping whichever base instance was current the first time. - Otherwise,
CreateDecoratorInstanceconstructs a new decorator instance, passing the currentinstanceas an additional constructor argument (matched by type, same mechanism as.WithArguments(...), seeResolveConstructorWithArguments), then stores it per its own lifetime (StoreDecorator) and runs member injection +IInitializable.Initialize()on the inner instance being wrapped (note:InjectMembers(instance, ...)and_invoker.Invoke<IInitializable>(instance)are called with the pre-decorationinstance, not the new decorator — see the source for the exact call sequence). instanceis reassigned to the newly created (or reused) decorator, becoming the input to the next decorator in the chain.
The final instance returned from ResolveDecorators is what the caller receives — a fully wrapped chain from innermost (the original Registration-produced service) to outermost (the highest-Order decorator).
Generic and Open-Generic Decorators
Decorators also support open-generic definitions:
container.Decorate(typeof(IRepository<>)).With(typeof(LoggingRepositoryDecorator<>)).AsSingle();
Registry.AddDecorator stores this under the open generic definition key (typeof(IRepository<>)). At resolution time, when a closed generic interface is being decorated, Scope.GetDecoratorRegistrations performs an extra pass (AddGenericDecoratorRegistrations) that looks up decorators registered against the open generic definition and, for each, closes the decorator's own generic type arguments using the requested interface's arguments (registration.DecoratorType.MakeGenericType(arguments)), producing an ad-hoc closed DecoratorRegistration for that specific resolution.
BindingDecorate.Validate (non-generic path) enforces three distinct compatibility rules depending on the shape of the interface being decorated:
- Closed generic interface (
IRepository<User>) — the decorator implementation must be a concrete class satisfying the closed generic's constraints (SatisfiesClosedGenericConstraints). - Open generic interface (
IRepository<>) — the decorator implementation must itself be assignable to the open generic definition (IsAssignableToGenericTypeDefinition). - Non-generic interface — a plain
IsAssignableFromcheck.
Decorator Lookup Across the Scope Tree
Scope.GetDecoratorRegistrations(interfaceType) walks from the current scope up to the root, collecting decorator lists from every ancestor scope (in root-to-current order via AddExactDecoratorRegistrations's reverse iteration of the collected scope list), so decorators registered at a parent scope apply to services resolved from a child scope as well — mirroring the same "child sees parent, not vice versa" rule used for regular registrations.
Continue to Configuration Import/Export (Serialization).
Configuration Import/Export (Serialization)
SimplEnteiner can serialize the structure of a container's entire binding graph (exact, open-generic, conditional, and decorator bindings, recursively across all child scopes) to JSON, and later reconstruct a new DIContainer from that JSON.
Source: Core/Configuration/ (DTOs), DIContainer.Serializer and Scope.Serializer nested classes in DIContainer.cs / Scope.cs.
Public API
public string ExportConfiguration(); // on DIContainer
public void ImportConfiguration(string jsonConfiguration); // on DIContainer
DIContainer container = new DIContainer();
container.Bind<IGreeter>().To<Greeter>().AsSingle().Apply();
container.Build();
string json = container.ExportConfiguration();
DIContainer restored = new DIContainer();
restored.ImportConfiguration(json);
// restored now has the same bindings as container, freshly Build()-ed
DTO Shapes
Source: Core/Configuration/BindingConfig.cs, DecoratorConfig.cs, ScopeConfig.cs
[Serializable]
internal class BindingConfig
{
public string InterfaceType { get; set; } // AssemblyQualifiedName
public string ImplementationType { get; set; } // AssemblyQualifiedName
public string Lifetime { get; set; } // LifeTime enum name
public string InstanceJson { get; set; } // JSON-serialized Instance (if any)
public List<string> ArgumentsJson { get; set; } // JSON-serialized Arguments
public string Id { get; set; } // JSON-serialized Id (if any)
public string Condition { get; set; } // AssemblyQualifiedName of ConditionType (if any)
}
[Serializable]
internal class DecoratorConfig
{
public string InterfaceType { get; set; }
public string DecoratorType { get; set; }
public int Order { get; set; }
public string Lifetime { get; set; }
}
[Serializable]
internal class ScopeConfig
{
public List<BindingConfig> ExactBindings = new();
public List<BindingConfig> OpenGenericBindings = new();
public List<DecoratorConfig> DecoratorBindings = new();
public List<BindingConfig> ConditionalBindings = new();
public List<ScopeConfig> Childrens = new();
}
All three DTOs are internal — not part of the public API surface — but understanding their shape is important for interpreting the exported JSON and for reasoning about round-trip fidelity.
Serialization Flow
sequenceDiagram
participant Caller
participant Container as DIContainer
participant CSerializer as DIContainer.Serializer
participant SSerializer as Scope.Serializer
participant JSON as System.Text.Json
Caller->>Container: ExportConfiguration()
Container->>CSerializer: Serialize(container)
CSerializer->>SSerializer: Serialize(container._rootScope)
SSerializer->>SSerializer: GetConfig(scope) — recursively builds ScopeConfig tree
SSerializer->>JSON: JsonSerializer.Serialize(ScopeConfig)
JSON-->>Caller: JSON string
GetConfig(Scope) walks each registry bucket and converts every Registration/DecoratorRegistration into its DTO counterpart using Type.AssemblyQualifiedName for type references, and JsonSerializer.Serialize(...) for Instance/Arguments/Id. It then recurses into scope._childrens to serialize the whole scope tree.
Deserialization Flow
public void ImportConfiguration(string jsonConfiguration)
{
_rootScope?.Dispose();
_pendingBindings.Clear();
_rootScope = new Scope(ConfigureConfig);
_rootScope.InitializeFromDto(_serializer.DeserializeInternal(jsonConfiguration));
Build();
}
Scope.InitializeFromDto(ScopeConfig) reconstructs registrations:
- Exact / open-generic / conditional bindings:
DeserializeRegistrationresolvesType.GetType(bindingConfig.InterfaceType/ImplementationType)and builds a newRegistrationwithFactory = null— meaning the compiled factory delegate is not restored; it isnullon the deserializedRegistration. Any code path that would callregistration.Factory(parameters)on such a registration (i.e., actually resolving it after import, when no live instance exists) would encounter anullfactory. In practice,Instanceis separately restored viaJsonSerializer.Deserialize(bindingConfig.InstanceJson, type), so instance-bound (effectively-singleton) bindings round-trip more usefully than factory/constructor-based ones. - Decorator bindings: reconstructed with
Constructor = nullandFactory = null— the resolver's decorator-instantiation path (CreateDecoratorInstance) already has a fallback (if (ctor == null || factory == null) { ctor = decorator.DecoratorType.GetInjectableConstructor(...); factory = ctor.GetFactoryMethod(); }), so decorators do correctly regenerate their factory delegate lazily at first use after import — a subtle but important asymmetry with exact/open-generic/conditional bindings. - Child scopes: recursively reconstructed via nested
ScopeConfig.Childrens.
Important Caveats
Instanceround-trips viaSystem.Text.Json, which means only types compatible withSystem.Text.Json's default serialization contract (public properties, parameterless constructor or supported constructor binding, no cycles, etc.) will survive export/import faithfully. Complex object graphs with private state, events, or unsupported types may lose data silently or throw during (de)serialization.- Non-instance, non-decorator registrations lose their compiled
Factoryon import (see above) — this means a plainBind<T>().To<Impl>().AsTransient().Apply()-style registration, afterExportConfiguration()/ImportConfiguration(), will haveFactory == nullon its restoredRegistration. SinceResolver.ResolveRegistrationunconditionally callsregistration.Factory(parameters)for these paths (no lazy-factory-regeneration fallback like decorators have), resolving a non-instance exact/open-generic/conditional binding afterImportConfigurationwill throw aNullReferenceExceptionunless that specific limitation is addressed by application code (e.g., re-registering critical bindings after import, or only relying onExportConfiguration/ImportConfigurationfor instance-bound / decorator-heavy configurations). Treat this feature as best-effort/experimental for anything beyond simple instance snapshots, and prefer explicit code-based composition roots for production dependency graphs. OnActivation/OnReleasecallbacks are not serialized at all (delegates cannot be represented in JSON) — they are alwaysnullon deserialized registrations.ConditionType/Idon conditional bindings round-trip viaAssemblyQualifiedName(for aTypecondition) orJsonSerializer.Deserialize<object>(...)(for an arbitraryidobject), with the deserializedid's runtime type depending on howSystem.Text.Jsoninfersobject-typed values (numbers becomeJsonElement/boxed primitives depending on context) — exercise caution when using non-string ids together with export/import.
Continue to Reachability Analysis and Validation.
Reachability Analysis and Validation
SimplEnteiner offers two related but distinct forms of "fail fast" validation for a container's dependency graph: general validation (always run as part of Build()) and reachability analysis (opt-in, targeted at "tree-shaking"-style dead-registration detection and stricter missing-binding detection from a known set of entry points).
General Validation — Registry.ValidateAll()
Invoked automatically by Scope.Build() → Scope.ValidateAll() → Registry.ValidateAll() for every scope in the tree (root first, then children, per Scope.Build()'s recursive _childrens[i].Build() calls — though note ValidateAll itself is called once per scope inside that scope's own Build(), not cascaded from the parent's ValidateAll).
public void ValidateAll()
{
Type injectAttribute = Constants.InjectAttributeType;
foreach (Type interfaceType in _exactBindings.Keys)
{
if (interfaceType.CanResolveAllDependencies(injectAttribute, _exactBindings, t => t.Implementation) == false)
throw new InvalidOperationException($"Cannot resolve all dependencies of {interfaceType}.");
}
foreach (KeyValuePair<Type, Registration> pair in _openGenericBindings)
{
Type implementation = pair.Value.Implementation;
if (implementation.IsConcreteClass(isIgnoreGeneratedType: true) == false)
throw new InvalidOperationException($"Open generic implementation {implementation} for {pair.Key} is not a concrete class.");
ConstructorInfo ctor = implementation.GetInjectableConstructor(injectAttribute)
?? throw new InvalidOperationException($"Open generic implementation {implementation} has no injectable constructor.");
}
}
For every exact binding, TypeAnalyzes.CanResolveAllDependencies (see TypeAnalyzes Reflection Toolkit) is used to confirm that every transitive dependency is either:
- A concrete, injectable class (resolvable via the self-registration fallback), or
- Present as a key in
_exactBindings(this scope's own bindings — note this check is per-scope, so a dependency satisfied only by a parent scope's registration would not be found by this specific check, since it only consults_exactBindingsof the scope being validated, not the merged ancestor chain).
CanResolveAllDependencies also calls HasCyclicDependencies first and throws TypeAnalyzes.CircularDependencyException immediately if a cycle is detected — cyclic dependencies are treated as a validation failure, not silently tolerated.
For every open generic binding, the implementation must itself be concrete and expose an injectable constructor (its actual closed-generic dependency resolvability is deferred to resolution time, since it depends on which type arguments are ultimately requested).
Reachability Analysis — Registry.AnalyzeReachability / ReachabilityAnalyzer
Source: Analysis/ReachabilityAnalyzer.cs, Registry.AnalyzeReachability in Registry.cs
Unlike ValidateAll() (always run), reachability analysis is opt-in — you explicitly call container.AnalyzeReachability(roots, injectAttribute) with a set of "entry point" types (e.g., your top-level controllers, command handlers, or composition roots).
container.AnalyzeReachability(
roots: new[] { typeof(OrderController), typeof(PaymentController) },
injectAttribute: typeof(InjectAttribute));
ReachabilityAnalyzer.ComputeReachability
public HashSet<Type> ComputeReachability(IEnumerable<Type> roots, IReadOnlyDictionary<Type, Type> bindings, Type injectAttribute)
A classic BFS over the dependency graph, starting from roots:
flowchart TD
A[Queue = roots] --> B{Queue empty?}
B -->|No| C[Dequeue type]
C --> D{Already in reachable set?}
D -->|Yes| B
D -->|No| E[Add to reachable set]
E --> F[implementation = bindings map or self]
F --> G[Get injectable constructor]
G -->|null| B
G -->|found| H[Enqueue each constructor parameter's underlying dependency type]
H --> I[Enqueue each injectable member's dependency types]
I --> B
B -->|Yes| J[Return reachable set]
Note: bindings here maps interfaceType → implementationType and is populated from _exactBindings only — open-generic and conditional bindings are not included in the reachability graph exploration in the current implementation.
Registry.AnalyzeReachability — Interpreting the Result
public void AnalyzeReachability(IEnumerable<Type> roots, Type injectAttribute)
{
Dictionary<Type, Type> allExact = _exactBindings.ToDictionary(...);
HashSet<Type> reachable = ReachabilityAnalyzer.Instance.ComputeReachability(roots, allExact, injectAttribute);
List<Type> unreachable = _exactBindings.Keys.Except(reachable).ToList();
List<Type> missing = reachable.Where(t => allExact.ContainsKey(t) == false && t.IsConcreteClass() == false).ToList();
// throws InvalidOperationException combining both lists, if either is non-empty
}
- Unreachable services — registrations that exist in
_exactBindingsbut were never encountered while walking the dependency graph fromroots. This flags "dead" registrations: bindings that nothing in your application actually depends on (directly or transitively) from the given entry points. This can indicate leftover/obsolete code, or — just as usefully — entry points you forgot to include inroots. - Missing bindings — types that are reachable from
rootsbut have no exact binding and are not themselves concrete/injectable classes (e.g., an interface with nothing bound to it). This is a stricter check thanValidateAll()'s per-registration dependency check, because it starts from real application entry points rather than only checking each registered type's own immediate dependencies.
Both lists, if non-empty, are combined into a single descriptive message and raised as InvalidOperationException.
Recommended Usage Pattern
DIContainer container = new DIContainer();
// ... all Bind<T>()...Apply() calls ...
container.Build(); // runs ValidateAll() per scope — catches missing/cyclic dependencies structurally
container.AnalyzeReachability(
roots: EntryPointTypes,
injectAttribute: typeof(InjectAttribute)); // optional, catches dead registrations + entry-point-driven missing bindings
Run both checks as part of your application's startup path (or, better, as part of an automated test — see Testing and Quality Assurance) so that configuration mistakes are caught before deployment rather than at first request.
Continue to Configuration and Settings.
Configuration and Settings
SimplEnteiner is a code-first, no-external-configuration-file library. There is no appsettings.json-style configuration surface, no environment-variable binding, and no IOptions<T>-style configuration section support built into the core container itself. All configuration of the container's behavior happens in code, through:
- The fluent binder API (
Bind,Decorate,BindConvention) — see Binder API and Convention-Based Binding. - The
ScopeCreationConfigobject, which lets you override the pluggable strategy implementations (IResolver,IRegistryFactory,IScopeFactory,IRepositoryService) — see below. - The JSON-based export/import feature (
ExportConfiguration/ImportConfiguration), which is the closest thing to a "configuration file" the library supports, but it operates on a serialized snapshot of an already-built binding graph, not a hand-authored settings format — see Configuration Import/Export (Serialization).
Overriding Default Strategies via ScopeCreationConfig
Source: Core/ScopeFeature/ScopeCreationConfig.cs
public sealed class ScopeCreationConfig
{
public IResolver Resolver { get; set; }
public IRegistryFactory RegistryFactory { get; set; }
public IScopeFactory ScopeFactory { get; set; }
public IRepositoryService SingletonRepository { get; set; }
}
DIContainer wires up the built-in defaults internally (ConfigureConfig):
private void ConfigureConfig(ScopeCreationConfig config)
{
config.Resolver = _resolver; // new Resolver()
config.ScopeFactory = new ScopeFactory.DefaultScopeFactory();
config.RegistryFactory = new RegistryFactory();
config.SingletonRepository = new RepositoryService.RepositoryService(new CleanupService());
}
There is currently no public constructor overload on DIContainer that accepts a custom ScopeCreationConfig — the internal DIContainer(ScopeConfig rootScopeConfig) constructor exists only for the deserialization path (Scope.Serializer.Deserialize). Consumers who need to substitute a custom IResolver/IRegistryFactory/IScopeFactory/IRepositoryService implementation currently must either:
- Construct a
Scopedirectly (new Scope(Action<ScopeCreationConfig> configure)), bypassingDIContainer, or - Extend/modify the library source, since
DIContainer'sConfigureConfigmethod isprivate.
This is a known extensibility gap — see Conclusion → Roadmap.
"Settings" Expressed as Bindings
Because there's no dedicated settings/options subsystem, application configuration values are typically injected the same way as any other dependency — bind a configuration object as a singleton instance:
var appConfig = new AppConfig
{
ConnectionString = Environment.GetEnvironmentVariable("APP_CONNECTION_STRING"),
MaxRetries = 3,
};
container.Bind<AppConfig>().ToInstance(appConfig).AsSingle().Apply();
Anything implementing IInitializable/IAsyncInitializable (see Lifecycle Interfaces and Enums) can be used to perform post-construction configuration loading/validation logic (e.g., reading from environment variables or a file at first resolution), keeping such logic decoupled from the container itself.
Overriding Defaults Summary
| What | How |
|---|---|
| Which implementation is used for an interface | Bind<TInterface>().To<TImplementation>() |
| Lifetime of a binding | .AsSingle() / .AsTransient() / .AsScoped() / .AsCached() |
| Multiple bindings for the same interface | .WithId(id) + [Id(id)], or .WhenInjectedInto<T>() |
| Constructor selection when multiple public constructors exist | [Inject] on exactly one constructor |
| Extra constructor arguments not resolved from the container | .WithArguments(...) |
| Post-construction hooks | IInitializable, IAsyncInitializable, IStartable, .OnActivation(...), .OnRelease(...) |
| Bulk registration by convention | BindConvention(...) — namespace/attribute/predicate filters |
| Cross-cutting wrapping | Decorate<T>().With<TDecorator>(order).AsX() |
Logging Configuration
SimplEnteiner does not integrate with Microsoft.Extensions.Logging or ILogger in any way — there is no built-in logging output for binding/resolution activity anywhere in the codebase (confirmed: no references to ILogger, Microsoft.Extensions.Logging, or Console.Write* exist in the SimplEnteiner project). See Error Handling and Logging for how to add your own diagnostics using the available extension points (OnActivation/OnRelease, IInitializable).
Continue to Dependencies and NuGet Packages.
Dependencies and NuGet Packages
SimplEnteiner (Main Library)
Source: SimplEnteiner/SimplEnteiner.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
<PackageReference Include="System.Text.Json" Version="10.0.7" />
</ItemGroup>
</Project>
| Package | Version | Why it's used |
|---|---|---|
Microsoft.Extensions.DependencyInjection | 10.0.7 | Provides the IServiceProvider, IServiceScope, IServiceScopeFactory, and ISupportRequiredService abstractions implemented by SimplEnteinerServiceProvider / SimplEnteinerServiceScope, plus the IServiceCollection extension surface (AddSimplEnteiner). SimplEnteiner's own container logic (binding, resolution, scopes) does not depend on this package at all — it is used purely for the optional MS.DI interoperability layer. |
System.Text.Json | 10.0.7 | Powers the configuration export/import feature (ExportConfiguration/ImportConfiguration), serializing/deserializing ScopeConfig/BindingConfig/DecoratorConfig DTOs and arbitrary bound Instance/Arguments/Id values to/from JSON. |
Notably, no third-party IoC/DI framework (Autofac, Ninject, Lamar, etc.) is used anywhere — the entire binding/resolution/lifetime engine is implemented from scratch using only System.Reflection and System.Linq.Expressions from the BCL.
SimplEnteinerTests (Test Project)
Source: SimplEnteinerTests/SimplEnteinerTests.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SimplEnteiner\SimplEnteiner.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
| Package | Version | Purpose |
|---|---|---|
coverlet.collector | 6.0.0 | Code coverage collection when running dotnet test (integrates with the MSTest/VSTest data collector pipeline). |
Microsoft.NET.Test.Sdk | 17.8.0 | Standard .NET test SDK required to run any test project via dotnet test. |
xunit | 2.5.3 | The test framework used for every test in the solution (see Testing and Quality Assurance). |
xunit.runner.visualstudio | 2.5.3 | Test adapter enabling Visual Studio / dotnet test discovery and execution of xUnit tests. |
The test project targets net8.0 (not netstandard2.1) since test execution requires a concrete runtime, while the library itself targets netstandard2.1 for maximum consumer compatibility (see Introduction → Technology Stack).
Is SimplEnteiner Published as a NuGet Package?
No .nuspec file, no PackageId/Authors/Description/Version MSBuild properties, and no packaging/publishing pipeline (.github/workflows) were found anywhere in the repository. The SimplEnteiner.csproj does not set <IsPackable> (defaults to packable for a class library, but no package metadata is defined, and no publish/pack workflow currently exists), and no license or NuGet-specific metadata files (LICENSE, .nuspec) are present in the repository root.
Conclusion: As of this documentation, SimplEnteiner is not currently published or configured as a versioned NuGet package; it is consumed as a source/project reference (as the test project does via <ProjectReference>). See Building and Deployment for how you could add packaging support, and Building and Deployment → Publishing to NuGet for the gaps that would need to be filled in to publish an official package.
Continue to Testing and Quality Assurance.
Testing and Quality Assurance
Test Framework
The SimplEnteinerTests project uses xUnit 2.5.3 (xunit, xunit.runner.visualstudio), targeting net8.0, with coverlet.collector wired in for code coverage collection. There is no NUnit or MSTest usage anywhere in the solution. See Dependencies and NuGet Packages for exact package versions.
Test Project Structure
SimplEnteinerTests/
├── TypeAnalyzesTests/ Unit + integration tests for every TypeAnalyzes method
│ ├── IntegrationTests.cs Cross-method integration scenarios (see below)
│ ├── ClearCacheTests.cs
│ ├── CanResolveAllDependenciesTests.cs
│ ├── GetMemberDependencyTypeTests.cs
│ ├── IsOptionalParameterTests.cs
│ ├── GetMarkedConstructorsTests.cs
│ ├── SatisfiesClosedGenericConstraints.cs
│ ├── SatisfiesOpenedGenericConstraintsTests.cs
│ ├── GetUnderlyingDependencyTypeTests.cs
│ ├── AddAssembliesTests.cs
│ ├── HasCyclicDependenciesTests.cs
│ ├── GetAllDependenciesTests.cs
│ ├── MatchesGenericParametersTests.cs
│ ├── GetInjectableMembersTests.cs
│ ├── GetFactoryMethodTests.cs
│ ├── GetDependencyTypeTests.cs
│ ├── GetInjectableConstructorTests.cs
│ ├── GetTypesWithAttributeTests.cs
│ ├── GetAssignableToGenericArgumentsTests.cs
│ ├── IsAssignableToGenericTypeDefinitionTests.cs
│ ├── IsConcreteClassTests.cs
│ ├── GetLoadableTypesTests.cs
│ ├── FindAllAssignableFromWithConditionTests.cs
│ ├── FindAllNonAbstractClassAssignableFrom.cs
│ └── FindAllAssignableFromTests.cs
├── TestInfrastructure/
│ └── TypeExtensionsTestBase.cs Shared base class resetting TypeAnalyzes' static caches between tests via reflection
└── TestTypes/ Purpose-built fixture types used across many tests
├── Classes/ ~20 files: simple services, generics, decorators, cyclic dependencies,
│ disposal-tracking classes, compiler-generated types, statics, abstracts, etc.
├── Interfaces/
├── Structs/
├── AtributeTypes/ Test attributes + types decorated with them
├── IsAndGetAssignableTypes/ Types for generic-assignability tests
├── WithConstraints/ Types exercising generic constraint satisfaction
├── Delegates.cs
├── Enums.cs
└── Attributes.cs
Test Categories
| Category | Location | What it covers |
|---|---|---|
| Unit tests | TypeAnalyzesTests/*Tests.cs (one file per public TypeAnalyzes method, e.g. GetInjectableConstructorTests.cs, HasCyclicDependenciesTests.cs) | Each public method of the TypeAnalyzes reflection toolkit is tested in isolation against purpose-built fixture types in TestTypes/. |
| Integration tests | TypeAnalyzesTests/IntegrationTests.cs | Exercises combinations of TypeAnalyzes methods together (e.g., FindAllAssignableFrom + IsConcreteClass, GetInjectableConstructor + GetFactoryMethod, AddAssemblies + FindAllAssignableFrom, cyclic dependency detection combined with GetAllDependencies), verifying that the caching layer and cross-method contracts hold together correctly under realistic usage patterns. See IntegrationTests.cs. |
| Fixture/test-data types | TestTypes/** | Not tests themselves, but a curated library of classes/interfaces/structs/enums/attributes/delegates designed to exercise specific reflection edge cases: multiple constructors, optional parameters, cyclic dependencies (ForCyclicDependicies.cs), generic constraints (WithConstraints/Types.cs), disposal semantics (FroLifetimeDisposalErrors.cs — sync/async disposables, instance/creation counters, throwing constructors), compiler-generated types, and nested type visibility. |
| Test infrastructure | TestInfrastructure/TypeExtensionsTestBase.cs | A shared abstract base class providing reflection-based helpers (ResetDomainTypeCache, ResetAssembliesCache, GetAssembliesCache, GetCachedDomainTypes, IsInjectableConstructorsCacheEmpty, IsDomainTypesCacheNull, IsInitializedFlagFalse) that reach into TypeAnalyzes's private static fields via reflection to reset/inspect its caches between test runs, since the caches are static and would otherwise leak state across tests. |
Note: the current test suite is entirely focused on the TypeAnalyzes reflection toolkit (namespace SimplEnteiner). There are currently no dedicated test files exercising the Core.* namespaces directly (e.g. no DIContainerTests.cs, ResolverTests.cs, RegistryTests.cs, ScopeTests.cs, or MS.DI integration tests) — despite fixture types like FroLifetimeDisposalErrors.cs (disposal counters, async disposables) clearly being prepared for container-level lifetime/disposal testing. This is a notable coverage gap for the DI container itself; see Conclusion → Roadmap.
Running Tests Locally
# From the repository root, restore and run the whole test project
dotnet test SimplEnteinerTests/SimplEnteinerTests.csproj
# Or run the whole solution's tests
dotnet test SimplEnteiner.sln
# Run a single test class
dotnet test --filter "FullyQualifiedName~IntegrationTests"
# Run with code coverage (coverlet.collector is already referenced)
dotnet test --collect:"XPlat Code Coverage"
Because many TypeAnalyzes tests depend on and mutate shared static caches, tests inheriting TypeExtensionsTestBase call ResetDomainTypeCache() at the start of each test method that relies on a clean cache — this pattern should be followed for any new tests added to the same class.
Code Coverage
coverlet.collector is referenced and wired for VSTest-based collection (dotnet test --collect:"XPlat Code Coverage" produces a Cobertura-format report under TestResults/). No coverage threshold, badge, or published report was found in the repository, so there is currently no enforced minimum coverage and no publicly tracked coverage percentage.
CI/CD Integration
No CI/CD configuration was found in this repository: there is no .github/workflows/ directory (only .github/copilot-instructions.md, which contains unrelated Azure Copilot tooling instructions, not a build pipeline) and no azure-pipelines.yml or other pipeline definition. Tests currently must be run manually (or wired into your own CI system) via the dotnet test commands above. See Building and Deployment and Conclusion → Roadmap for suggested next steps.
Continue to Performance and Optimization.
Performance and Optimization
SimplEnteiner does not currently ship a BenchmarkDotNet project or any published throughput/latency/memory benchmarks (no benchmark project exists in the solution — only SimplEnteiner and SimplEnteinerTests are present in SimplEnteiner.sln). This page instead documents the performance-relevant design decisions actually present in the code, and gives practical recommendations derived from the implementation.
Performance-Relevant Design Decisions
Compiled Factory Delegates instead of Activator.CreateInstance
Source: TypeAnalyzes.GetFactoryMethod
public static Func<object[], object> GetFactoryMethod(this ConstructorInfo constructor)
{
try
{
ParameterExpression parametersExpression = Expression.Parameter(typeof(object[]), "args");
IEnumerable<UnaryExpression> parameterExpression = constructor.GetParameters().Select((p, i) =>
Expression.Convert(Expression.ArrayIndex(parametersExpression, Expression.Constant(i)), p.ParameterType));
NewExpression newExpression = Expression.New(constructor, parameterExpression);
Expression<Func<object[], object>> lambda = Expression.Lambda<Func<object[], object>>(newExpression, parametersExpression);
return lambda.Compile();
}
catch
{
return (args) => constructor.Invoke(args);
}
}
Each Registration compiles its constructor invocation once, at registration time (Registry.CreateRegistration), rather than reflectively invoking the constructor on every resolution. This is significantly faster than repeated ConstructorInfo.Invoke/Activator.CreateInstance calls for hot paths with many resolutions, at the (one-time, per-binding) cost of expression-tree compilation. The try/catch fallback to constructor.Invoke(args) protects environments where dynamic method compilation is unavailable or restricted (AOT/IL2CPP), trading throughput for portability in those environments only.
Reflection Metadata Caching (TypeAnalyzes)
Source: TypeAnalyzes.cs
s_injectableConstructorsCache— aConcurrentDictionary<Type, ConcurrentDictionary<Type, ConstructorInfo>>memoizingGetInjectableConstructor(type, injectAttributeType)results, avoiding repeatedGetConstructors()+ attribute-scanning + LINQ ordering on every call for the same(type, attribute)pair. This directly benefits the hot resolution path, sinceResolvercallsGetInjectableConstructoron every non-cached-instance resolution.s_cachedDomainTypes— a process-wide, lazily-populatedList<Type>avoiding repeatedAppDomain.CurrentDomain.GetAssemblies()+GetTypes()scans for every call toFindAllAssignableFrom/FindAllNonAbstractClassAssignableFrom. This is primarily relevant to convention-based binding and reflection-heavy startup code, not to steady-state resolution.- Both caches are guarded for thread safety (
ConcurrentDictionaryfor the constructor cache; a singlelock (s_lock)for the domain-types cache during initial population), so concurrent startup-time registration/scanning from multiple threads is safe, though the domain-types cache population itself is a serialized (locked) operation the first time it runs.
Iterative (not Recursive) Dependency Graph Walking for GetAllDependencies
TypeAnalyzes.GetAllDependencies uses an explicit Stack<Type> rather than recursive calls, avoiding call-stack growth (and potential StackOverflowException) for deep or wide dependency graphs. HasCyclicDependencies, by contrast, is implemented recursively (HasCyclicDependenciesRecursive) — very deep dependency chains (unusual in practice for typical DI graphs) could theoretically exhaust the call stack during cycle detection; this asymmetry is worth being aware of for pathological inputs.
Per-Resolution-Call Context, Not Global State
ResolutionContext is allocated fresh (and disposed via using) for every top-level Resolve() call, keeping the Cached lifetime's dictionary short-lived and avoiding any global mutable resolution state that would need locking on every resolve. Regular scope-level state (_scopedInstances, singleton repository) uses targeted lock statements scoped tightly around dictionary mutations (see Scope.StoreSingleton, StoreScoped) rather than broad, contention-prone locks.
Known Bottlenecks / Trade-offs (Inferred from the Implementation)
Registry.ValidateAll()andAnalyzeReachability()are O(bindings × dependency-graph size) — they re-walk each registered type's full dependency graph independently. For very large registration graphs (hundreds+ of services with deep dependency chains), calling these at everyBuild()could add measurable startup latency; this is an intentional trade-off (fail-fast correctness over startup speed) and is only paid once at composition-root build time, not per-request.IEnumerable<T>resolution is O(all exact bindings in scope) —Resolver.ResolveAllEnumerablelinearly scanscontext.CurrentScope.GetAllExactRegistration()for every assignable type, on every resolution of anIEnumerable<T>dependency. For containers with very large numbers of registrations, resolving many-implementations collections repeatedly could be a hot spot; consider caching the resolved collection yourself (e.g., behind aSingletonwrapper service) if this becomes measurable in your application.Func<T>wrapper compilation happens on every resolution (Resolver.CreateFuncbuilds and compiles a newExpression.Lambdaeach time aFunc<T>dependency is resolved) — unlike constructor factories (compiled once and cached on theRegistration), this compilation is not cached/memoized anywhere. IfFunc<T>dependencies are resolved frequently in a hot path, this repeated expression-tree compilation cost should be measured; a possible optimization opportunity (see Conclusion → Roadmap) is caching the compiledFunc<T>factory per(argumentType, scope)pair.- Decorator resolution walks the entire scope chain (
Scope.GetDecoratorRegistrationscollects every ancestor scope into aList<IScope>on every call) for every decorated resolution — for deep scope trees, this adds a per-resolution cost proportional to scope depth, though scope trees are typically shallow (root + 1–2 levels) in practice.
Recommendations for Consumers
- Prefer
Singleton/ScopedoverTransient/Cachedfor expensive-to-construct services that don't need per-call freshness — this avoids repeated constructor/member-injection work and repeatedIInitializable.Initialize()invocations. - Call
Build()once at startup, not repeatedly — it triggers full-graph validation (ValidateAll) and singleton eager-instantiation (IStartable.Start()), which are one-time, not per-resolution costs. Do not callBuild()inside a request/resolution hot path. - Reuse
IScopeinstances appropriately (e.g., one child scope per logical unit of work/request) rather than creating and disposing scopes excessively — scope creation allocates a newRegistry,_scopedInstancesdictionary, andCleanupServiceeach time. - Avoid resolving
Func<T>/IEnumerable<T>dependencies in extremely hot loops without caching the resolved delegate/collection yourself, given the per-resolution compilation/scan costs described above. - Use
TypeAnalyzes.AddAssemblies(...)/ClearCache()deliberately — clearing the domain-type cache forces a full re-scan of all tracked assemblies on next use, which is comparatively expensive; only clear it when you know new types genuinely need to be picked up (e.g., after dynamically loading a plugin assembly). - If you need concrete performance numbers for your scenario, the recommended approach is to add a
BenchmarkDotNet-based micro-benchmark project (none currently exists) exercising your specific binding/resolution patterns — see Conclusion → Roadmap.
Continue to Error Handling and Logging.
Error Handling and Logging
Exception Catalogue
SimplEnteiner does not define a large hierarchy of custom exception types. It relies primarily on standard BCL exceptions (ArgumentNullException, ArgumentException, InvalidOperationException) raised with descriptive messages, plus one custom exception type for circular dependencies.
| Exception | Thrown by | When |
|---|---|---|
ArgumentNullException | ThrowExtensions.ThrowIfArgumentNull<T>() (used pervasively across Binder, Resolver, ContainerExtensions, ConventionBuilder, etc.) | Any required argument (interface Type, BindingBuilder, delegate callback, assembly, etc.) is null. Also thrown directly (not via the extension) in several IBindingTo/IBindingTo<T> implementations for ToInstance/ToMethod with null arguments, and throughout TypeAnalyzes public methods for null type/assembly/parameter/member arguments. |
InvalidOperationException | BindingBuilder.ThrowIfCantTransit<T> | A binding builder stage is set out of order (e.g., calling .AsSingle() twice) — message format: "{OperationName} already set!" (e.g. "Lifetime already set!"). |
InvalidOperationException | Registry.ValidateAll() | A registered exact binding's dependency graph cannot be fully resolved (missing binding for a non-concrete dependency), or an open-generic implementation is not concrete / lacks an injectable constructor. |
InvalidOperationException | Registry.AnalyzeReachability() | Unreachable registrations and/or missing bindings for reachable non-concrete types were found (see Reachability Analysis and Validation). |
InvalidOperationException | Resolver.GetRegistration / ResolveInternal (via ThrowInvalidIfNull) | No binding can be found at all for a requested interfaceType (not concrete, no exact/open-generic/conditional registration). Message: "No binding found for {interfaceType}". |
InvalidOperationException | Resolver.GetRegistration (id path) | An explicit .Resolve<T>(id)/.Resolve(Type, id) call, or an [Id(...)]-attributed dependency, cannot find the requested conditional binding. Message includes the id: "No binding found for {interfaceType} with id '{context.Id}'". |
InvalidOperationException | Scope.RegisterDecorator | No injectable constructor exists for a registered decorator type. Message: "No constructor for decorator {decoratorType}". |
InvalidOperationException | BindingDecorate.Validate / Registry.ValidateDecorator | A decorator implementation is not compatible with (assignable to / constraint-satisfying / open-generic-implementing) the interface it decorates, or its constructor lacks a parameter assignable to the decorated interface. |
InvalidOperationException | SimplEnteinerServiceProvider.GetRequiredService | Resolve(serviceType) returned null (see MS.DI Integration API). |
ArgumentException | Registry.Validate / ValidateFirstStep / ValidateSecondStep / ValidateDecorator | An implementation is not a concrete class, is not assignable to the target interface, does not satisfy closed/open generic constraints, or (for decorators) has no public constructor. |
ArgumentException | TypeAnalyzes.SatisfiesClosedGenericConstraints | The supplied closedGenericDefinition argument is not actually a closed generic type (i.e., it's either non-generic or is itself an open generic definition). |
ArgumentException | ConventionBuilder.FromAssembly | The same assembly is added twice via FromAssembly/FromAssemblies. |
Exception (plain) | TypeAnalyzes.GetInjectableConstructor | More than one constructor on a type is marked with the inject attribute — message: "Multiple constructors with {injectAttributeType.Name} attribute in {type}". Note: this is a plain System.Exception, not a more specific type — catch broadly or fix the offending type's constructors. |
TypeAnalyzes.CircularDependencyException (sealed, extends Exception) | TypeAnalyzes.CanResolveAllDependencies (via HasCyclicDependencies) | A type's constructor/member dependency graph contains a cycle. Exposes CircularPath (IReadOnlyList<Type>) with the exact cycle, and a message like "A cyclical dependency has been detected. Path: [ NamespaceA.TypeA -> NamespaceB.TypeB -> NamespaceA.TypeA ]". See Attributes and Delegates → CircularDependencyException. |
Error Handling Flow During Build()
flowchart TD
A[container.Build] --> B[BuildPendings: flush unregistered bindings]
B --> C{Registry.Add validation per binding}
C -->|fails| E1[ArgumentException / InvalidOperationException]
C -->|ok| D[Scope.Build]
D --> E[Registry.ValidateAll per scope]
E -->|missing dependency| E2[InvalidOperationException: Cannot resolve all dependencies]
E -->|cycle detected| E3[CircularDependencyException]
E -->|ok| F[Scope.Start: invoke IStartable on singletons]
F --> G[Recurse into child scopes]
G --> H[Build complete]
Because Build() performs validation before starting any IStartable singletons, a configuration error anywhere in the graph prevents any startable singleton from running — there's no partial-startup state where some services are running and others failed validation.
Best Practices for Handling Errors in Consuming Code
-
Call
container.Build()inside your application's startup path, not lazily. Wrap it (or the whole composition-root method) so failures surface as clear, early, fatal startup errors rather than being discovered mid-request:try { container.Build(); } catch (TypeAnalyzes.CircularDependencyException ex) { // ex.CircularPath gives you the exact offending cycle for logging/diagnostics throw new ApplicationException($"DI cycle detected: {string.Join(" -> ", ex.CircularPath)}", ex); } catch (InvalidOperationException ex) { // Missing binding, decorator misconfiguration, etc. throw; } -
Use
AnalyzeReachability(...)in a startup self-test or dedicated test, not on every production boot if your registration graph is large enough that the O(n) walk matters (see Performance and Optimization) — though for most applications running it at every startup is perfectly fine and recommended. -
Do not rely on
GetService-stylenull-on-miss semantics when going through the MS.DI integration —SimplEnteinerServiceProvider.GetServicepropagates SimplEnteiner's ownInvalidOperationExceptionfor missing non-concrete bindings rather than returningnull, which differs from some otherIServiceProviderimplementations. Usetry/catchor pre-validate viaBuild()/AnalyzeReachabilityinstead of relying on null-checks afterGetService. -
Treat the plain
Exceptionthrown byGetInjectableConstructor(multiple[Inject]-marked constructors) as a compile-time-detectable class-authoring mistake — fix the offending type rather than catching this specific case at runtime. -
OnRelease/OnActivationcallbacks should not throw. NeitherCleanupService.Dispose()/DisposeAsync()norResolver.ResolveRegistrationwrap these callback invocations intry/catch— an exception thrown from anOnReleasecallback during scope disposal will propagate out ofDispose()/DisposeAsync()and can prevent subsequent tracked instances in the same cleanup batch from being disposed (since the loop is not guarded). Keep these callbacks defensive.
Logging
There is no built-in logging (ILogger, Microsoft.Extensions.Logging, Console.Write*, trace sources, or any other diagnostic output) anywhere in the SimplEnteiner codebase. All "log points" you might want (binding registered, instance activated/released, decorator applied) must be added by consumers via:
.OnActivation(instance => logger.LogDebug(...))/.OnRelease(instance => logger.LogDebug(...))per binding.- Implementing
IInitializable/IAsyncInitializable/IStartableon your own types and logging from within those hooks. - Wrapping services with a logging decorator.
See Configuration and Settings → Logging Configuration for the same conclusion from a configuration-surface perspective.
Continue to Building and Deployment.
Building and Deployment
Environment Prerequisites
| Requirement | Detail |
|---|---|
| .NET SDK | An SDK capable of building netstandard2.1 (main library) and net8.0 (test project) — the .NET 8 SDK (or later) satisfies both, since newer SDKs can target older TargetFramework monikers. |
| IDE | Any IDE/editor with C#/.NET tooling (Visual Studio, Visual Studio Code with C# Dev Kit, JetBrains Rider). The solution was authored with Visual Studio 2022 (VisualStudioVersion = 17.14... in SimplEnteiner.sln), but nothing in the codebase requires Visual Studio specifically. |
| Build tool | dotnet CLI (ships with the SDK) is sufficient for all build/test/pack operations — no additional build tooling (MSBuild-only projects, no custom .targets/.props files) is required. |
| OS | Any OS with a compatible .NET SDK — Windows, Linux, macOS all work, since no OS-specific APIs are used in the library. |
Build Process
# Restore and build the whole solution
dotnet restore SimplEnteiner.sln
dotnet build SimplEnteiner.sln -c Release
# Build only the library
dotnet build SimplEnteiner/SimplEnteiner.csproj -c Release
# Run tests (see Testing and Quality Assurance for more detail)
dotnet test SimplEnteinerTests/SimplEnteinerTests.csproj -c Release
There are two configurations defined in the solution file: Debug|Any CPU and Release|Any CPU (see SimplEnteiner.sln). No custom build configurations, Directory.Build.props, or MSBuild targets exist beyond the two straightforward .csproj files.
Packing (dotnet pack)
The SimplEnteiner.csproj currently has no NuGet package metadata (PackageId, Version, Authors, Description, PackageLicenseExpression, etc.) — see Dependencies and NuGet Packages → Is SimplEnteiner Published as a NuGet Package?. Running dotnet pack today will still produce a .nupkg (since class libraries are packable by default), but with only inferred/default metadata (project name as package id, a default version like 1.0.0, no description, no license).
# Produces a .nupkg with default/inferred metadata (not currently curated)
dotnet pack SimplEnteiner/SimplEnteiner.csproj -c Release -o ./artifacts
Suggested Metadata to Add Before Publishing
To make the library production-ready for NuGet distribution, the following properties should be added to SimplEnteiner.csproj's <PropertyGroup> (not currently present in the repository):
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<PackageId>SimplEnteiner</PackageId>
<Version>1.0.0</Version>
<Authors>Dmitry Rysev</Authors>
<Description>A small, embeddable Dependency Injection framework for .NET with hierarchical scopes, decorators, conditional bindings, convention-based registration, and Microsoft.Extensions.DependencyInjection interoperability.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression> <!-- or whichever license is chosen -->
<RepositoryUrl>https://github.com/JunDmitry/SimplEnteiner</RepositoryUrl>
<PackageProjectUrl>https://github.com/JunDmitry/SimplEnteiner</PackageProjectUrl>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
(The repository URL above is inferred from book.toml's git-repository-url setting; verify against the actual canonical repository before publishing.)
Publishing to NuGet (Not Yet Configured)
There is currently no publish workflow in this repository:
- No
.github/workflows/*.ymlexists (only.github/copilot-instructions.md, unrelated to CI/CD). - No
dotnet nuget pushscript, Azure DevOps pipeline, or any other automation for publishing to nuget.org or a private feed was found. - No API-key management convention (secrets file, environment variable references, etc.) exists.
To set this up, a typical GitHub Actions workflow would:
# .github/workflows/publish.yml (example — not present in the repository)
name: Publish
on:
push:
tags: ["v*"]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- run: dotnet restore SimplEnteiner.sln
- run: dotnet build SimplEnteiner.sln -c Release
- run: dotnet test SimplEnteinerTests/SimplEnteinerTests.csproj -c Release
- run: dotnet pack SimplEnteiner/SimplEnteiner.csproj -c Release -o ./artifacts
- run: dotnet nuget push ./artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }}
Versioning Strategy
No versioning convention (SemVer tagging, MinVer/Nerdbank.GitVersioning, changelog file) currently exists in the repository. Given the library's small, focused surface area, Semantic Versioning (SemVer 2.0) tied to Git tags (e.g., via Nerdbank.GitVersioning or MinVer) is recommended once packaging is set up, with breaking changes to public interfaces (IBinder, IScope, IBindingTo<T>, etc.) triggering a major version bump.
Strong Naming / Assembly Signing
No .snk file, <SignAssembly>, <AssemblyOriginatorKeyFile>, or any other strong-naming configuration exists in SimplEnteiner.csproj. The assembly is not strong-named. If strong naming is required for your consuming scenario (e.g., GAC deployment, strict assembly-identity requirements), this would need to be added explicitly — it is not currently part of the build.
Publishing the Documentation (mdBook / GitHub Pages)
This documentation is authored as an mdBook book, configured via book.toml:
[book]
title = "SimplEnteiner"
authors = ["Dmitry Rysev"]
language = "en"
src = "src"
[output.html]
default-theme = "light"
preferred-dark-theme = "navy"
git-repository-url = "https://github.com/JunDmitry/SimplEnteiner"
edit-url-template = "https://github.com/JunDmitry/SimplEnteiner/edit/main/src/{path}"
additional-js = ["mermaid-init.js"]
[output.html.search]
limit-results = 30
[preprocessor.links]
To build and preview locally (requires the mdbook CLI, e.g. cargo install mdbook):
mdbook build # outputs to ./book by default
mdbook serve # live-reloading local preview
To publish to GitHub Pages, a typical workflow (not currently present in .github/workflows/) would build the book and deploy the book/ output directory via actions/deploy-pages or peaceiris/actions-gh-pages. Mermaid diagram rendering is already wired up via theme/head.hbs and theme/mermaid_init.js (loaded via additional-js in book.toml), so diagrams embedded throughout this book render automatically once published.
Continue to Contributing.
Contributing
No CONTRIBUTING.md, issue/PR templates, or code-of-conduct file currently exist in this repository. This page documents the conventions observable directly from the existing codebase to guide contributors until a formal contributing guide is authored.
Code Style and Conventions (Observed)
- Braces: Allman style (opening brace on its own line) throughout the codebase — see any file, e.g.
DIContainer.cs. - Naming:
- Interfaces prefixed with
I(IScope,IBinder,IRegistry,IResolver, ...). - Private fields prefixed with
_(_pendingBindings,_registry,_singletons). - Private static fields prefixed with
s_(s_injectableConstructorsCache,s_lock,s_assembliesCache). Internal/implementation classes typically match their public interface name without theIprefix (IBinder→ no direct class;IRegistry→Registry;IResolver→Resolver).
- Interfaces prefixed with
- Visibility discipline: Types not meant for direct consumer use are consistently marked
internal(all ofBinder.Implementations,Binder.BuilderStages,Registry,Resolver,RepositoryService,CleanupService,InterfaceInvoker,RegistryFactory,DefaultScopeFactory). See Architecture → Namespace Structure → Visibility Conventions — follow this pattern for new code: default tointernal, and only make somethingpublicwhen it's part of the intended extension surface. - Argument validation: Guard clauses are expressed via the small extension-method helpers in
Utilities/ThrowExtensions.cs(ThrowIfArgumentNull,ThrowInvalidIfNull,ThrowIfInvalidOperation) rather than repeated inlineif (x == null) throw ...blocks. Prefer these helpers for consistency in new code. - XML doc comments: Public API surfaces in
TypeAnalyzes.csare thoroughly documented with<summary>,<param>,<returns>, and<exception>tags. This standard is not uniformly applied acrossCore/*(many public interfaces/classes there currently have no XML doc comments) — new public API additions should follow theTypeAnalyzes.csstandard of complete XML documentation. - Nullable reference types: Enabled in the test project (
<Nullable>enable</Nullable>inSimplEnteinerTests.csproj) but not enabled in the main library project (SimplEnteiner.csprojhas no<Nullable>setting, defaulting to disabled for itsnetstandard2.1target).TypeAnalyzes.csnonetheless uses nullable annotations in a few return types (ConstructorInfo?) despite the project-level setting being off — be consistent with the surrounding file's existing style when editing. - File-scoped organization: One primary type per file, file name matching the type name (e.g.
IBindingTo.cscontains bothIBindingTo<TInterface>andIBindingTo,IBindingLifetime.cscontains both generic and non-generic variants) — generic/non-generic interface pairs are consistently co-located in a single file. - Partial classes are used to split a single logical type across files by concern (
TypeAnalyzes.cs+TypeAnalyzes.TypeCondition.cs).
File Size Convention
This repository's local rules mandate that no code file exceed 200 lines. Most existing files comply, but a few exceed this (notably TypeAnalyzes.cs at over 1000 lines and Scope.cs/Resolver.cs at 400-600+ lines) — these predate the convention or represent an accepted exception for the reflection toolkit and core resolution engine, given their inherently large, closely-related method sets. New contributions should still aim to keep individual files under 200 lines where reasonably possible, favoring further decomposition (e.g., more partial class splits, or extracting cohesive private helper methods into separate internal utility classes) over growing existing large files further.
Setting Up the Development Environment
git clone <repository-url>
cd SimplEnteiner
dotnet restore SimplEnteiner.sln
dotnet build SimplEnteiner.sln
dotnet test SimplEnteinerTests/SimplEnteinerTests.csproj
No additional environment variables, local secrets, or external services are required to build or test this repository — it is a fully self-contained, dependency-light .NET solution. See Building and Deployment for full prerequisites.
Adding Tests for New Features
Given the current test coverage gap around the Core.* namespaces (container/binder/resolver/scope behavior is not yet directly unit-tested — only TypeAnalyzes is), contributions that add or modify Core.* behavior are strongly encouraged to include corresponding tests, following the existing SimplEnteinerTests project structure:
- Add fixture types under
SimplEnteinerTests/TestTypes/rather than inline in test files, matching the existing pattern. - Reset any relevant static caches at the start of tests (see
TypeExtensionsTestBase) if your test touchesTypeAnalyzes's cached state. - Prefer integration-style tests (like
IntegrationTests.cs) when validating end-to-end container behavior (bind → build → resolve → dispose), in addition to focused unit tests for individual methods.
Pull Request Process
No formal PR template or review process is documented in the repository. Until one is established, contributors should follow standard good practice:
- Open an issue or discussion describing the change before large refactors.
- Keep pull requests focused on a single logical change.
- Ensure
dotnet buildanddotnet testboth succeed locally before submitting. - Update this documentation (
src/) alongside any change to public API surface, behavior, or configuration — per this project's own repository rule that documentation indocs/srcmust be kept in sync with code changes. - Match the existing code style conventions documented above.
Continue to Conclusion.
Conclusion
Summary
SimplEnteiner is a compact, dependency-light Dependency Injection container for .NET (netstandard2.1) that implements, from first principles, the full set of features expected of a mature IoC container:
- A fluent, staged, misuse-resistant binding API (Binder API).
- Hierarchical scopes with correct
Transient/Singleton/Scoped/Cachedlifetime semantics (Scopes, Lifetimes and Disposal). - Constructor, field, property, and method injection with attribute-based disambiguation (Attributes and Delegates).
- Decorators, including open-generic decorator support (Decorators).
- Convention-based/assembly-scanning registration and a lightweight installer/module system (Convention-Based Binding).
- Structural dependency-graph validation and reachability analysis, enabling fail-fast composition roots (Reachability Analysis and Validation).
- A reusable, independently valuable reflection toolkit (
TypeAnalyzes) with aggressive caching (TypeAnalyzesReflection Toolkit). - First-class interoperability with
Microsoft.Extensions.DependencyInjection(MS.DI Integration API). - An experimental JSON-based configuration export/import mechanism (Configuration Import/Export (Serialization)).
Its value proposition is embeddability and transparency: a small, fully-owned, dependency-light codebase (two NuGet packages, no third-party IoC dependency) that application and library authors can audit, extend, or fork with confidence, while still covering the vast majority of features found in larger, more established DI containers.
Known Gaps and Roadmap
The following gaps were identified while producing this documentation and are recommended as a roadmap for future work:
- NuGet packaging is not configured — no
PackageId/Version/Description/license metadata, nodotnet pack/publish pipeline. See Building and Deployment. - No CI/CD pipeline — no GitHub Actions workflow (or other CI system) currently builds, tests, or publishes this repository automatically. See Testing and Quality Assurance → CI/CD Integration.
- Test coverage gap for
Core.*namespaces — the current test suite exhaustively coversTypeAnalyzesbut has no dedicated tests forDIContainer,Scope,Resolver,Registry, decorators, or the MS.DI integration layer, despite fixture types already prepared for lifetime/disposal testing. See Testing and Quality Assurance. - No public extensibility point for custom
ScopeCreationConfigonDIContainer—ConfigureConfigisprivate, so swapping in a customIResolver/IRegistryFactory/IScopeFactory/IRepositoryServicecurrently requires bypassingDIContainerand usingScopedirectly. See Configuration and Settings. - Serialization round-trip fidelity is incomplete — non-instance, non-decorator registrations lose their factory delegate on import and will throw
NullReferenceExceptionif resolved afterward;OnActivation/OnReleasecallbacks are never serialized. See Configuration Import/Export (Serialization) → Important Caveats. - No logging integration — there is no
ILogger/Microsoft.Extensions.Logginghook anywhere; all diagnostics must be added by consumers viaOnActivation/OnRelease/lifecycle interfaces/decorators. See Error Handling and Logging. - No
BenchmarkDotNetproject or published performance numbers — performance characteristics in this book are derived from code inspection, not measurement. See Performance and Optimization. Func<T>wrapper resolution recompiles its expression tree on every resolution (unlike constructor factories, which are cached) — a plausible, low-risk optimization target. See Performance and Optimization → Known Bottlenecks.- No
CONTRIBUTING.md, license file, or issue/PR templates currently exist in the repository. See Contributing.
Additional Resources
- Repository (inferred from
book.toml): https://github.com/JunDmitry/SimplEnteiner README.md— the project's one-line description ("A small DI framework").- Source code entry points for further exploration:
SimplEnteiner/Core/DIContainer.cs— start here to trace any public API call end-to-end.SimplEnteiner/TypeAnalyzes.cs— the reflection toolkit, useful even outside the DI context.SimplEnteiner/Core/BindExample.cs— internal usage examples covering most of the fluent API surface in one file.SimplEnteinerTests/— the most authoritative, executable source of truth forTypeAnalyzesbehavior.
This concludes the SimplEnteiner documentation book. Return to the Introduction or browse the Architecture and API Reference sections for deeper detail on any specific area.