Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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()/AnalyzeReachability validation 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 same IServiceProvider/IServiceScope abstractions.
  • 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

AspectValue
Main library target frameworknetstandard2.1 (see SimplEnteiner.csproj)
Test project target frameworknet8.0 (see SimplEnteinerTests.csproj)
LanguageC# (nullable-annotated in parts, e.g. TypeAnalyzes.cs)
Supported OSAny OS supported by a netstandard2.1-compatible runtime (Windows, Linux, macOS) — no OS-specific APIs are used
Direct NuGet dependenciesMicrosoft.Extensions.DependencyInjection 10.0.7, System.Text.Json 10.0.7
Test frameworkxUnit 2.5.3
DocumentationmdBook (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 APIBind<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 scopesIScope.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 (see LifeTime).
  • Decorators with explicit ordering, including open-generic decorators for open-generic services.
  • Conditional bindingsWithId(...) and WhenInjectedInto<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(...) and IConventionBuilder (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 for IEnumerable<T>, Lazy<T>, and Func<T> "meta" dependencies.
  • Cyclic-dependency detection (TypeAnalyzes.HasCyclicDependencies) and reachability analysis (Registry.AnalyzeReachability / ReachabilityAnalyzer) that can be run at container Build() time to fail fast on invalid graphs.
  • Object lifecycle hooks: IInitializable, IAsyncInitializable, IStartable, plus OnActivation/OnRelease callbacks 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.DependencyInjection interop via AddSimplEnteiner(...), SimplEnteinerServiceProvider, and SimplEnteinerServiceScope.
  • 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) and System.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:

  1. 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.
  2. Registration layer (Core/RegistrationService) — the Registry that 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.
  3. Resolution layer (Core/ResolverService) — the Resolver, which walks the constructor/member dependency graph, creates instances via compiled factory delegates, applies lifetime-specific caching/storage, and wraps instances with decorators.
  4. Scope layer (Core/ScopeFeature) — Scope/IScope, the hierarchical container implementation that owns a Registry, a singleton repository, scoped instance dictionary, child scopes, and drives disposal.
  5. Lifecycle layer (Core/Lifecycle) — lifetime enum, initialization/startup interfaces (IInitializable, IAsyncInitializable, IStartable), and the CleanupService that tracks IDisposable/IAsyncDisposable instances for correct teardown.
  6. Convention layer (Core/ConventionBinding) — assembly-scanning, predicate/attribute/namespace-filtered auto-registration (ConventionBuilder).
  7. Configuration/Serialization layer (Core/Configuration) — DTOs (ScopeConfig, BindingConfig, DecoratorConfig) used to serialize/deserialize a container's registration graph to/from JSON.
  8. Reflection toolkit (SimplEnteiner.TypeAnalyzes, top-level namespace SimplEnteiner) — a large, independently reusable set of Type/ConstructorInfo/MemberInfo extension methods with a lazily-initialized, thread-safe, process-wide cache of loadable types.
  9. Analysis layer (SimplEnteiner.Analysis) — ReachabilityAnalyzer, used to compute the set of types reachable from a set of "root" service types, used both by Registry.AnalyzeReachability and directly by consumers.
  10. Integration layer (SimplEnteiner.Integrations.MS_DI) — adapters (SimplEnteinerServiceProvider, SimplEnteinerServiceScope, Extensions.AddSimplEnteiner) that expose a DIContainer/IScope as a standard IServiceProvider/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 (IBindingToIBindingLifetimeIBindingOptions) 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 (RegistryFactoryRegistry), 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.

NamespaceFolderResponsibility
SimplEnteinerSimplEnteiner/ (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.AnalysisAnalysis/ReachabilityAnalyzer — BFS-based reachability computation over a dependency graph.
SimplEnteiner.CoreCore/DIContainer (public entry point), Delegates.cs (ResolverFunc), Constants.cs, BindExample.cs (internal usage examples, not part of the public API contract).
SimplEnteiner.Core.AttributesCore/Attributes/InjectAttribute, IdAttribute — mark constructors/members for injection and disambiguate conditional/id-based bindings.
SimplEnteiner.Core.BinderCore/Binder/BindingBuilder — mutable, staged binding descriptor.
SimplEnteiner.Core.Binder.BuilderStagesCore/Binder/BuilderStages/Stage, InitialStage, ImplementationStage, LifetimeStage, OptionsStage, FinalStage, BuilderStateMachine — internal staged validation engine (all internal).
SimplEnteiner.Core.Binder.InterfacesCore/Binder/Interfaces/Public fluent contracts: IBinder, IBindingTo(<T>), IBindingLifetime(<T>), IBindingOptions(<T>), IBindingDecorate(<T>), IBindingDecorateLifetime(<T>), and the internal IBindingTarget.
SimplEnteiner.Core.Binder.ImplementationsCore/Binder/Implementations/Internal concrete implementations of the interfaces above: BindingTo(<T>), BindingLifetime(<T>), BindingOptions(<T>), BindingDecorate(<T>), BindingDecorateLifetime(<T>).
SimplEnteiner.Core.RegistrationServiceCore/RegistrationService/IRegistry/Registry (registration storage & validation), Registration, DecoratorRegistration.
SimplEnteiner.Core.RegistrationService.FactoryCore/RegistrationService/Factory/IRegistryFactory/RegistryFactory — pluggable Registry creation strategy.
SimplEnteiner.Core.ResolverServiceCore/ResolverService/IResolver/Resolver — the resolution algorithm (constructor/member injection, generic wrappers, decorators, lifetime storage).
SimplEnteiner.Core.ScopeFeatureCore/ScopeFeature/IScope/Scope (hierarchical container), ScopeCreationConfig, ConditionalKey, ResolutionContext (internal per-resolve state).
SimplEnteiner.Core.ScopeFactoryCore/ScopeFactory/IScopeFactory/DefaultScopeFactory — pluggable child-Scope creation strategy.
SimplEnteiner.Core.LifecycleCore/Lifecycle/LifeTime enum, IInitializable, IAsyncInitializable, IStartable, internal ICleanupService/CleanupService, internal IInterfaceInvoker/InterfaceInvoker.
SimplEnteiner.Core.RepositoryServiceCore/RepositoryService/IRepositoryService/RepositoryService — dictionary-like store for singleton instances with disposal tracking, wrapping a Dictionary<Type,object> behind numerous collection interfaces.
SimplEnteiner.Core.ConventionBinding.InterfacesCore/ConventionBinding/Interfaces/IConventionBuilder — fluent contract for assembly-scanning based auto-registration.
SimplEnteiner.Core.ConventionBinding.ImplementationsCore/ConventionBinding/Implementations/ConventionBuilder, internal ConventionBindType flags enum.
SimplEnteiner.Core.InstallerService.InterfacesCore/InstallerService/Interfaces/IInstaller — a unit of registration logic that can be discovered and invoked via ScanAndInstall.
SimplEnteiner.Core.ConfigurationCore/Configuration/Internal serializable DTOs: ScopeConfig, BindingConfig, DecoratorConfig, used by the export/import feature.
SimplEnteiner.UtilitiesUtilities/Cross-cutting extension methods: ThrowExtensions (argument guarding), ListExtensions (binary-search insertion for decorator ordering), ContainerExtensions (ScanAndInstall).
SimplEnteiner.Integrations.MS_DIIntegrations/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) are public.
  • Types that are implementation details consumers should not depend on directly (Registry, Resolver, RepositoryService, CleanupService, InterfaceInvoker, all Binder.Implementations and Binder.BuilderStages classes) are marked internal.
  • A small number of types are public because 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 DIContainer is created, configured synchronously via the configure callback, then immediately Build()-ed — meaning all validation (dependency graph resolvability, generic constraint checks) happens before returning from AddSimplEnteiner, so misconfiguration surfaces immediately at startup rather than lazily at first resolution.
  • Both IServiceProvider and IServiceScopeFactory are registered as singletons pointing to the same SimplEnteinerServiceProvider instance (which implements both interfaces plus ISupportRequiredService).
  • This call does not remove or interact with any services already registered directly on the IServiceCollection via services.AddSingleton<T>() etc. — it purely adds an IServiceProvider/IServiceScopeFactory pair. Whether the host actually uses this custom provider depends on the hosting infra (e.g. UseServiceProviderFactory in ASP.NET Core) — SimplEnteiner does not attempt to hijack IServiceCollection internals.

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());
}
  • GetService simply forwards to IScope.Resolve(Type). Any exception raised inside the SimplEnteiner resolution pipeline (e.g. "No binding found for X") propagates unchanged — MS.DI's GetService contract nominally expects null for unresolvable optional services, so callers relying on graceful null returns 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 to IScope.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 (via AddSingleton, AddScoped, etc.) into SimplEnteiner bindings — all bindings must be configured explicitly inside the configure callback passed to AddSimplEnteiner.
  • IServiceProviderIsService and other newer MS.DI diagnostic interfaces are not implemented.
  • Keyed services (IServiceCollection keyed registration APIs) are not directly mapped; SimplEnteiner's own WithId(...)/[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

