1. Requirements & Scope (5 min)
Functional Requirements
- Register services with their implementations (interface → concrete class mapping) using explicit registration or auto-discovery (annotations/attributes)
- Support three lifecycle scopes: Singleton (one instance per container), Transient (new instance per request), and Scoped (one instance per scope, e.g., per HTTP request)
- Automatically resolve dependency graphs — if Service A depends on B and C, and B depends on D, resolve the full chain recursively
- Detect circular dependencies at registration time (not at resolve time) and provide clear error messages showing the cycle
- Support named/keyed registrations, factory functions, lazy initialization, and optional dependencies
Non-Functional Requirements
- Performance: Singleton resolution < 100ns (cached lookup). Transient resolution < 1μs for a 5-deep dependency chain. The container is called millions of times — it must be near-zero overhead.
- Memory: Container metadata (registrations, dependency graph) < 10MB for 10K registered services. No memory leaks from scoped instances.
- Thread Safety: All resolve operations must be thread-safe. Singleton creation must be exactly-once (no double initialization).
- Developer Experience: Clear error messages. Fail-fast on misconfiguration. Support for debugging tools (dependency graph visualization, unused registration detection).
- Extensibility: Plugin architecture for interceptors (AOP), decorators, and custom lifetime managers.
2. Estimation (3 min)
Scale (typical large application)
- Registered services: 500-5,000 in a large enterprise app
- Dependency depth: Average 3-5 levels, max 10-15 levels
- Resolution frequency: Web app handling 10K requests/sec, each request resolves 20-50 services = 200K-500K resolutions/sec
- Scoped instances: Per HTTP request, ~30 scoped instances created and disposed
Memory
- Registration metadata: 5,000 services × 500 bytes (type info, lifetime, dependencies) = 2.5MB
- Singleton cache: 500 singletons × 1KB average = 500KB
- Scoped instances per request: 30 × 1KB = 30KB (short-lived, GC’d after request)
- Compiled resolution plans (pre-computed): 5,000 × 200 bytes = 1MB
- Total container overhead: ~5MB — negligible
Key Insight
This is fundamentally a graph problem (dependency graph resolution) combined with a caching problem (singleton/scoped instance reuse). The critical optimization is pre-computing resolution plans at registration time so that resolve-time is just executing a pre-built plan — no graph traversal needed in the hot path.
3. API Design (3 min)
Registration API
// Basic registration
container.Register<IUserRepository, SqlUserRepository>(Lifetime.Scoped);
container.Register<IEmailService, SmtpEmailService>(Lifetime.Singleton);
container.Register<ILogger, ConsoleLogger>(Lifetime.Transient);
// Factory registration (custom instantiation logic)
container.Register<IDbConnection>(factory: ctx => {
var config = ctx.Resolve<IConfig>();
return new NpgsqlConnection(config.ConnectionString);
}, Lifetime.Scoped);
// Named registration (multiple implementations of same interface)
container.Register<ICache, RedisCache>("distributed", Lifetime.Singleton);
container.Register<ICache, MemoryCache>("local", Lifetime.Singleton);
// Instance registration (pre-built singleton)
container.RegisterInstance<IConfig>(new AppConfig("prod"));
// Auto-discovery (scan assembly for annotated classes)
container.Scan(assembly: typeof(Startup).Assembly, convention: c => {
c.AddAllImplementationsOf<IEventHandler>();
c.WithDefaultLifetime(Lifetime.Scoped);
});
// Decorator registration
container.Decorate<IUserRepository, CachingUserRepository>();
// Resolving IUserRepository now returns CachingUserRepository
// wrapping SqlUserRepository
// Validation (call after all registrations)
container.Validate(); // throws if missing dependencies, cycles, etc.
Resolution API
// Basic resolution
IUserRepository repo = container.Resolve<IUserRepository>();
// Named resolution
ICache cache = container.Resolve<ICache>("distributed");
// Try-resolve (returns null instead of throwing)
IOptionalService? svc = container.TryResolve<IOptionalService>();
// Resolve all implementations
IEnumerable<IEventHandler> handlers = container.ResolveAll<IEventHandler>();
// Scoped resolution
using (var scope = container.CreateScope()) {
var repo = scope.Resolve<IUserRepository>(); // scoped instance
// ... handle HTTP request ...
} // all scoped instances disposed here
Key Decisions
- Explicit over implicit: Require registration before resolution (no auto-wiring by default). This makes the dependency graph explicit and debuggable.
- Validate at startup: The
Validate()call traverses the entire dependency graph and catches all errors (missing registrations, cycles, lifetime mismatches) before the application starts serving traffic. - Scope as a child container: A scope inherits all registrations from the root container but maintains its own instance cache for scoped lifetimes.
4. Data Model (3 min)
This is an in-process library, not a distributed system — so the “data model” is the internal data structures.
Registration Store
class ServiceRegistration {
Type ServiceType // interface or abstract class
Type ImplementationType // concrete class
Lifetime Lifetime // Singleton | Transient | Scoped
string? Name // for named registrations
Func<IScope,T>? Factory // optional custom factory
Type[] Dependencies // constructor parameter types (pre-computed)
int RegistrationOrder // for deterministic resolution of duplicates
}
// Stored in:
Dictionary<Type, List<ServiceRegistration>> registrations;
// Key: ServiceType, Value: list (supports multiple implementations)
Dependency Graph (pre-computed at Validate time)
class DependencyNode {
ServiceRegistration Registration
DependencyNode[] Dependencies // resolved child nodes
int DepthFromRoot // for cycle detection
}
// Stored as a DAG:
Dictionary<Type, DependencyNode> dependencyGraph;
Resolution Plan (compiled)
class ResolutionPlan {
Type ServiceType
Instruction[] Steps
// Steps are a pre-computed sequence:
// 1. Check singleton cache → return if found
// 2. Resolve dependency A (recurse into A's plan)
// 3. Resolve dependency B
// 4. Call constructor(A, B) → create instance
// 5. Store in singleton cache (if singleton)
// 6. Return instance
}
// Compiled into a delegate for maximum performance:
Dictionary<Type, Func<IScope, object>> compiledResolvers;
Instance Caches
// Singleton cache (root container, thread-safe)
ConcurrentDictionary<Type, Lazy<object>> singletonCache;
// Scoped cache (per scope, single-threaded within a request)
Dictionary<Type, object> scopedCache;
Why This Structure
- Dictionary by ServiceType enables O(1) registration lookup
- Pre-computed dependency graph enables cycle detection at startup, not at runtime
- Compiled delegates eliminate reflection overhead in the hot path — resolution becomes a series of direct method calls
- Lazy
for singletons guarantees exactly-once initialization in a thread-safe manner
5. High-Level Design (12 min)
Container Lifecycle
Application Startup:
1. Create Container
2. Registration Phase:
→ container.Register<IFoo, Foo>(Lifetime.Singleton)
→ container.Register<IBar, Bar>(Lifetime.Scoped)
→ container.Scan(assembly, conventions)
→ container.Decorate<IFoo, CachingFoo>()
3. Validation Phase (container.Validate()):
→ Build dependency graph from all registrations
→ Detect cycles (topological sort / DFS with coloring)
→ Check for missing dependencies
→ Check for lifetime mismatches (scoped → singleton is invalid)
→ Compile resolution plans into delegates
4. Container is now FROZEN (no more registrations)
Request Handling:
5. Create Scope → scope = container.CreateScope()
6. Resolve services → scope.Resolve<IFoo>()
→ Execute compiled resolution plan
→ Check caches (singleton → root, scoped → scope)
→ Create instances as needed
7. Dispose Scope → scope.Dispose()
→ Dispose all scoped instances (in reverse creation order)
→ Clear scoped cache
Resolution Flow (detailed)
Resolve<IUserService>()
→ Look up compiled resolver for IUserService
→ Execute plan:
1. IUserService needs IUserRepository and ILogger
2. Resolve IUserRepository (Scoped):
→ Check scopedCache → miss
→ IUserRepository needs IDbConnection
→ Resolve IDbConnection (Scoped):
→ Check scopedCache → miss
→ Execute factory: new NpgsqlConnection(connString)
→ Store in scopedCache
→ Call new SqlUserRepository(dbConnection)
→ Store in scopedCache
3. Resolve ILogger (Singleton):
→ Check singletonCache → hit! Return cached instance
4. Call new UserService(userRepo, logger)
→ Return UserService instance (Transient, not cached)
Components
- Registration Builder: Fluent API for adding registrations. Validates basic sanity (type assignability) immediately. Supports conventions and scanning.
- Graph Compiler: Builds the dependency DAG, runs validation, and compiles resolution plans into IL or expression tree delegates.
- Resolver: Executes compiled plans. Manages singleton cache (thread-safe with double-check locking via Lazy
) and delegates scoped caching to Scope objects. - Scope Manager: Creates and manages scopes. Each scope has its own instance cache and tracks disposable instances for cleanup.
- Interceptor Pipeline: Optional AOP layer. Wraps resolved instances with proxies that intercept method calls (for logging, caching, transactions, etc.).
- Diagnostic Tools: Dependency graph visualization, resolution timing, unused registration detection, lifetime mismatch warnings.
6. Deep Dives (15 min)
Deep Dive 1: Circular Dependency Detection
The problem: If A depends on B, and B depends on A, naive resolution would stack overflow. Worse: A → B → C → A is harder to spot.
Detection algorithm: DFS with three-color marking
enum Color { White, Gray, Black }
void DetectCycles(Dictionary<Type, DependencyNode> graph) {
var colors = new Dictionary<Type, Color>();
var path = new Stack<Type>(); // for error message
foreach (var node in graph.Values) {
if (colors[node.Type] == White) {
DFS(node, colors, path);
}
}
}
void DFS(DependencyNode node, colors, path) {
colors[node.Type] = Gray; // currently visiting
path.Push(node.Type);
foreach (var dep in node.Dependencies) {
if (colors[dep.Type] == Gray) {
// CYCLE DETECTED! Gray node encountered again
// path contains the full cycle for the error message
throw new CircularDependencyException(
$"Circular dependency: {FormatCycle(path, dep.Type)}"
// "IUserService → IOrderService → IUserService"
);
}
if (colors[dep.Type] == White) {
DFS(dep, colors, path);
}
}
path.Pop();
colors[node.Type] = Black; // fully explored
}
Time complexity: O(V + E) where V = number of registered services, E = number of dependency edges. For 5,000 services with average 3 dependencies = O(5,000 + 15,000) = O(20,000). Runs in < 1ms.
This runs at Validate() time, not at Resolve() time. The application fails fast at startup with a clear error message showing the exact cycle path.
Breaking cycles (when they’re legitimate):
- Lazy
injection: Instead of injecting IFoo, inject Lazy. The container provides a lazy wrapper that only resolves when .Value is accessed, breaking the construction-time cycle. - Property injection: Instead of constructor injection (which creates the cycle at construction), inject one of the dependencies via a property setter after construction.
- Both are code smells — cycles usually indicate a design problem. The container should warn loudly.
Deep Dive 2: Lifetime Management and Mismatch Detection
The captive dependency problem:
container.Register<ICache, RedisCache>(Lifetime.Singleton);
container.Register<IDbConnection, NpgsqlConnection>(Lifetime.Scoped);
// RedisCache constructor:
class RedisCache : ICache {
public RedisCache(IDbConnection db) { ... }
}
This is a lifetime mismatch bug: RedisCache is a Singleton that depends on a Scoped service. The Singleton is created once and holds a reference to a single IDbConnection. When the scope is disposed, the connection is closed — but the Singleton still holds a reference to the dead connection. Every subsequent request uses a disposed connection.
Detection at Validate() time:
void ValidateLifetimes(DependencyNode node) {
foreach (var dep in node.Dependencies) {
if (node.Lifetime == Singleton && dep.Lifetime == Scoped) {
throw new LifetimeMismatchException(
$"Singleton '{node.Type}' depends on Scoped '{dep.Type}'. "
+ "This will capture a single scoped instance for the "
+ "lifetime of the application."
);
}
if (node.Lifetime == Singleton && dep.Lifetime == Transient) {
Warn($"Singleton '{node.Type}' depends on Transient '{dep.Type}'. "
+ "The transient will effectively become a singleton.");
}
}
}
Lifetime hierarchy: Singleton > Scoped > Transient. A service can only depend on services with equal or longer lifetimes. Singleton can depend on Singleton. Scoped can depend on Scoped or Singleton. Transient can depend on anything.
Scope disposal ordering:
Scope tracks creation order:
Created: [DbConnection, UserRepository, OrderService]
On Dispose(), reverse order:
1. OrderService.Dispose()
2. UserRepository.Dispose()
3. DbConnection.Dispose()
This ensures dependencies are disposed AFTER the services that use them.
Deep Dive 3: Performance — Compiled Resolution Plans
The problem: Naive resolution uses reflection for every Resolve() call:
// Slow path (reflection):
var ctor = implementationType.GetConstructors()[0];
var parameters = ctor.GetParameters();
var args = parameters.Select(p => Resolve(p.ParameterType)).ToArray();
var instance = ctor.Invoke(args); // ~500ns per invocation
For 500K resolutions/sec, this adds 250ms/sec of pure reflection overhead.
Solution: Compile resolution plans into delegates at Validate() time
Approach 1: Expression Trees (C# / .NET)
// At Validate() time, generate an expression tree:
var ctorInfo = typeof(UserService).GetConstructor(
new[] { typeof(IUserRepository), typeof(ILogger) });
Expression<Func<IScope, object>> expr =
scope => new UserService(
(IUserRepository)scope.Resolve(typeof(IUserRepository)),
(ILogger)scope.Resolve(typeof(ILogger))
);
Func<IScope, object> compiled = expr.Compile();
// This compiles to IL — same performance as hand-written code
Approach 2: IL Emission (maximum performance)
DynamicMethod dm = new DynamicMethod("Resolve_UserService", ...);
ILGenerator il = dm.GetILGenerator();
il.Emit(OpCodes.Ldarg_0); // load scope
il.Emit(OpCodes.Call, resolveUserRepo); // resolve IUserRepository
il.Emit(OpCodes.Ldarg_0); // load scope
il.Emit(OpCodes.Call, resolveLogger); // resolve ILogger
il.Emit(OpCodes.Newobj, userServiceCtor); // new UserService(repo, logger)
il.Emit(OpCodes.Ret);
Performance comparison:
Reflection: ~500ns per resolution
Expression Trees: ~50ns per resolution (10x faster)
IL Emission: ~30ns per resolution (17x faster)
Direct new(): ~10ns (theoretical minimum)
For singletons, the compiled delegate runs once. The hot path is just a dictionary lookup returning the cached instance — < 20ns.
Trade-off: Expression trees are readable and debuggable. IL emission is faster but opaque. Most real-world containers (Autofac, Microsoft.Extensions.DI) use expression trees as the sweet spot.
7. Extensions (2 min)
- AOP / Interceptors: Automatically wrap resolved instances with proxies that intercept method calls. Use cases: logging every method call, automatic transaction management, caching return values, retry policies. Implemented via Castle DynamicProxy or DispatchProxy.
- Child containers: Create a child container that inherits parent registrations but can override or add new ones. Useful for multi-tenant applications where each tenant has different service implementations.
- Module system: Group related registrations into modules (e.g.,
DataAccessModule,CachingModule). Modules can declare dependencies on other modules. Application composes modules at startup. - Hot reload: In development, detect changes to registered types and re-resolve affected singletons without restarting the application. Requires a “mutable” container mode disabled in production.
- Diagnostics endpoint: Expose a
/debug/containerHTTP endpoint showing the full dependency graph, resolution timings, instance counts by lifetime, and potential issues (unused registrations, deep chains > 10 levels).