A professional-grade dependency injection framework designed for modern Python applications, with first-class support for Azure Functions and extensible middleware capabilities.
Dependency injection transforms tightly-coupled code into maintainable, testable architectures. While Python's dynamic nature makes DI less critical than in statically-typed languages, complex applications—especially those integrating multiple services, configurations, and external APIs—benefit significantly from structured dependency management.
servicegraph provides this structure without the overhead. Designed to excel in both stateless serverless environments and traditional long-running applications, it offers the power of enterprise DI patterns with Python's characteristic simplicity. Its robust resource management and thread safety make it suitable for any Python application architecture.
pip install servicegraph- Python 3.9+ (Python 3.8 is not supported due to syntax compatibility issues)
- No external runtime dependencies (except
typing-extensionsfor Python < 3.10)
⚠️ IMPORTANT: Version 0.1.0 is deprecated and will not work correctly. Please use version 0.1.1 or later.
from servicegraph import ApplicationBuilder, ServiceLifetime
from abc import ABC, abstractmethod
# Define your service interfaces using ABC
class INotificationService(ABC):
@abstractmethod
def send(self, message: str) -> None:
"""Send a notification message."""
pass
# Implement the interface
class NotificationService(INotificationService):
def send(self, message: str) -> None:
print(f"Sending: {message}")
# Configure and build your application
builder = ApplicationBuilder()
builder.services.add_singleton(INotificationService, NotificationService)
# Get your configured service provider
provider = builder.build()
# Resolve and use services
notification_service = provider.get_service(INotificationService)
notification_service.send("Hello, World!")| Feature | servicegraph | dependency-injector | injector | FastAPI Depends |
|---|---|---|---|---|
| Learning Curve | Low (if from .NET) | High | Medium | Low (if using FastAPI) |
| External Dependencies | 0 | 6+ | 0 | Many (FastAPI ecosystem) |
| Async Support | ✅ Full | ✅ Full | ❌ Limited | ✅ Full |
| Type Hints Required | ✅ Yes | ✅ Yes | ✅ Yes | |
| Scope Management | ✅ Automatic | ✅ Per-request | ||
| Lifecycle Hooks | ✅ Yes | ✅ Yes | ❌ No | |
| Thread Safety | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
| Configuration Files | ❌ Code-only | ✅ YAML/JSON | ❌ Code-only | ❌ Code-only |
| Serverless Ready | ✅ Optimized | |||
| Cold Start Impact | Minimal | Moderate | Minimal | High |
| .NET-like API | ✅ Yes | ❌ No | ❌ No | ❌ No |
- Migrating from .NET to Python - familiar patterns reduce friction
- Serverless/stateless platforms (Azure Functions, AWS Lambda, etc.) - built-in scope management per invocation
- Microservices - zero dependencies means smaller containers
- Teams with .NET background - onboarding is instant
- Projects valuing explicitness - no magic, clear registration
- Type-safe codebases - leverages Python's type system
- Need YAML/JSON configuration → use
dependency-injector - Already using FastAPI → stick with
Depends() - Want implicit autowiring → consider
pinject(if risk of abandonment is acceptable) - Complex multi-tenant scenarios →
dependency-injectorhas more enterprise features
- Python 2.7 or <3.9 - requires modern type hints
- Projects avoiding type hints - core to servicegraph's design
- Need for decorators on every class - servicegraph is registration-based, not decorator-based
servicegraph uses a recursive resolution mechanism that automatically discovers and instantiates dependencies. When you request a service, the container:
- Locates the registration - Finds the concrete type mapped to your interface
- Analyzes dependencies - Inspects the constructor for required parameters
- Recursively resolves - Automatically instantiates any dependencies of dependencies
- Manages lifecycle - Applies the appropriate lifetime scope (singleton, transient, scoped)
- Returns the instance - Provides a fully-configured object ready for use
This eliminates the manual wiring typically required in Python applications while maintaining full control over object creation.
Note on Constructor Analysis: servicegraph gracefully handles classes without explicit constructors (those that inherit Python's default
object.__init__). The container automatically detects when a class has no custom constructor and creates simple factory functions that instantiate the class directly, without attempting dependency injection. This means you can register and resolve both complex services with dependencies and simple data classes or utility classes seamlessly.
servicegraph enforces proper interface design using Python's Abstract Base Classes (ABC):
from abc import ABC, abstractmethod
# ✅ CORRECT: Use ABC with @abstractmethod decorators
class IPaymentProcessor(ABC):
@abstractmethod
def process_payment(self, amount: float, method: str) -> bool:
"""Process a payment transaction."""
pass
@abstractmethod
def validate_payment_method(self, method: str) -> bool:
"""Validate payment method."""
pass
# ✅ CORRECT: Concrete implementations must inherit the interface
class StripePaymentProcessor(IPaymentProcessor):
def process_payment(self, amount: float, method: str) -> bool:
# Implementation here
return True
def validate_payment_method(self, method: str) -> bool:
# Implementation here
return method in ["card", "bank_transfer"]
# ❌ INCORRECT: Plain classes without ABC inheritance
class IPaymentProcessor: # Missing ABC inheritance
def process_payment(self, amount: float, method: str) -> bool:
pass # Missing @abstractmethod decoratorWhy this matters:
- Type Safety: Ensures interfaces are properly defined contracts
- IDE Support: Excellent IntelliSense and error detection
- Runtime Validation: Prevents instantiation of incomplete implementations
- Documentation: Abstract methods serve as living documentation
servicegraph includes a robust configuration system that seamlessly integrates with dependency injection:
from servicegraph import ApplicationBuilder, IConfiguration
from abc import ABC, abstractmethod
def configure_app():
builder = ApplicationBuilder()
# Basic configuration setup
def setup_config(config):
config.add_json_file("appsettings.json", optional=True)
config.add_environment_variables("")
return config
builder.configure_configuration(setup_config)
# Configuration is automatically available for injection
provider = builder.build()
config = provider.get_service(IConfiguration)
api_url = config.get_value("API_BASE_URL")Key Features:
- Type Preservation: Configuration values retain their original types (int, bool, float, str, dict, list) from JSON files or environment variables
- Case-Insensitive Lookups: Keys are matched case-insensitively (
database:port,DATABASE:PORT, andDatabase:Portall work) - Hierarchical Merging: Multiple configuration sources merge intelligently, with later sources overriding earlier ones regardless of key casing
- Convention Over Configuration: Provides sensible defaults while remaining highly customizable
# Type preservation examples
config.get_value("api:timeout") # Returns: 30 (int from JSON)
config.get_value("feature:enabled") # Returns: True (bool from JSON)
config.get_value("database:port") # Returns: "5432" (str from env var)
config.get_value("cache:ttl") # Returns: 3.5 (float from JSON)
# Case-insensitive lookups - all equivalent
config.get_value("database:connection_string")
config.get_value("DATABASE:CONNECTION_STRING")
config.get_value("Database:ConnectionString")The configuration system provides sensible defaults while remaining highly customizable, following the principle of convention over configuration.
Understanding service lifetimes is crucial for both performance and correctness in dependency injection.
builder.services.add_singleton(INotificationService, NotificationService)What happens: One instance created for the entire application lifetime. Use when: Services are stateless, expensive to create, or need to maintain state across requests. Memory impact: Minimal - single instance regardless of usage frequency.
builder.services.add_transient(INotificationService, NotificationService)
# Basic usage - new instance every time
notification_service = provider.get_service(INotificationService)
# Session-scoped transients - same instance within a session
session_id = "session_abc123"
service1 = provider.get_service(INotificationService, session_id)
service2 = provider.get_service(INotificationService, session_id) # Same instance
service3 = provider.get_service(INotificationService, "different_session") # New instance
# Clean up session when done
provider.dispose_session(session_id)What happens: New instance created every time the service is requested, unless a session_id is provided.
Use when: Services are lightweight, stateful per operation, or you need isolation between usages.
Session management: When you provide a session_id:
- New session: If the session doesn't exist, a new session is created with a fresh service instance
- Existing session: If the session exists, the same service instance is returned
- Per-service tracking: Each service type gets its own instance within a session Client considerations:
- Use
dispose_session(session_id)to clean up session-scoped instances - Sessions automatically expire after 30 minutes of inactivity
- Transient services in long-running operations should be explicitly disposed if they implement
IDisposableor have cleanup requirements.
builder.services.add_scoped(INotificationService, NotificationService)
# Direct resolution - must be used within a 'with' statement
with provider.get_service(INotificationService) as notification_service:
notification_service.send("Scoped message")
# Service is automatically disposed when exiting the 'with' block
# With optional session_id (needed when scoped service depends on transients)
with provider.get_service(IDatabaseConnection, session_id="request_123") as db:
# Transient dependencies of this scoped service will share this session
db.execute_query()
# Service disposed, but session persists for other services to reuse transients
# Dependency injection - graceful behavior without context manager
class RequestHandler:
def __init__(self, db_connection: IDatabaseConnection):
self.db_connection = db_connection # ✅ Works seamlessly
builder.services.add_scoped(RequestHandler)
handler = provider.get_service(RequestHandler) # ✅ No context manager needed for dependencies
# Scoped service depending on a Transient service - session_id required
class DatabaseConnection:
def __init__(self, query_builder: IQueryBuilder): # Transient dependency
self.query_builder = query_builder
builder.services.add_transient(IQueryBuilder, QueryBuilder)
builder.services.add_scoped(IDatabaseConnection, DatabaseConnection)
# ✅ CORRECT: Provide session_id when scoped service depends on transient
with provider.get_service(IDatabaseConnection, session_id="request_123") as db:
db.execute_query() # QueryBuilder instance is consistent within this session
# ⚠️ WITHOUT session_id: Transient dependencies may vary on each scoped resolution
with provider.get_service(IDatabaseConnection) as db:
db.execute_query() # New QueryBuilder instance created (no session tracking)
# Scoped service depending on a Singleton service - no session_id needed
class RequestProcessor:
def __init__(self, logger: ILogger): # Singleton dependency
self.logger = logger
builder.services.add_singleton(ILogger, Logger)
builder.services.add_scoped(IRequestProcessor, RequestProcessor)
# ✅ No session_id required - singleton is shared across all scopes
with provider.get_service(IRequestProcessor) as processor:
processor.process() # Logger instance is the same singleton for all requestsWhat happens: One instance per scope, automatically disposed when the scope ends. Use when: You need per-request state with guaranteed cleanup (database connections, file handles, etc.). Session ID: Optional parameter that creates a session for consistent transient resolution within the scoped service's dependencies.
Graceful Dependency Injection: servicegraph intelligently handles scoped services differently based on context:
- Direct resolution (
provider.get_service(IScopedService)): Returns a context manager wrapper that enforces thewithpattern - Dependency injection (constructor parameter): Automatically unwraps to the raw instance—no context manager required
- Automatic session management: When used as dependencies, scoped services automatically inherit the session context from their parent scope
- Cascading cleanup: All scoped dependencies are tracked and disposed together when the parent scope ends
Why this matters: When a scoped service depends on another scoped service, you don't need to worry about context manager syntax for the dependency. The framework automatically unwraps scoped dependencies and ensures proper lifecycle management and cleanup. Note that lifecycle dependency rules still apply—scoped services cannot be injected into singleton or transient services.
Session persistence with transients (when using session_id): When a scoped service depends on a transient service and you provide a session_id, the transient instance persists in the session beyond the scoped service's lifecycle:
builder.services.add_scoped(IDatabaseConnection, DatabaseConnection)
builder.services.add_transient(IQueryBuilder, QueryBuilder) # Used by DatabaseConnection
# WITH session_id: Transients are reused within the session
with provider.get_service(IDatabaseConnection, session_id="request_123") as db1:
# QueryBuilder instance #1 created and cached in session
db1.execute_query() # Uses QueryBuilder #1
# First scoped service disposed, but QueryBuilder #1 still in session
with provider.get_service(IDatabaseConnection, session_id="request_123") as db2:
# QueryBuilder instance #1 reused from session
db2.execute_query() # Uses same QueryBuilder #1
# Session cleanup - explicitly dispose when request is complete
provider.dispose_session("request_123") # Now QueryBuilder #1 is cleaned up
# WITHOUT session_id: Each scoped service gets fresh transient instances
with provider.get_service(IDatabaseConnection) as db:
# QueryBuilder created, used, and may be garbage collected after disposal
db.execute_query()Key Point: The session_id parameter is optional. Use it only when you need consistent transient instances across multiple scoped service resolutions within the same logical operation (like a web request or function invocation).
Behind the scenes: servicegraph wraps scoped services in a ScopedServiceContextManager that enforces proper usage for direct resolution while gracefully unwrapping for dependency injection.
Disposal behavior: The context manager automatically calls dispose() or close() methods if your service implements them, but scoped services don't need to implement these methods—the with pattern works with any service. All scoped dependencies created during a scope are tracked and disposed together.
servicegraph enforces strict lifetime compatibility rules to prevent subtle bugs where longer-lived services depend on shorter-lived ones. These validations occur at registration time, catching configuration errors early.
The Dependency Safety Rule:
Services with longer instance lifetimes cannot depend on services with shorter instance lifetimes, as this would cause the shorter-lived service to be incorrectly captured for too long.
Instance Lifetime Hierarchy (from longest to shortest):
- Singleton - lives for entire application lifetime (longest)
- Transient - not bound to any scope, can be created anywhere (medium)
- Scoped - restricted to a specific scope context (shortest/narrowest)
Allowed Dependencies (what each can depend on):
- Singleton → can depend on: Singleton only
- Transient → can depend on: Singleton, Transient only
- Scoped → can depend on: Singleton, Transient, Scoped (anything)
Validation Rules:
-
Singleton services CANNOT depend on Transient or Scoped services
# ❌ INVALID - Singleton depending on Transient class SingletonService: def __init__(self, transient: TransientService): # ERROR at registration! self.transient = transient builder.services.add_transient(TransientService) builder.services.add_singleton(SingletonService) # ValueError: Singleton services cannot depend on transient services
Why prohibited: Singletons are created once and reused. If a singleton depends on a transient, it would capture a single transient instance, violating the transient's "new instance every time" contract.
-
Transient services CANNOT depend on Scoped services
# ❌ INVALID - Transient depending on Scoped class TransientService: def __init__(self, scoped: ScopedService): # ERROR at registration! self.scoped = scoped builder.services.add_scoped(ScopedService) builder.services.add_transient(TransientService) # ValueError: Transient services cannot depend on scoped services
Why prohibited: Transient services can be created outside of a scope context, but scoped services are restricted to a specific scope. Scoped services have a narrower lifetime than transient services, so allowing this dependency would create orphaned scoped instances that outlive their intended scope.
-
Scoped services CAN depend on anything ✅
# ✅ VALID - Scoped can depend on Transient class ScopedService: def __init__(self, transient: TransientService): self.transient = transient # ✅ VALID - Scoped can depend on Singleton class ScopedService: def __init__(self, singleton: SingletonService): self.singleton = singleton # ✅ VALID - Transient can depend on Singleton class TransientService: def __init__(self, singleton: SingletonService): self.singleton = singleton # ✅ VALID - Scoped can depend on Scoped class ScopedService: def __init__(self, other_scoped: OtherScopedService): self.other_scoped = other_scoped
Error Messages: When you violate these rules, servicegraph provides clear, actionable error messages:
ValueError: Invalid dependency in SingletonService: Singleton services cannot depend on
transient services.
Parameter 'transient' of type 'TransientService' is registered as transient, but
SingletonService is a singleton.
This would break singleton semantics as the dependency could have different instances.
Solution: Register 'TransientService' as a singleton, or change SingletonService to
transient/scoped.
Design Philosophy: These restrictions enforce best practices and prevent memory leaks, dangling references, and unpredictable behavior. While they may seem restrictive, they guide you toward more maintainable architectures. If you find yourself needing to violate these rules, consider whether your service lifetimes are correctly chosen for their responsibilities.
Understanding the Limitation
Dependency injection systems are designed to inject complex types (classes, interfaces) but cannot automatically inject primitive types (str, int, float, bool, list, dict, set, tuple, bytes). This is a fundamental limitation across all DI frameworks because there's no way for the container to know what value a primitive parameter should have without explicit configuration.
What Happens:
class EmailService:
def __init__(self, smtp_host: str, smtp_port: int, use_ssl: bool = True):
self.smtp_host = smtp_host
self.smtp_port = smtp_port
self.use_ssl = use_ssl
# ⚠️ IMPROPER: Direct registration without factory
builder.services.add_singleton(IEmailService, EmailService)
# What happens:
# 1. Warning is logged: "Parameter 'smtp_host' of type 'str' in EmailService
# constructor is a primitive type with no default value..."
# 2. Service is created successfully with None for smtp_host and smtp_port
# 3. Service with None values is returned - no crash during creation
# 4. Error occurs LATER when you try to use the None values:
service = provider.get_service(IEmailService)
print(service.smtp_host) # None - service exists but has None
service.smtp_host.lower() # ❌ AttributeError: 'NoneType' object has no attribute 'lower'The Design Decision:
servicegraph follows the principle that the DI container should not crash your application. Instead:
- The service is created successfully (with
Nonefor primitive parameters without defaults) - Warnings are logged to alert you of the configuration issue
- The error occurs when you try to use the improperly configured service
- This makes the problem obvious during development while keeping the framework resilient
This is developer responsibility, not a framework bug. If your service has primitives without defaults, you must use one of the proper registration patterns below.
✅ Proper Solutions:
1. Factory Registration (Recommended for Configuration-Based Values)
Use a factory function to provide primitive values explicitly:
def email_service_factory(provider: ServiceProvider) -> EmailService:
# Option A: Hardcoded values
return EmailService(
smtp_host="smtp.example.com",
smtp_port=587,
use_ssl=True
)
# Option B: From configuration
config = provider.get_service(IConfiguration)
return EmailService(
smtp_host=config.get_value("email:smtp_host"),
smtp_port=int(config.get_value("email:smtp_port")),
use_ssl=config.get_value("email:use_ssl", True)
)
builder.services.add_factory(IEmailService, email_service_factory)2. Default Values in Constructor (Simplest for Constants)
Provide sensible defaults in your service constructor:
class EmailService:
def __init__(
self,
smtp_host: str = "smtp.example.com", # ✅ Has default
smtp_port: int = 587, # ✅ Has default
use_ssl: bool = True # ✅ Has default
):
self.smtp_host = smtp_host
self.smtp_port = smtp_port
self.use_ssl = use_ssl
# Now direct registration works fine
builder.services.add_singleton(IEmailService, EmailService)3. Instance Registration (For Pre-Configured Objects)
Create and configure the instance before registration:
email_service = EmailService(
smtp_host="smtp.example.com",
smtp_port=587,
use_ssl=True
)
builder.services.add_instance(IEmailService, email_service)4. Mixed Approach (Primitives with Defaults + Complex Type Injection)
This is the recommended pattern for most services:
class NotificationService:
def __init__(
self,
email_service: IEmailService, # ✅ Complex type - will be injected
app_name: str = "MyApp", # ✅ Primitive with default
max_retries: int = 3 # ✅ Primitive with default
):
self.email_service = email_service # Automatically injected
self.app_name = app_name # Uses default unless overridden by factory
self.max_retries = max_retries
# Direct registration works - complex type injected, primitives use defaults
builder.services.add_singleton(NotificationService)
# Or use factory to override defaults
builder.services.add_factory(
NotificationService,
lambda p: NotificationService(
email_service=p.get_service(IEmailService), # Injected
app_name="CustomApp", # Override default
max_retries=5 # Override default
)
)Key Takeaways:
- Primitive types cannot be auto-injected - this is by design in all DI systems
- The application won't crash during service creation - you'll get warnings and
Nonevalues - Errors occur when you try to use
None- making the issue obvious during development - Use factories, defaults, or instances - these are the proper patterns for primitives
- Complex types are still auto-injected - only primitives require special handling
This behavior encourages proper service design where configuration values (primitives) are separated from dependencies (complex types), leading to more maintainable and testable code.
For complex object creation scenarios, factories provide ultimate flexibility:
def create_api_client_factory(provider: ServiceProvider) -> Callable[[], ApiClient]:
config = provider.get_service(IConfiguration)
endpoint = config.get_value("API_ENDPOINT")
def create_client() -> ApiClient:
return ApiClient(
endpoint=endpoint,
api_key=config.get_value("API_KEY")
)
return create_client
builder.services.add_factory(
Callable[[], ApiClient],
create_api_client_factory
)Class composition benefit: Factory registration excels when object creation involves multiple steps, conditional logic, or external resource initialization. The factory pattern also enables lazy loading—expensive resources are only created when actually needed.
When you need multiple implementations of the same interface:
# Register multiple notification providers
builder.services.add_named_singleton("smtp", INotificationService, SmtpNotificationService)
builder.services.add_named_singleton("push", INotificationService, PushNotificationService)
# Resolve specific implementations
smtp_service = provider.get_named_service("smtp", INotificationService)
push_service = provider.get_named_service("push", INotificationService)Why it matters: Named registration is essential for scenarios like multi-tenant applications, A/B testing implementations, or fallback service patterns. Rather than creating separate interfaces for functionally identical services, named registration maintains clean abstractions while providing implementation flexibility.
servicegraph was born from a specific need: bringing .NET's familiar DI patterns to Python with first-class support for modern deployment architectures.
-
Zero Surprises for .NET Developers
// C# ASP.NET Core services.AddTransient<IMyService, MyService>();
# Python servicegraph services.add(MyService, IMyService, lifetime=ServiceLifetime.TRANSIENT)
-
No Hidden Dependencies
- Every import is from
servicegraphor Python stdlib - No surprise package installations
- Predictable deployment sizes
- Every import is from
-
Explicit Resource Management
- Scopes are visible:
with provider.get_service(IScopedService, session_id) as service: - Lifecycle hooks are obvious:
Disposableprotocol - No magic cleanup - you control when
- Scopes are visible:
-
Type Safety First
- Type hints aren't optional - they're the API
- Runtime type checking catches errors early
- IDE autocomplete works perfectly
-
Stateless Architecture Optimized
- Scope-per-invocation pattern built-in
- Thread-safe for concurrent executions
- Minimal cold start overhead for serverless platforms
Problem: Serverless platforms (Azure Functions, AWS Lambda, etc.) can have concurrent executions in the same process. Traditional singleton patterns can cause data bleed between invocations.
servicegraph Solution:
from servicegraph import ApplicationBuilder, ServiceLifetime
import azure.functions as func # or AWS Lambda handler, etc.
# Create application once at startup
builder = ApplicationBuilder()
builder.services.add_scoped(IRequestContext, RequestContext) # Per-invocation
builder.services.add_singleton(IDatabaseClient, DatabaseClient) # Shared connection
builder.services.add_transient(IMyService, MyService)
provider = builder.build()
def main(req: func.HttpRequest) -> func.HttpResponse:
# Use unique session per invocation - no data bleed!
session_id = req.invocation_id # or generate unique ID
# Scoped service automatically manages lifecycle
with provider.get_service(IRequestContext, session_id=session_id) as context:
context.user_id = req.params.get('user_id')
# Transient services resolved with same session_id are reused within the session
service = provider.get_service(IMyService, session_id=session_id)
result = service.process()
# Scoped services auto-disposed, session cleaned up
provider.dispose_session(session_id)
return func.HttpResponse(result)Why not dependency-injector?
- Requires manual scope creation boilerplate
- External dependencies increase cold start time
- Configuration overhead for simple scenarios
Why not injector?
- No built-in scope management
- Manual lifetime tracking required
- Less intuitive for .NET developers
Why not FastAPI Depends?
- Not designed for serverless platforms
- Requires FastAPI framework overhead
The recommended pattern follows .NET's familiar startup conventions: centralize configuration in a setup module, then initialize once at application startup.
1. Create a Setup Module (program.py or app_setup.py):
from typing import Callable
from servicegraph import ApplicationBuilder, ServiceProvider, IConfiguration
from interfaces.i_email_service import IEmailService
from interfaces.i_database import IDatabaseConnection
from interfaces.i_api_client import IApiClient
from services.email_service import EmailService
from services.database import DatabaseConnection
from services.api_client import ApiClient
def create_api_client_factory(provider: ServiceProvider) -> Callable[[], IApiClient]:
"""Factory function that captures config and returns a client creator."""
config = provider.get_service(IConfiguration)
base_url = config.get_value("API_BASE_URL")
api_key = config.get_value("API_KEY")
timeout = config.get_value("API_TIMEOUT", 30)
def create_client() -> IApiClient:
return ApiClient(
base_url=base_url,
api_key=api_key,
timeout=timeout
)
return create_client
def configure_services(builder: ApplicationBuilder):
"""Register all application services."""
# Register complex service factories
builder.services.add_factory(
Callable[[], IApiClient],
create_api_client_factory
)
# Register business logic services
builder.services.add_singleton(IEmailService, EmailService)
builder.services.add_transient(IDataProcessor, DataProcessor)
builder.services.add_scoped(IDatabaseConnection, DatabaseConnection)
def configure_middleware(builder: ApplicationBuilder, environment: str = "Development"):
"""Configure middleware pipeline (optional - framework-specific)."""
import os
log_level = os.getenv("LOG_LEVEL", "INFO")
# Add middleware components (if applicable to your platform)
add_logging_middleware(builder, log_level)
add_authentication_middleware(builder)
def create_app(environment: str = "Development") -> ServiceProvider:
"""
Create the application with fully configured services.
Call this once at application startup.
Returns:
Configured ServiceProvider
"""
builder = ApplicationBuilder()
# Configure configuration sources (hierarchical override pattern)
def configure_config(config):
return (config
.add_json_file("config/appsettings.json", optional=True)
.add_json_file(f"config/appsettings.{environment.lower()}.json", optional=True)
.add_environment_variables("")) # Load all environment variables
builder.configure_configuration(configure_config)
# Register services
configure_services(builder)
# Configure middleware (optional)
if supports_middleware():
configure_middleware(builder, environment)
# Build and return
return builder.build()2. Initialize Once at Application Startup:
# Serverless function example (Azure Functions, AWS Lambda, etc.)
from program import create_app
# Create once - reused across all function invocations
service_provider = create_app("Production")
def handler(event, context):
# Resolve services as needed
processor = service_provider.get_service(IDataProcessor)
result = processor.process(event.get('data'))
return {"statusCode": 200, "body": result}# Web application example (Flask/FastAPI)
from program import create_app
# Initialize at module level
app_provider = create_app()
@app.route('/api/process')
def process_data():
processor = app_provider.get_service(IDataProcessor)
result = processor.process(request.json)
return jsonify(result)# Background worker/script example
from program import create_app
def main():
provider = create_app()
# Resolve top-level service
worker = provider.get_service(IBackgroundWorker)
worker.run()
if __name__ == "__main__":
main()Direct Resolution (Top-level services):
# Resolve when you need it
service = provider.get_service(IMyService)
result = service.do_work()Constructor Injection (Preferred - automatic dependency resolution):
class DocumentProcessor:
def __init__(
self,
parser: IDocumentParser, # Automatically injected
email: IEmailService, # Automatically injected
config: IConfiguration # Automatically injected
):
self.parser = parser
self.email = email
self.config = config
def process(self, document: bytes):
parsed = self.parser.parse(document)
self.email.send(f"Processed: {parsed}")
# Register and resolve - dependencies are automatically injected
builder.services.add_transient(DocumentProcessor)
processor = provider.get_service(DocumentProcessor) # All dependencies resolved!Scoped Services (Request/operation lifetime):
def handle_request(request_id: str):
# Simple usage - no session_id needed if scoped service has no transient dependencies
with provider.get_service(IDatabaseConnection) as db:
db.execute_query()
# With session_id - use when scoped service depends on transients
# This ensures transient dependencies are reused within the same request
with provider.get_service(IDatabaseConnection, session_id=request_id) as db:
# Resolve other services within this scope
processor = provider.get_service(IRequestProcessor, session_id=request_id)
# Use services - all scoped dependencies share this session
processor.process(db)
# Scoped service and its dependencies automatically disposed here
# Clean up session when request is complete
provider.dispose_session(request_id)When to use session_id with scoped services:
- ✅ Use
session_idwhen your scoped service depends on transient services that should be reused within the same request/operation - ✅ Use
session_idwhen you have multiple scoped services in the same request that should share transient dependencies - ❌ Skip
session_idfor simple scoped services with no transient dependencies (database connections, file handles, etc.)
When to use: Services that need runtime configuration values or complex initialization.
def create_api_client_factory(provider: ServiceProvider) -> IApiClient:
"""Factory resolves dependencies and applies configuration."""
config = provider.get_service(IConfiguration)
logger = provider.get_service(ILogger)
# Complex initialization logic
base_url = config.get_value("api:base_url")
timeout = config.get_value("api:timeout", 30)
client = ApiClient(
base_url=base_url,
timeout=timeout,
retry_policy=ExponentialBackoff(max_attempts=3)
)
logger.info(f"Created API client for {base_url}")
return client
builder.services.add_factory(IApiClient, create_api_client_factory)When to use: Multiple implementations of the same interface for different contexts.
# Register multiple storage providers
builder.services.add_named_singleton("blob", IStorageProvider, BlobStorageProvider)
builder.services.add_named_singleton("file", IStorageProvider, FileStorageProvider)
builder.services.add_named_singleton("sql", IStorageProvider, SqlStorageProvider)
# Resolve based on runtime context
def store_document(tenant_config: dict, document: bytes):
storage_type = tenant_config["storage_type"] # "blob", "file", or "sql"
storage = provider.get_named_service(storage_type, IStorageProvider)
storage.save(document)Override services for testing:
def create_test_app() -> ServiceProvider:
"""Create app with test doubles."""
builder = ApplicationBuilder()
# Use mocks/fakes for external dependencies
builder.services.add_singleton(IEmailService, FakeEmailService)
builder.services.add_singleton(IStorageProvider, InMemoryStorage)
# Use real implementations for business logic
builder.services.add_transient(DocumentProcessor)
return builder.build()
def test_document_processing():
provider = create_test_app()
processor = provider.get_service(DocumentProcessor)
result = processor.process(test_document)
assert result.status == "success"servicegraph provides first-class support for Azure Functions v2 (isolated worker model):
import azure.functions as func
from servicegraph import ApplicationBuilder
def create_app():
builder = ApplicationBuilder()
# Configure services
builder.services.add_singleton(INotificationService, NotificationService)
return builder.build()
# Initialize once, use across function invocations
app_provider = create_app()
def main(req: func.HttpRequest) -> func.HttpResponse:
# Services are available immediately
notification_service = provider.get_service(INotificationService)
# Your function logic here
return func.HttpResponse("Success")servicegraph includes an extensible middleware pipeline for cross-cutting concerns like logging, authentication, and validation.
Current Implementation: Azure Functions
from servicegraph.middleware import MiddlewarePipeline
import azure.functions as func
def configure_middleware(builder: ApplicationBuilder):
# Add logging, authentication, validation, etc.
add_logging_middleware(builder, connection_string, environment)
add_authentication_middleware(builder)
def main(req: func.HttpRequest) -> func.HttpResponse:
# Middleware automatically processes request/response
return middleware_pipeline.execute(req, your_handler)Framework Support Status:
- ✅ Azure Functions v2 - Full middleware support for HTTP triggers
- 🔄 FastAPI - Planned (contributions welcome)
- 🔄 Flask/Django - Planned (contributions welcome)
- 🔄 AWS Lambda - Planned (contributions welcome)
The middleware pattern is designed to be framework-agnostic. Current implementation focuses on Azure Functions HTTP triggers, but the architecture supports extension to other frameworks. Contributions for additional framework support are encouraged.
Basic Setup (Single Source):
def setup_config(config):
config.add_json_file("appsettings.json", optional=True)
return configChained Setup (Multiple Sources):
def setup_config(config):
return (config
.add_json_file("appsettings.json", optional=True)
.add_json_file(f"appsettings.{environment}.json", optional=True)
.add_environment_variables("APP_")) # Prefix for environment variablesEnvironment Variable Naming Convention:
When using environment variables for nested configuration, use double underscores (__) to represent hierarchy:
# Environment variables for nested configuration
os.environ["APP_DATABASE__HOST"] = "localhost"
os.environ["APP_DATABASE__PORT"] = "5432"
os.environ["APP_DATABASE__NAME"] = "mydb"
# After removing prefix "APP_", becomes:
# {"DATABASE": {"HOST": "localhost", "PORT": "5432", "NAME": "mydb"}}
# Access using colon notation
config.get_value("database:host") # Returns "localhost"
config.get_value("database:port") # Returns "5432"Key points:
- Single underscore (
_) separates words within the same key - Double underscore (
__) creates nested configuration levels - After prefix removal,
__is converted to:for hierarchical access
When to use each:
- Basic setup: Simple applications with one configuration source, or when you prefer explicit calls
- Chained setup: Applications with multiple configuration layers (base + environment + runtime overrides)
Configuration sources are processed in order, with later sources overriding earlier ones. This enables the standard pattern:
- Base configuration (
appsettings.json) - Environment-specific overrides (
appsettings.production.json) - Runtime overrides (environment variables)
Case-Insensitive Merging: The configuration system merges sources intelligently regardless of key casing. For example:
# appsettings.json (lowercase keys)
{
"database": {
"host": "localhost",
"port": 5432
}
}
# Environment variable (uppercase keys)
os.environ["APP_DATABASE__PORT"] = "3306"
# Result after merging - the PORT override matches "port" case-insensitively
config.get_value("database:port") # Returns: "3306"
config.get_value("DATABASE:PORT") # Returns: "3306" (same value, case-insensitive)This ensures environment variables and configuration files work together seamlessly, regardless of naming conventions used in different sources.
@dataclass
class ApiConfig:
base_url: str
timeout: int
retry_count: int
# Register configuration objects
builder.services.configure(ApiConfig, "Api")
# Inject typed configuration
def __init__(self, api_config: ApiConfig):
self.config = api_configservicegraph provides intelligent memory management designed for both stateless and long-running environments. This design reflects its versatility across different runtime platforms.
What this means for you:
- Singleton services: Persist for the application lifetime with automatic cleanup on shutdown—suitable for both stateless environments and long-running applications
- Transient services: Created and released per request with session-based cleanup to prevent memory leaks
- Scoped services: Guaranteed cleanup through context manager pattern
- Session management: Automatic expiration (30 minutes) prevents memory accumulation in long-running processes
servicegraph was architected for versatile deployment across different platforms:
Stateless platforms (optimized):
- Azure Functions
- AWS Lambda
- Container-based microservices
- Serverless environments
Long-running applications (fully supported):
- Web applications and APIs
- Background services and workers
- Desktop applications
- Multi-threaded server applications
Design benefits:
- Fast startup: Minimal overhead during container initialization
- Predictable lifecycle: Service lifetimes align with request/response cycles or application lifetime
- Resource efficiency: Automatic cleanup prevents memory leaks in both short and long-lived processes
- Thread safety: Concurrent access support for multi-threaded applications
Consider alternatives if you're building:
- Applications requiring complex object lifecycle management beyond the three standard lifetimes (singleton, transient, scoped)
- Systems with sophisticated disposal patterns that need more than basic
dispose()/close()method calling - Applications with complex dependency graphs that require advanced features like conditional registration, decorators, or aspect-oriented programming
- Service resolution: O(1) for singletons after first resolution, O(n) for transients where n is dependency depth
- Memory footprint: Minimal metadata overhead with automatic session cleanup; actual memory usage depends on your registered services
- Startup cost: Linear with number of singleton services requiring immediate initialization
- Long-running stability: Session expiration and automatic cleanup prevent memory accumulation over time
- No configuration files - Registration is code-only. If you need YAML/JSON config, use
dependency-injector. - No automatic discovery - You must explicitly register services. This is intentional for clarity.
- No decorators-everywhere - Unlike
injector, you don't decorate every class. Registration is centralized. - No web framework integration - servicegraph is framework-agnostic. FastAPI's
Depends()is better if you're all-in on FastAPI. - No validation framework - servicegraph injects dependencies; it doesn't validate them. Use Pydantic/etc. separately.
If you want a batteries-included, enterprise-grade DI container with every feature imaginable, dependency-injector is more mature.
If you want the simplest possible DI system that feels like .NET, especially for serverless and stateless architectures, servicegraph is your tool.
"Dependency Injection for Python developers who miss .NET, or serverless developers who want sanity."
We welcome contributions! Whether it's bug reports, feature requests, or code contributions, please feel free to engage with the project.
This project is licensed under the Apache License 2.0 with Commons Clause - see the LICENSE file for complete details.
- Open Source: Full access to source code, modification, and distribution rights
- Commercial Use Restrictions: The software may not be sold or offered as a paid service
- AI Protection: Special provisions prevent unauthorized AI-based code generation or redistribution of substantially similar systems
- Internal Use: Unrestricted use within your organization and integration into broader applications
Important: While the source code is freely available, commercial resale and AI-based reproduction are specifically restricted. This ensures the framework remains open for legitimate use while protecting against unauthorized commercialization.
servicegraph is committed to minimizing external dependencies for core functionality. This design principle ensures:
- Zero dependency conflicts with your existing projects
- Lightweight installation that doesn't bloat your environment
- Maximum compatibility across different Python versions and platforms
- Long-term stability since standard library APIs rarely change
What this means for contributors:
- Core dependency injection functionality must use only Python's standard library
- External dependencies are acceptable only for:
- Testing frameworks (pytest, etc.)
- Platform-specific integrations (Azure Functions SDK, etc.)
- Optional features clearly documented as requiring additional packages
Code review criteria: All contributions will be evaluated against this standard library requirement. Pull requests introducing unnecessary external dependencies for core functionality will be respectfully declined with suggestions for standard library alternatives.
Current external dependencies:
- None for core DI functionality
- Testing and Azure Functions integration may use appropriate external libraries
This philosophy keeps servicegraph lean, predictable, and suitable for any Python environment without imposing additional complexity on your projects.
Built for the modern Python developer who values clean architecture without sacrificing simplicity.