MemberSignatureDescription
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 resulting IBindingOptions, or
  • In batch, when Build() is called (which flushes any bindings that were never explicitly Apply()-ed).

Resolution Members

MemberSignature
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

MemberSignatureDescription
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.
ParentIScope Parent { get; }Always null for the root DIContainer.
IsRootbool IsRoot { get; }Always true for DIContainer.
RegistryIRegistry 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 walking Parent chains).
  • IRepositoryService _singletonsshared with the root scope across the whole tree (singletons live process/container-wide, not per-scope).
  • Dictionary<Type, object> _scopedInstanceslocal to this scope; Scoped-lifetime instances are stored here and never shared with parent/children.
  • List<IScope> _childrens — direct children, used for cascading Build(), AnalyzeReachability, and disposal notification.
  • ICleanupService _cleanupService — tracks Transient/Cached disposables 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: targetimplementationlifetimeoptionsapply.

flowchart LR
    A["IBinder.Bind&lt;T&gt;()"] --> B["IBindingTo&lt;T&gt;"]
    B -->|To/ToSelf/ToMethod/ToInstance| C["IBindingLifetime&lt;T&gt;"]
    C -->|AsSingle/AsTransient/AsCached/AsScoped| D["IBindingOptions&lt;T&gt;"]
    D -->|WithArguments/WhenInjectedInto/WithId/OnActivation/OnRelease| D
    D -->|Apply| E[Registered in Registry]

    A2["IBinder.Decorate&lt;T&gt;()"] --> F["IBindingDecorate&lt;T&gt;"]
    F -->|With&lt;TDecorator&gt;order| G["IBindingDecorateLifetime&lt;T&gt;"]
    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);
}
MethodBehavior
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();
}
MethodBehavior
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:

