Service Lifetimes¶
Service lifetimes (also called scopes) control how long service instances live and when they are created. This guide explains the different lifetime options in InjectQ and when to use each one.
๐ฏ What are Service Lifetimes?¶
Service lifetimes determine:
- When a service instance is created
- How long it lives
- When it gets cleaned up
- Whether instances are shared or unique
๐ Available Lifetimes¶
InjectQ provides several built-in lifetimes:
1. Singleton (Default)¶
One instance for the entire application lifetime.
from injectq import InjectQ, singleton
# Explicit singleton
@singleton
class Database:
def __init__(self):
self.connection_id = id(self)
print(f"Database created: {self.connection_id}")
# Or bind with scope
container = InjectQ()
container.bind(Database, Database, scope="singleton")
# Usage
db1 = container.get(Database)
db2 = container.get(Database)
print(f"Same instance? {db1 is db2}") # True
When to use: - Database connections - Configuration objects - Caching services - Logging services - Any expensive resource that can be shared
Pros: - Memory efficient (one instance) - Fast (no creation overhead) - Thread-safe sharing
Cons: - Cannot store request-specific data - Harder to test in isolation
2. Transient¶
New instance every time the service is requested.
from injectq import transient
@transient
class RequestProcessor:
def __init__(self):
self.instance_id = id(self)
print(f"Processor created: {self.instance_id}")
# Usage
proc1 = container.get(RequestProcessor)
proc2 = container.get(RequestProcessor)
print(f"Different instances? {proc1 is not proc2}") # True
When to use: - Request handlers - Command processors - Validators - Any service that needs to be stateless
Pros: - Clean state for each use - Easy to test - No shared state issues
Cons: - Memory overhead (many instances) - Creation overhead - Cannot cache data between calls
3. Scoped¶
One instance per scope (request, session, etc.).
from injectq import InjectQ, scoped
@scoped("request")
class RequestContext:
def __init__(self):
self.request_id = id(self)
self.user_id = None
self.start_time = time.time()
# Usage
container = InjectQ()
async with container.scope("request"):
ctx1 = container.get(RequestContext)
# Do work...
ctx1.user_id = 123
# Same instance in same scope
ctx2 = container.get(RequestContext)
print(f"Same context? {ctx1 is ctx2}") # True
print(f"User ID: {ctx2.user_id}") # 123
# New scope = new instance
async with container.scope("request"):
ctx3 = container.get(RequestContext)
print(f"New context? {ctx1 is not ctx3}") # True
When to use: - Request context data - User session data - Transaction contexts - Per-operation state
Pros: - Shared within logical unit - Automatic cleanup - Request-scoped caching
Cons: - More complex to manage - Requires scope management
๐๏ธ Built-in Scopes¶
InjectQ provides several built-in scopes:
Application Scope¶
Lives for the entire application lifetime (same as singleton):
from injectq import Scope
container.bind(AppConfig, scope=Scope.APP)
container.bind(Database, scope=Scope.APP)
Request Scope¶
Lives for the duration of a request:
container.bind(RequestContext, scope=Scope.REQUEST)
container.bind(UserSession, scope=Scope.REQUEST)
Action Scope¶
Lives for the duration of an action/method:
container.bind(ActionContext, scope=Scope.ACTION)
container.bind(ValidationContext, scope=Scope.ACTION)
Transient Scope¶
Always creates new instances (same as @transient
):
container.bind(CommandHandler, scope=Scope.TRANSIENT)
container.bind(Validator, scope=Scope.TRANSIENT)
๐จ Custom Scopes¶
You can create custom scopes for specific needs:
from injectq import Scope, ScopeManager
class TaskScope(Scope):
"""Scope that lives for the duration of a background task."""
def __init__(self):
self.task_id = None
self.start_time = None
def enter(self):
"""Called when entering the scope."""
self.task_id = f"task_{uuid.uuid4().hex[:8]}"
self.start_time = time.time()
print(f"Starting task: {self.task_id}")
def exit(self):
"""Called when exiting the scope."""
duration = time.time() - self.start_time
print(f"Task {self.task_id} completed in {duration:.2f}s")
# Register custom scope
scope_manager = container._scope_manager
scope_manager.register_scope("task", TaskScope())
# Use custom scope
@scoped("task")
class TaskProcessor:
def __init__(self):
self.task_id = None
def set_task_id(self, task_id: str):
self.task_id = task_id
# Usage
async with container.scope("task"):
processor = container.get(TaskProcessor)
processor.set_task_id("process_data")
# Do task work...
๐ Lifetime Examples¶
Example 1: Web Application¶
from injectq import InjectQ, singleton, scoped, transient
container = InjectQ()
# Application-wide services
@singleton
class Database:
pass
@singleton
class Cache:
pass
# Request-scoped services
@scoped("request")
class RequestContext:
def __init__(self):
self.user_id = None
self.request_id = str(uuid.uuid4())
@scoped("request")
class UserSession:
def __init__(self, context: RequestContext):
self.context = context
self.user_data = {}
# Transient services
@transient
class EmailSender:
def send(self, to: str, subject: str, body: str):
print(f"Sending email to {to}: {subject}")
# Usage in request handler
@inject
async def handle_request(
db: Database,
cache: Cache,
context: RequestContext,
session: UserSession,
email_sender: EmailSender
):
# db and cache are shared across all requests
# context and session are unique to this request
# email_sender is new for this handler
context.user_id = 123
session.user_data["last_login"] = datetime.now()
# Each call gets a new email_sender
email_sender.send("user@example.com", "Welcome", "Hello!")
Example 2: Background Job Processing¶
@singleton
class JobQueue:
pass
@scoped("job")
class JobContext:
def __init__(self):
self.job_id = None
self.start_time = time.time()
self.progress = 0
@transient
class FileProcessor:
def process(self, file_path: str) -> dict:
# Process file and return results
return {"processed": True, "file": file_path}
@inject
async def process_job(
queue: JobQueue,
context: JobContext,
processor: FileProcessor
):
# queue is shared
# context is per-job
# processor is new each time
files = ["file1.txt", "file2.txt", "file3.txt"]
for file_path in files:
# Each iteration gets a new processor
result = processor.process(file_path)
context.progress += 1
print(f"Processed {file_path}: {result}")
โก Performance Considerations¶
Memory Usage¶
# Singleton - Low memory usage
@singleton
class HeavyService:
def __init__(self):
self.data = {} # Large data structure
# Transient - High memory usage
@transient
class LightService:
def __init__(self):
self.temp_data = [] # Small data structure
Creation Overhead¶
# Singleton - Created once
@singleton
class ExpensiveService:
def __init__(self):
time.sleep(1) # Expensive initialization
# Transient - Created every time
@transient
class CheapService:
def __init__(self):
pass # Cheap initialization
Thread Safety¶
# Singleton - Must be thread-safe
@singleton
class SharedCache:
def __init__(self):
self._data = {}
self._lock = threading.Lock()
def get(self, key):
with self._lock:
return self._data.get(key)
def set(self, key, value):
with self._lock:
self._data[key] = value
# Transient - No thread safety concerns
@transient
class RequestHandler:
def __init__(self):
self.request_data = {}
๐งช Testing Different Lifetimes¶
Testing Singletons¶
def test_singleton_behavior():
with test_container() as container:
container.bind(Database, MockDatabase)
# Should be same instance
db1 = container.get(Database)
db2 = container.get(Database)
assert db1 is db2
# Test the singleton
db1.connect()
assert db2.is_connected()
Testing Scoped Services¶
def test_scoped_behavior():
with test_container() as container:
container.bind(RequestContext, RequestContext, scope="request")
# Outside scope - should fail
with pytest.raises(DependencyNotFoundError):
container.get(RequestContext)
# Inside scope
with container.scope("request"):
ctx1 = container.get(RequestContext)
ctx2 = container.get(RequestContext)
assert ctx1 is ctx2
# New scope - new instance
with container.scope("request"):
ctx3 = container.get(RequestContext)
assert ctx1 is not ctx3
Testing Transients¶
def test_transient_behavior():
with test_container() as container:
container.bind(Processor, Processor, scope="transient")
proc1 = container.get(Processor)
proc2 = container.get(Processor)
# Should be different instances
assert proc1 is not proc2
assert proc1.instance_id != proc2.instance_id
๐จ Common Lifetime Mistakes¶
1. Wrong Scope for Shared Data¶
# โ Wrong - request data in singleton
@singleton
class UserContext:
def __init__(self):
self.user_id = None # Will be shared across requests!
# โ
Correct - request-scoped
@scoped("request")
class UserContext:
def __init__(self):
self.user_id = None # Unique per request
2. Expensive Operations in Transient¶
# โ Wrong - expensive operation in transient
@transient
class DatabaseConnection:
def __init__(self):
self.connection = create_expensive_connection() # Called every time!
# โ
Correct - singleton for expensive resources
@singleton
class DatabaseConnection:
def __init__(self):
self.connection = create_expensive_connection() # Called once
3. State in Singletons¶
# โ Wrong - mutable state in singleton
@singleton
class Cache:
def __init__(self):
self._data = {} # Shared mutable state
def set_user_data(self, user_id, data):
self._data[user_id] = data # Race conditions!
# โ
Correct - thread-safe or scoped
@singleton
class Cache:
def __init__(self):
self._data = {}
self._lock = threading.Lock()
def set_user_data(self, user_id, data):
with self._lock:
self._data[user_id] = data
๐ Best Practices¶
1. Choose the Right Lifetime¶
# Singleton for shared resources
@singleton
class DatabaseConnection:
pass
# Scoped for request-specific data
@scoped("request")
class RequestContext:
pass
# Transient for stateless operations
@transient
class EmailValidator:
pass
2. Consider Thread Safety¶
# Make singletons thread-safe
@singleton
class SharedService:
def __init__(self):
self._lock = threading.Lock()
def do_work(self):
with self._lock:
# Thread-safe operations
pass
3. Use Appropriate Scopes¶
# Web application scopes
container.bind(UserSession, scope=Scope.REQUEST)
container.bind(Transaction, scope=Scope.REQUEST)
# Background job scopes
container.bind(JobContext, scope="job")
container.bind(TaskProgress, scope="job")
4. Handle Cleanup¶
# Use resource management for cleanup
@resource
def database_connection():
conn = create_connection()
try:
yield conn
finally:
conn.close()
5. Test Lifetime Behavior¶
def test_lifetimes():
# Test singleton behavior
# Test scoped behavior
# Test transient behavior
pass
๐ฏ Summary¶
Service lifetimes control:
- Singleton: One instance for the entire application
- Transient: New instance every time
- Scoped: One instance per scope (request, session, etc.)
- Custom: User-defined scopes for specific needs
Choose the right lifetime based on:
- Sharing needs: Shared vs. isolated state
- Performance: Creation overhead vs. memory usage
- Thread safety: Concurrent access patterns
- Testing: Isolation requirements
Key principles:
- Use singleton for expensive shared resources
- Use scoped for request/session specific data
- Use transient for stateless operations
- Always consider thread safety for singletons
- Test your lifetime choices
Ready to explore type safety in InjectQ?