Why Late Binding?

Late binding (or lazy instantiation) is a core design principle of hdmi. This document explains why we chose this approach and how it benefits your applications.

What is Late Binding?

Late binding means that services are instantiated only when they’re first accessed, not when the builder is configured or the container is built.

        graph LR
    subgraph "Early Binding (Eager)"
        E1[Configure] --> E2[Build]
        E2 --> E3[Instantiate ALL]
        E3 --> E4[Use Services]
    end

    subgraph "Late Binding (Lazy)"
        L1[Configure] --> L2[Build & Validate]
        L2 --> L3[Use Service A]
        L3 --> L4[Instantiate A only]
        L2 --> L5[Use Service B]
        L5 --> L6[Instantiate B only]
    end

    style E3 fill:#ffcccc
    style L4 fill:#ccffcc
    style L6 fill:#ccffcc
    

With early binding, all services are created upfront. With late binding, they’re created on-demand.

Benefits of Late Binding

1. Faster Startup Time

Applications start faster because services aren’t created until needed:

import asyncio

async def main():
    # With early binding: ALL services created here (slow!)
    container = builder.build()

    # With late binding: only validation happens (fast!)
    container = builder.build()

    # Services created only when accessed
    service = await container.get(MyService)  # <- instantiation happens here

asyncio.run(main())

For applications with many services, this can significantly reduce startup time.

2. Lower Memory Footprint

Services that are never used are never created:

import asyncio

async def main():
    # Register many services
    builder.register(ServiceA)
    builder.register(ServiceB)
    builder.register(ServiceC)
    # ... 50 more services ...

    container = builder.build()

    # Only use one service
    service_a = await container.get(ServiceA)

    # ServiceB, ServiceC, and the other 50 services are never instantiated
    # Memory is conserved!

asyncio.run(main())

This is especially valuable in:

  • CLI applications (not all commands use all services)

  • Testing (tests often use a subset of services)

  • Microservices (different endpoints use different services)

3. Conditional Service Creation

Services can be created based on runtime conditions:

import asyncio

async def main():
    container = builder.build()

    if user_wants_feature_x():
        # Service created only if feature is used
        feature_x = await container.get(FeatureXService)

    if environment == "production":
        # Different service for production
        monitor = await container.get(ProductionMonitor)
    else:
        # Different service for development
        monitor = await container.get(DevMonitor)

asyncio.run(main())

4. Better Error Isolation

Errors in service constructors don’t prevent the application from starting:

import asyncio

async def main():
    # Build succeeds even if OptionalService has constructor issues
    container = builder.build()

    try:
        # Error only occurs if we actually try to use this service
        optional = await container.get(OptionalService)
    except Exception:
        # Handle gracefully - other services still work
        logger.warning("Optional feature unavailable")

    # Core services still work fine
    core = await container.get(CoreService)

asyncio.run(main())

5. Circular Dependency Detection Without Full Instantiation

Late binding allows us to validate the dependency graph without creating instances:

# These services have a circular dependency
builder.register(ServiceA)  # depends on ServiceB
builder.register(ServiceB)  # depends on ServiceA

# Validation catches the cycle immediately
container = builder.build()  # Raises CircularDependencyError

# No services were instantiated!
# The error is caught at graph-validation time, not instantiation time

Early Validation + Late Instantiation

The key insight of hdmi is that you can validate the dependency graph without instantiating services:

        graph TD
    Builder[ContainerBuilder<br/>Configuration] --> Build{.build}
    Build -->|Validation| CheckCycles[Check for cycles]
    CheckCycles --> CheckTypes[Check types]
    CheckTypes --> CheckResolvable[Check all resolvable]
    CheckResolvable --> Container[Container<br/>Validated Graph]

    Container -->|Later| Get{.get}
    Get --> Instantiate[Instantiate Service]

    style Build fill:#fff4e1
    style Instantiate fill:#e1ffe1
    

This gives you:

  • Safety: All configuration errors detected at build time

  • Performance: Services created only when needed

  • Flexibility: Runtime decisions about which services to use

When Late Binding Isn’t Appropriate

Late binding isn’t always the right choice. Consider early binding when:

  1. Fail-Fast is Critical If you need to know immediately that all services can be instantiated successfully:

    import asyncio
    
    async def main():
        # With late binding, this succeeds even if services can't be created
        container = builder.build()
    
        # You might want to eagerly instantiate critical services
        await container.get(DatabaseConnection)  # Fail immediately if DB is down
        await container.get(ConfigService)       # Fail immediately if config is invalid
    
    asyncio.run(main())
    
  2. Warm-Up is Beneficial For some services, instantiation is expensive and you want to do it upfront:

    import asyncio
    
    async def main():
        # Warm up expensive services during startup
        ml_model = await container.get(MachineLearningModel)  # Takes 30 seconds
        cache = await container.get(CacheService)             # Needs initialization
    
        # Now the services are ready for fast access
    
    asyncio.run(main())
    
  3. Deterministic Resource Allocation When you need to know upfront what resources will be allocated:

    # Late binding makes it hard to predict memory usage
    # Early binding (or explicit instantiation) gives predictability
    

Comparison with Other Approaches

Approach

Startup Speed

Memory Usage

Error Detection

Early Binding

Slow (all services)

High (all services)

All errors upfront

Late Binding (hdmi)

Fast (validation)

Low (on-demand)

Config errors early, instance errors late

Factory Pattern

Fast

Low

Deferred

Service Locator

Fast

Low

Runtime only

hdmi’s approach gives you the best of both worlds:

  • Configuration errors are caught early (like early binding)

  • Instantiation is deferred (like factory pattern)

  • Services are typed and validated (unlike service locator)

Real-World Example

Consider a web application with these services:

from hdmi import ContainerBuilder

# Configure all services
builder = ContainerBuilder()
builder.register(DatabaseConnection)
builder.register(CacheService)
builder.register(EmailService)
builder.register(PaymentService)
builder.register(AnalyticsService)
builder.register(LoggingService)
builder.register(MonitoringService)

# Build container (validates, but doesn't instantiate)
container = builder.build()  # < 1ms, all dependencies validated

# Handle a simple GET request
@app.get("/health")
async def health():
    # Only LoggingService is instantiated
    logger = await container.get(LoggingService)
    logger.info("Health check")
    return {"status": "ok"}

# Handle a payment request
@app.post("/payment")
async def payment():
    # Now PaymentService, DatabaseConnection are instantiated
    payment_service = await container.get(PaymentService)
    return payment_service.process()

Benefits in this scenario:

  • Health check endpoint is very fast (doesn’t initialize payment services)

  • Each endpoint only pays for the services it uses

  • All endpoints benefit from early validation of the dependency graph

Conclusion

Late binding provides:

✅ Faster startup ✅ Lower memory usage ✅ Better error isolation ✅ More flexibility ✅ Still catches configuration errors early

The only tradeoff is that instantiation errors happen at first access, not at startup. For most applications, this is an excellent tradeoff.