BucketKeyPopulated when
ExactBindingsType (interface/service type, non-open-generic, no condition/id)Bind<T>()...Apply() for a closed type without WithId/WhenInjectedInto
OpenGenericBindingsType (open generic definition, e.g. typeof(IRepository<>))Bind(typeof(IRepository<>))...Apply()
ConditionalBindingsConditionalKey ((Type interfaceType, object id))WithId(id) or WhenInjectedInto<T>() (using the consumer Type as the id)
DecoratorBindingsType (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:

  1. 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).
  2. Builds a Registration (compiling a factory delegate via GetFactoryMethod, unless a custom FactoryMethod was supplied).
  3. Routes the registration into the correct bucket based on whether ConditionType/Id is set, or whether InterfaceType.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 — the Scope the 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 for WhenInjectedInto<T>() conditional matching; temporarily swapped to the type currently being constructed while resolving its dependencies.
  • CachedInstances — a ConcurrentDictionary<Type, object> used to implement the Cached lifetime within a single top-level Resolve() 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,
}
ValueStorageSharing scopeDisposal
TransientNot cached — a new instance is created on every resolution.None.Tracked by the current scope's CleanupService and disposed when that scope is disposed.
SingletonStored 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.
CachedStored 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.
ScopedStored 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 via ToInstance(...)), 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 then Start() is invoked.
  • Non-singleton registrations (Transient, Scoped, Cached) are not eagerly instantiated and therefore never receive an automatic Start() 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.GetInjectableConstructor looks 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], an Exception is 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 = false is the default since it's not specified, but inherit: true is used when checking IsDefined).
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);
}
MethodEffect
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:

  1. Create a new ConventionBuilder(scope).
  2. Invoke the caller's configure callback against it (throws ArgumentNullException if configure is null).
  3. Call builder.Build(), which performs the scan-and-bind (see below), then invokes ConfigureBinder if one was set via Configure(...).
  4. On DIContainer specifically, 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-wide List<Type> of every loadable type from every assembly known to the cache. Populated on first use of FindAllAssignableFrom (via LoadAllDomainTypes()), which seeds it from AppDomain.CurrentDomain.GetAssemblies().
  • s_assembliesCache — the HashSet<Assembly> backing the above, to avoid re-scanning already-known assemblies.
  • s_injectableConstructorsCacheConcurrentDictionary<Type, ConcurrentDictionary<Type, ConstructorInfo>>, memoizing GetInjectableConstructor results per (type, injectAttributeType) pair.
  • All cache access is guarded by a single object s_lock for the domain-types cache; the constructor cache uses lock-free ConcurrentDictionary operations.
  • 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

MethodSignaturePurpose
FindAllNonAbstractClassAssignableFromIEnumerable<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.
GetLoadableTypesIEnumerable<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.
IsConcreteClassbool 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.
IsAssignableToGenericTypeDefinitionbool IsAssignableToGenericTypeDefinition(this Type type, Type genericTypeDefinition)True if type implements/derives from the open generic definition genericTypeDefinition (walks interfaces and base-type chain).
GetAssignableToGenericArgumentsType[] 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.
GetInjectableConstructorConstructorInfo? 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.
GetDependencyTypeType GetDependencyType(this ParameterInfo parameterInfo)Equivalent to parameterInfo.ParameterType.GetUnderlyingDependencyType().
GetFactoryMethodFunc<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).
GetInjectableMembersIEnumerable<MemberInfo> GetInjectableMembers(this Type type, Type injectAttribute)All public/non-public instance fields, properties, and methods marked with injectAttribute.
MatchesGenericParametersbool MatchesGenericParameters(this Type[] args, Type[] constraints)True if every args[i] is assignable to constraints[i] and lengths match.
GetAllDependenciesHashSet<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).
HasCyclicDependenciesbool 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.
AddAssembliesvoid AddAssemblies(IEnumerable<Assembly> assemblies)Incrementally registers additional assemblies into the domain-types cache.
GetUnderlyingDependencyTypeType GetUnderlyingDependencyType(this Type type)Unwraps T[], IEnumerable<T>, Lazy<T>, Func<T> down to T; returns type unchanged otherwise.
SatisfiesOpenedGenericConstraintsbool 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.
SatisfiesClosedGenericConstraintsbool 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.
GetMarkedConstructorsConstructorInfo[] 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.
IsOptionalParameterbool IsOptionalParameter(this ParameterInfo parameter)True if the parameter is [Optional] or has a default value.
GetMemberDependencyTypeType[] 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.
ClearCachevoid 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);
}
ParameterDescription
servicesThe IServiceCollection to augment with a SimplEnteiner-backed IServiceProvider/IServiceScopeFactory.
configureCallback 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();
}
MemberBehavior
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 IServiceProvider registered by AddSimplEnteiner depends on how/whether the host's default service provider factory is overridden (e.g. via a custom IServiceProviderFactory<TContainerBuilder>), which SimplEnteiner does not currently supply out of the box. AddSimplEnteiner only 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

  1. Bind<TService>() creates a BindingBuilder for typeof(TService), adds it to the owning container/scope's internal _pendingBindings list (thread-safe via a lock), and returns an IBindingTo<TService> wrapping both the builder and the owning IBindingTarget (the container/scope, accessed only through the internal interface — never exposed publicly).

  2. Each fluent call (To, AsSingle, WithId, ...) mutates the same underlying BindingBuilder and advances (or validates against) its BuilderStateMachine. Calling a setter for a stage that has already been passed throws InvalidOperationException (e.g. "Lifetime already set!"), preventing accidental double-configuration.

  3. Apply() (on IBindingOptions/IBindingOptions<T>) is the terminal step for a non-decorator binding. It calls the owning target's internal Register(BindingBuilder):

    • builder.ExecuteAllStages() runs any stages not yet explicitly configured (defaulting Lifetime to Transient and Implementation to self, if the caller skipped those steps entirely — though in practice Bind<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 resulting Registration in the appropriate bucket (ExactBindings, OpenGenericBindings, or ConditionalBindings — see Registration and Resolution).
    • builder.MarkRegistered() sets IsRegistered = true, and the builder is removed from _pendingBindings.
  4. If Apply() is never called for a given BindingBuilder (a common pattern for decorator-less simple usage where a caller forgets it, or intentionally defers), the binding remains "pending." Calling container.Build() (or BindConvention(...) on DIContainer) flushes all pending bindings via BuildPendings(), registering each one exactly as Apply() would, and clearing the pending list. This makes Build() a safety net that guarantees no binding is silently lost, though relying on it instead of explicit Apply() calls is discouraged for readability.

    Note: Scope.Bind<T>() (used for child scopes, as opposed to DIContainer.Bind<T>()) does not maintain its own pending list — bindings created directly on a child Scope must be explicitly .Apply()-ed; only DIContainer performs 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.AddValidate) 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&lt;T&gt;| Enum[ResolveAllEnumerable: find all ExactBindings assignable to T, resolve each]
    IsGeneric -->|Lazy&lt;T&gt;| Lazy[CreateLazy: build Lazy&lt;T&gt; wrapping a compiled Func]
    IsGeneric -->|Func&lt;T&gt;| Func[CreateFunc: compile Expression.Lambda calling Resolve&lt;T&gt; 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:

  1. Explicit id (context.Id != null) — looked up via FindConditionalRegistration(interfaceType, context.Id), walking the scope chain (this scope → parent → parent's parent → ...). Throws InvalidOperationException immediately if not found (no fallback for explicit ids).
  2. 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.
  3. Exact registrationFindExactRegistration(interfaceType), walking the scope chain.
  4. Closed-generic-from-open-generic — only if interfaceType is a non-open generic type; looks for a matching OpenGenericBindings entry for interfaceType.GetGenericTypeDefinition(), closes the registered open implementation via MakeGenericType, and validates SatisfiesOpenedGenericConstraints before accepting it.
  5. Self-registration fallback — if interfaceType.IsConcreteClass(isIgnoreGeneratedType: true), an ad-hoc Transient Registration is created on the fly (no explicit Bind call required to resolve a concrete, injectable class — a common convenience in many DI containers).
  6. Otherwise, null is returned, and the caller (ResolveInternal) throws InvalidOperationException("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 ShapeBehavior
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:

  1. 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, and IInitializable entirely — those only fire on new instantiation).
  2. Otherwise, resolves the constructor's parameters:
    • If the registration has explicit Arguments (from .WithArguments(...)), uses ResolveConstructorWithArguments, 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 via ResolveInternal, temporarily setting context.RequestType to the type being constructed (enabling WhenInjectedInto<T>() conditional matches for its dependencies) and swapping context.Id in/out per-parameter based on any [Id] attribute present.
  3. Invokes the registration's compiled Factory delegate with the resolved parameter array.
  4. Injects members (InjectMembers): iterates GetInjectableMembers for the implementation type, setting fields/properties via reflection and invoking [Inject]-marked methods with resolved parameters (same per-parameter [Id] handling as constructor parameters).
  5. Stores the instance according to its lifetime (StoreInstance).
  6. Invokes IInitializable.Initialize() if implemented.
  7. 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

LifetimeWhere the instance is storedLookup keyShared acrossExample use case
TransientNot stored for reuse — a new instance every resolution.n/aNothing.Lightweight, stateless, or mutable-per-use objects (e.g. a command object, a DTO builder).
SingletonIRepositoryService (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.
ScopedEach 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.
CachedThe 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 _childrens list 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 nested using blocks) 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() mirrors Dispose() but awaits _cleanupService.DisposeAsync(), which prefers IAsyncDisposable.DisposeAsync() over IDisposable.Dispose() per tracked instance when both are implemented.
  • The OnRelease callback (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:

  1. If the decorator's own Lifetime is Singleton or Scoped, checks whether an instance for that decorator type already exists in the appropriate store (GetSingleton/GetScoped keyed by decorator.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.
  2. Otherwise, CreateDecoratorInstance constructs a new decorator instance, passing the current instance as an additional constructor argument (matched by type, same mechanism as .WithArguments(...), see ResolveConstructorWithArguments), 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-decoration instance, not the new decorator — see the source for the exact call sequence).
  3. instance is 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 IsAssignableFrom check.

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: DeserializeRegistration resolves Type.GetType(bindingConfig.InterfaceType/ImplementationType) and builds a new Registration with Factory = null — meaning the compiled factory delegate is not restored; it is null on the deserialized Registration. Any code path that would call registration.Factory(parameters) on such a registration (i.e., actually resolving it after import, when no live instance exists) would encounter a null factory. In practice, Instance is separately restored via JsonSerializer.Deserialize(bindingConfig.InstanceJson, type), so instance-bound (effectively-singleton) bindings round-trip more usefully than factory/constructor-based ones.
  • Decorator bindings: reconstructed with Constructor = null and Factory = 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

  • Instance round-trips via System.Text.Json, which means only types compatible with System.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 Factory on import (see above) — this means a plain Bind<T>().To<Impl>().AsTransient().Apply()-style registration, after ExportConfiguration()/ImportConfiguration(), will have Factory == null on its restored Registration. Since Resolver.ResolveRegistration unconditionally calls registration.Factory(parameters) for these paths (no lazy-factory-regeneration fallback like decorators have), resolving a non-instance exact/open-generic/conditional binding after ImportConfiguration will throw a NullReferenceException unless that specific limitation is addressed by application code (e.g., re-registering critical bindings after import, or only relying on ExportConfiguration/ImportConfiguration for 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/OnRelease callbacks are not serialized at all (delegates cannot be represented in JSON) — they are always null on deserialized registrations.
  • ConditionType/Id on conditional bindings round-trip via AssemblyQualifiedName (for a Type condition) or JsonSerializer.Deserialize<object>(...) (for an arbitrary id object), with the deserialized id's runtime type depending on how System.Text.Json infers object-typed values (numbers become JsonElement/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 _exactBindings of 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 _exactBindings but were never encountered while walking the dependency graph from roots. 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 in roots.
  • Missing bindings — types that are reachable from roots but 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 than ValidateAll()'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.

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:

  1. The fluent binder API (Bind, Decorate, BindConvention) — see Binder API and Convention-Based Binding.
  2. The ScopeCreationConfig object, which lets you override the pluggable strategy implementations (IResolver, IRegistryFactory, IScopeFactory, IRepositoryService) — see below.
  3. 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 Scope directly (new Scope(Action<ScopeCreationConfig> configure)), bypassing DIContainer, or
  • Extend/modify the library source, since DIContainer's ConfigureConfig method is private.

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

WhatHow
Which implementation is used for an interfaceBind<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 hooksIInitializable, IAsyncInitializable, IStartable, .OnActivation(...), .OnRelease(...)
Bulk registration by conventionBindConvention(...) — namespace/attribute/predicate filters
Cross-cutting wrappingDecorate<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>
PackageVersionWhy it's used
Microsoft.Extensions.DependencyInjection10.0.7Provides 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.Json10.0.7Powers 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>
PackageVersionPurpose
coverlet.collector6.0.0Code coverage collection when running dotnet test (integrates with the MSTest/VSTest data collector pipeline).
Microsoft.NET.Test.Sdk17.8.0Standard .NET test SDK required to run any test project via dotnet test.
xunit2.5.3The test framework used for every test in the solution (see Testing and Quality Assurance).
xunit.runner.visualstudio2.5.3Test 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

CategoryLocationWhat it covers
Unit testsTypeAnalyzesTests/*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 testsTypeAnalyzesTests/IntegrationTests.csExercises 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 typesTestTypes/**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 infrastructureTestInfrastructure/TypeExtensionsTestBase.csA 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 — a ConcurrentDictionary<Type, ConcurrentDictionary<Type, ConstructorInfo>> memoizing GetInjectableConstructor(type, injectAttributeType) results, avoiding repeated GetConstructors() + attribute-scanning + LINQ ordering on every call for the same (type, attribute) pair. This directly benefits the hot resolution path, since Resolver calls GetInjectableConstructor on every non-cached-instance resolution.
  • s_cachedDomainTypes — a process-wide, lazily-populated List<Type> avoiding repeated AppDomain.CurrentDomain.GetAssemblies() + GetTypes() scans for every call to FindAllAssignableFrom/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 (ConcurrentDictionary for the constructor cache; a single lock (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() and AnalyzeReachability() 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 every Build() 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.ResolveAllEnumerable linearly scans context.CurrentScope.GetAllExactRegistration() for every assignable type, on every resolution of an IEnumerable<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 a Singleton wrapper service) if this becomes measurable in your application.
  • Func<T> wrapper compilation happens on every resolution (Resolver.CreateFunc builds and compiles a new Expression.Lambda each time a Func<T> dependency is resolved) — unlike constructor factories (compiled once and cached on the Registration), this compilation is not cached/memoized anywhere. If Func<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 compiled Func<T> factory per (argumentType, scope) pair.
  • Decorator resolution walks the entire scope chain (Scope.GetDecoratorRegistrations collects every ancestor scope into a List<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/Scoped over Transient/Cached for expensive-to-construct services that don't need per-call freshness — this avoids repeated constructor/member-injection work and repeated IInitializable.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 call Build() inside a request/resolution hot path.
  • Reuse IScope instances appropriately (e.g., one child scope per logical unit of work/request) rather than creating and disposing scopes excessively — scope creation allocates a new Registry, _scopedInstances dictionary, and CleanupService each 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.

ExceptionThrown byWhen
ArgumentNullExceptionThrowExtensions.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.
InvalidOperationExceptionBindingBuilder.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!").
InvalidOperationExceptionRegistry.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.
InvalidOperationExceptionRegistry.AnalyzeReachability()Unreachable registrations and/or missing bindings for reachable non-concrete types were found (see Reachability Analysis and Validation).
InvalidOperationExceptionResolver.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}".
InvalidOperationExceptionResolver.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}'".
InvalidOperationExceptionScope.RegisterDecoratorNo injectable constructor exists for a registered decorator type. Message: "No constructor for decorator {decoratorType}".
InvalidOperationExceptionBindingDecorate.Validate / Registry.ValidateDecoratorA 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.
InvalidOperationExceptionSimplEnteinerServiceProvider.GetRequiredServiceResolve(serviceType) returned null (see MS.DI Integration API).
ArgumentExceptionRegistry.Validate / ValidateFirstStep / ValidateSecondStep / ValidateDecoratorAn 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.
ArgumentExceptionTypeAnalyzes.SatisfiesClosedGenericConstraintsThe supplied closedGenericDefinition argument is not actually a closed generic type (i.e., it's either non-generic or is itself an open generic definition).
ArgumentExceptionConventionBuilder.FromAssemblyThe same assembly is added twice via FromAssembly/FromAssemblies.
Exception (plain)TypeAnalyzes.GetInjectableConstructorMore 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

  1. 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;
    }
    
  2. 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.

  3. Do not rely on GetService-style null-on-miss semantics when going through the MS.DI integrationSimplEnteinerServiceProvider.GetService propagates SimplEnteiner's own InvalidOperationException for missing non-concrete bindings rather than returning null, which differs from some other IServiceProvider implementations. Use try/catch or pre-validate via Build()/AnalyzeReachability instead of relying on null-checks after GetService.

  4. Treat the plain Exception thrown by GetInjectableConstructor (multiple [Inject]-marked constructors) as a compile-time-detectable class-authoring mistake — fix the offending type rather than catching this specific case at runtime.

  5. OnRelease/OnActivation callbacks should not throw. Neither CleanupService.Dispose()/DisposeAsync() nor Resolver.ResolveRegistration wrap these callback invocations in try/catch — an exception thrown from an OnRelease callback during scope disposal will propagate out of Dispose()/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/IStartable on 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

RequirementDetail
.NET SDKAn 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.
IDEAny 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 tooldotnet 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.
OSAny 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/*.yml exists (only .github/copilot-instructions.md, unrelated to CI/CD).
  • No dotnet nuget push script, 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 the I prefix (IBinder → no direct class; IRegistryRegistry; IResolverResolver).
  • Visibility discipline: Types not meant for direct consumer use are consistently marked internal (all of Binder.Implementations, Binder.BuilderStages, Registry, Resolver, RepositoryService, CleanupService, InterfaceInvoker, RegistryFactory, DefaultScopeFactory). See Architecture → Namespace Structure → Visibility Conventions — follow this pattern for new code: default to internal, and only make something public when 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 inline if (x == null) throw ... blocks. Prefer these helpers for consistency in new code.
  • XML doc comments: Public API surfaces in TypeAnalyzes.cs are thoroughly documented with <summary>, <param>, <returns>, and <exception> tags. This standard is not uniformly applied across Core/* (many public interfaces/classes there currently have no XML doc comments) — new public API additions should follow the TypeAnalyzes.cs standard of complete XML documentation.
  • Nullable reference types: Enabled in the test project (<Nullable>enable</Nullable> in SimplEnteinerTests.csproj) but not enabled in the main library project (SimplEnteiner.csproj has no <Nullable> setting, defaulting to disabled for its netstandard2.1 target). TypeAnalyzes.cs nonetheless 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.cs contains both IBindingTo<TInterface> and IBindingTo, IBindingLifetime.cs contains 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 touches TypeAnalyzes'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:

  1. Open an issue or discussion describing the change before large refactors.
  2. Keep pull requests focused on a single logical change.
  3. Ensure dotnet build and dotnet test both succeed locally before submitting.
  4. Update this documentation (src/) alongside any change to public API surface, behavior, or configuration — per this project's own repository rule that documentation in docs/src must be kept in sync with code changes.
  5. 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:

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:

  1. NuGet packaging is not configured — no PackageId/Version/Description/license metadata, no dotnet pack/publish pipeline. See Building and Deployment.
  2. 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.
  3. Test coverage gap for Core.* namespaces — the current test suite exhaustively covers TypeAnalyzes but has no dedicated tests for DIContainer, Scope, Resolver, Registry, decorators, or the MS.DI integration layer, despite fixture types already prepared for lifetime/disposal testing. See Testing and Quality Assurance.
  4. No public extensibility point for custom ScopeCreationConfig on DIContainerConfigureConfig is private, so swapping in a custom IResolver/IRegistryFactory/IScopeFactory/IRepositoryService currently requires bypassing DIContainer and using Scope directly. See Configuration and Settings.
  5. Serialization round-trip fidelity is incomplete — non-instance, non-decorator registrations lose their factory delegate on import and will throw NullReferenceException if resolved afterward; OnActivation/OnRelease callbacks are never serialized. See Configuration Import/Export (Serialization) → Important Caveats.
  6. No logging integration — there is no ILogger/Microsoft.Extensions.Logging hook anywhere; all diagnostics must be added by consumers via OnActivation/OnRelease/lifecycle interfaces/decorators. See Error Handling and Logging.
  7. No BenchmarkDotNet project or published performance numbers — performance characteristics in this book are derived from code inspection, not measurement. See Performance and Optimization.
  8. 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.
  9. No CONTRIBUTING.md, license file, or issue/PR templates currently exist in the repository. See Contributing.

Additional Resources

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.