Service locator and dependency injection container for Python with thread-safe singletons, lazy loading, and factory patterns
Project description
GetIt: Service Locator & Dependency Injection Container
GetIt is a comprehensive, thread-safe service locator and dependency injection container for Python. It provides production-grade dependency management through declarative registration, multiple lifecycle strategies, async-first design, and automatic resource management—with minimal boilerplate and maximum architectural clarity.
Designed for WSGI/ASGI applications, microservices, cloud-native systems, and complex Python projects requiring flexible, maintainable dependency management.
Table of Contents
- Features
- Installation
- Core Concepts
- Basic Usage
- Dependency Lifecycles
- Resolution Strategies
- Advanced Features
- Async Patterns
- Scoped Dependencies
- Best Practices
- Testing Strategies
- API Reference
- Troubleshooting
Features
Core Capabilities
- Thread-Safe Implementation — Double-checked locking guarantees singleton instantiation exactly once under concurrent load
- Multiple Lifecycle Strategies — Singleton, Lazy Singleton, Factory, Async Singleton, Scoped, and Weak Singleton patterns
- Async-First Design — Full support for async initialization with topological dependency ordering and concurrent initialization
- Named Registrations — Support multiple instances of the same type with distinct identifiers
- Flexible Resolution APIs — Type-based, dot-notation, instance call, and metaclass syntaxes for different coding styles
Developer Experience
- Zero Circular Imports — Defer dependency resolution to runtime without architectural constraints
- Declarative Registration — Use
@singleton,@lazy_singleton,@factorydecorators at point of definition - Full Type Hint Support — Complete type annotations with
@overloadfor IDE autocomplete in VS Code and PyCharm - Comprehensive Error Handling — Custom exceptions with actionable diagnostics and debugging information
- Structured Logging — Built-in logging at DEBUG level for observability and troubleshooting
Production Features
- Scope Management — Context-local dependency isolation for request-scoped, transaction-scoped, and context-scoped patterns
- Resource Cleanup — Automatic disposal hooks with LIFO cleanup ordering for proper resource finalization
- Circular Dependency Detection — Validates dependency graph at registration time to prevent runtime failures
- Override & Restore — Temporarily replace dependencies for testing without modifying production code
Installation
pip install getit-python
Requirements: Python 3.8 or later
Core Concepts
Service Locator Pattern
GetIt implements the Service Locator pattern, which centralizes dependency resolution through a singleton container. Services request dependencies from the container at runtime rather than requiring explicit constructor injection.
Dependency Lifecycle
Every registered dependency has a lifecycle that determines instantiation timing and instance count:
| Lifecycle | Instantiation | Instances | Use Case |
|---|---|---|---|
| Singleton | At registration | Exactly 1 | Static config, caches, shared state |
| Lazy Singleton | On first access | Exactly 1 | Database connections, expensive initialization |
| Factory | Every access | Many (fresh each time) | Request contexts, temporary objects |
| Async Singleton | At all_ready() |
Exactly 1 | Async-initialized services with ordering |
| Scoped | Per scope | 1 per scope | Request-scoped, transaction-scoped dependencies |
| Weak Singleton | On first access | 0 or 1 (GC-sensitive) | Memory-sensitive caches |
Basic Usage
Declarative Registration with Decorators
Services self-register using decorators at class definition:
from get_it import GetIt, singleton, lazy_singleton, factory
@singleton
class Configuration:
"""Loaded once at container initialization."""
def __init__(self):
self.debug = True
self.database_url = "postgresql://localhost/mydb"
@lazy_singleton
class DatabasePool:
"""Loaded on first access."""
def __init__(self):
print("Initializing database connection pool...")
self.connections = self._create_pool()
def _create_pool(self):
# Expensive initialization deferred until needed
return []
@factory
class RequestContext:
"""Fresh instance for each request."""
def __init__(self):
import uuid
self.request_id = str(uuid.uuid4())
self.user = None
# Resolution
config = GetIt.get(Configuration)
db_pool = GetIt.get(DatabasePool)
request_ctx = GetIt.get(RequestContext)
Manual Runtime Registration
Register services programmatically when needed:
from get_it import GetIt
class Logger:
def log(self, message: str) -> None:
print(f"[LOG] {message}")
# Register an existing instance
logger = Logger()
GetIt.register_singleton(logger)
# Register a factory
GetIt.register_factory(Logger, lambda: Logger())
# Register a lazy-loaded service
GetIt.register_lazy_singleton(Logger, lambda: Logger())
# Resolve
logger_instance = GetIt.get(Logger)
logger_instance.log("Service initialized")
Dependency Lifecycles
Singleton (Eager Initialization)
Instantiated immediately when registered. The instance exists for the entire application lifetime:
from get_it import GetIt, singleton
@singleton
class ApplicationMetadata:
"""Initialized immediately at import time."""
def __init__(self):
print("[INIT] Loading application metadata...")
self.version = "2.0.0"
self.environment = "production"
# Initialization happens during decorator evaluation
metadata = GetIt.get(ApplicationMetadata)
Advantages:
- Failures occur at startup (fail-fast principle)
- Simple, predictable initialization
Disadvantages:
- Blocks application startup if initialization is expensive
- Resources allocated even if never used
Use when:
- Initialization is fast and non-blocking
- Failures should prevent application startup
- Resource is stateless or read-only
- Configuration affects the entire application
Lazy Singleton (Deferred Initialization)
Instantiated on first access using double-checked locking for thread safety. The instance is cached for subsequent accesses:
from get_it import GetIt, lazy_singleton
@lazy_singleton
class DatabaseConnection:
"""Initialized only on first access."""
def __init__(self):
print("[DB] Establishing connection...")
import psycopg2
self.conn = psycopg2.connect("postgresql://localhost/mydb")
def query(self, sql: str):
return self.conn.execute(sql)
# No initialization yet
print("Application starting...")
# First access triggers initialization
result = GetIt.get(DatabaseConnection).query("SELECT VERSION();")
Advantages:
- Defers expensive operations until needed
- Reduces startup time
- Allows graceful handling of initialization failures
Disadvantages:
- First access slightly slower due to initialization
- Initialization timing unpredictable
Use when:
- Initialization is expensive (I/O, network, computation)
- Service may not be needed in all execution paths
- You want to defer failures until actual usage
Factory (Fresh Instance Per Access)
Creates a new instance on every resolution request. Each instance is independent:
from get_it import GetIt, factory
@factory
class RequestContext:
"""New instance for each request or operation."""
def __init__(self):
import uuid
self.request_id = str(uuid.uuid4())
self.user = None
self.metadata = {}
# Each call creates a new instance
ctx1 = GetIt.get(RequestContext)
ctx2 = GetIt.get(RequestContext)
assert ctx1 is not ctx2
assert ctx1.request_id != ctx2.request_id
Advantages:
- Complete isolation between instances
- No shared state issues
- Suitable for request/transaction scoping
Disadvantages:
- Memory overhead from frequent instantiation
- Initialization cost paid every time
Use when:
- Each invocation needs isolated state
- Objects are request-scoped or transaction-scoped
- State must not leak between calls
- Stateless factories with minimal overhead
Resolution Strategies
GetIt provides multiple ways to resolve dependencies. Choose based on context and coding style:
Strategy 1: Explicit Type-Based Resolution (Recommended)
Most explicit and type-safe approach:
from get_it import GetIt, lazy_singleton
@lazy_singleton
class UserRepository:
pass
# Clearest intent, best for type checking
repo = GetIt.get(UserRepository)
Strategy 2: Instance Method Call
Using the GetIt instance as a callable:
get_it = GetIt()
repo = get_it(UserRepository)
Strategy 3: Dot-Notation (Flutter Style)
Resolves by matching class name:
get_it = GetIt()
repo = get_it.UserRepository # Resolves by class name
Note: Less type-safe; rely on TYPE_CHECKING for IDE support.
Strategy 4: Metaclass Call
Direct class-level resolution:
repo = GetIt(UserRepository)
Advanced Features
Named Registrations
Support multiple instances of the same type with distinct names:
from get_it import GetIt
class DatabaseConnection:
def __init__(self, url: str):
self.url = url
# Register multiple instances with names
prod_db = DatabaseConnection("postgresql://prod.example.com/db")
test_db = DatabaseConnection("sqlite:///:memory:")
cache_db = DatabaseConnection("redis://localhost:6379")
GetIt.register_singleton(prod_db, name="prod")
GetIt.register_singleton(test_db, name="test")
GetIt.register_singleton(cache_db, name="cache")
# Resolve by name
production = GetIt.get(DatabaseConnection, name="prod")
testing = GetIt.get(DatabaseConnection, name="test")
cache = GetIt.get(DatabaseConnection, name="cache")
Override and Restore (Testing)
Temporarily replace a dependency for testing without modifying production code:
from get_it import GetIt, lazy_singleton
@lazy_singleton
class PaymentGateway:
def charge(self, amount: float) -> bool:
# Real implementation calls payment processor
return True
class MockPaymentGateway:
def charge(self, amount: float) -> bool:
# Test implementation returns fixed response
return True
# In tests
def test_checkout_flow():
# Setup
mock_gateway = MockPaymentGateway()
GetIt.override(PaymentGateway, mock_gateway)
# Execute
service = OrderService()
result = service.checkout(amount=99.99)
# Assert
assert result is True
# Cleanup
GetIt.restore_override(PaymentGateway)
Weak Singletons
Singleton that recreates if garbage collected. Useful for memory-sensitive caches:
from get_it import GetIt
class CacheItem:
def __init__(self, key: str, value: str):
self.key = key
self.value = value
def create_cache_item():
return CacheItem("config", "expensive_value")
GetIt.register_weak_singleton(CacheItem, create_cache_item)
# Instance exists while referenced
item = GetIt.get(CacheItem)
print(item.value)
# If no external references and GC runs,
# next GetIt.get() recreates it
del item # Remove external reference
import gc
gc.collect()
item2 = GetIt.get(CacheItem) # Recreated
Use when:
- Caching objects that should be reclaimed under memory pressure
- Memory-sensitive applications with soft reference caches
Async Patterns
Async Singleton with Dependency Ordering
Register async-initialized services with dependency graph and topological initialization:
import asyncio
from get_it import GetIt
class Config:
async def load(self):
await asyncio.sleep(0.1)
return "config_loaded"
class Database:
def __init__(self, config: str):
self.config = config
async def create_config():
config = Config()
await config.load()
return config
async def create_database(config: Config):
# Config is guaranteed to be initialized first
# Parameter is auto-injected based on type hint
return Database(config)
GetIt.register_singleton_async(Config, create_config)
GetIt.register_singleton_async(
Database,
create_database,
depends_on=[Config] # Ensures Config initializes first
)
async def main():
# Initializes in topological order: Config → Database
await GetIt.all_ready()
db = GetIt.get(Database)
print(f"Database ready with config: {db.config}")
asyncio.run(main())
Complex Dependency Graph
Topological ordering handles multi-level dependencies with concurrent initialization:
import asyncio
from get_it import GetIt
async def init_logger():
await asyncio.sleep(0.01)
return {"type": "logger"}
async def init_config(logger: dict):
# logger parameter auto-injected from dict type
await asyncio.sleep(0.01)
return {"logger": logger, "debug": True}
async def init_database(config: dict):
# config parameter auto-injected from dict type
await asyncio.sleep(0.01)
return {"config": config}
async def init_repository(database: dict, logger: dict):
# Both parameters auto-injected based on type hints
await asyncio.sleep(0.01)
return {"database": database, "logger": logger}
GetIt.register_singleton_async(dict, init_logger, name="logger")
GetIt.register_singleton_async(
dict,
init_config,
name="config",
depends_on=[dict] # Depends on logger
)
GetIt.register_singleton_async(
dict,
init_database,
name="database",
depends_on=[dict] # Depends on config
)
GetIt.register_singleton_async(
dict,
init_repository,
name="repository",
depends_on=[dict] # Depends on database and logger
)
async def main():
# Initialization order: logger → config → database → repository
await GetIt.all_ready()
repository = GetIt.get(dict, name="repository")
print(repository)
asyncio.run(main())
Auto-Injection for Async Factories: Parameters in async factory functions are automatically injected based on their type hints. The framework resolves dependencies in topological order, ensuring all dependencies are initialized before a dependent service. Parameters without type hints use default values if provided, or raise an error if required.
Async Factory
Creates new instances asynchronously on each access:
import asyncio
from get_it import GetIt
async def create_connection():
await asyncio.sleep(0.1) # Simulated async connection
return {"status": "connected"}
GetIt.register_factory_async(dict, create_connection)
async def main():
# Each call creates a new instance asynchronously
conn1 = await GetIt.get_async(dict)
conn2 = await GetIt.get_async(dict)
assert conn1 is not conn2
asyncio.run(main())
Scoped Dependencies
Scopes isolate dependencies within a context (request, transaction, operation). Perfect for web frameworks:
import asyncio
from get_it import GetIt
class RequestContext:
def __init__(self, request_id: str):
self.request_id = request_id
self.user = None
self.closed = False
async def cleanup(self):
self.closed = True
print(f"[CLEANUP] Request {self.request_id}")
async def handle_request(request_id: str):
# Enter scope for this request
GetIt.push_scope(f"request_{request_id}")
try:
# Register request-scoped services
ctx = RequestContext(request_id)
GetIt.register_singleton_scoped(RequestContext, ctx)
# Use services
retrieved_ctx = GetIt.get(RequestContext)
print(f"Processing request {retrieved_ctx.request_id}")
finally:
# Exit scope and trigger cleanup
await GetIt.pop_scope()
async def main():
# Run multiple requests with isolated scopes
await asyncio.gather(
handle_request("req-001"),
handle_request("req-002"),
handle_request("req-003")
)
asyncio.run(main())
FastAPI Integration
from fastapi import FastAPI, Request
from get_it import GetIt
import asyncio
app = FastAPI()
class RequestDB:
def __init__(self, request_id: str):
self.request_id = request_id
self.session = None
@app.middleware("http")
async def setup_scope(request: Request, call_next):
"""Middleware to manage request scope."""
request_id = request.headers.get("X-Request-ID", "unknown")
GetIt.push_scope(f"request_{request_id}")
try:
response = await call_next(request)
finally:
await GetIt.pop_scope()
return response
@app.get("/data/{item_id}")
async def get_data(item_id: int):
db = GetIt.get(RequestDB)
return {"item_id": item_id, "request_id": db.request_id}
Best Practices
1. Organize by Feature
Group related services and register them together:
# services/auth.py
from get_it import lazy_singleton
@lazy_singleton
class AuthService:
def authenticate(self, token: str) -> bool:
return len(token) > 10
# services/database.py
from get_it import lazy_singleton
@lazy_singleton
class Database:
def query(self, sql: str):
return f"Result: {sql}"
# main.py
from services import auth, database # Services auto-register on import
from get_it import GetIt
auth_service = GetIt.get(auth.AuthService)
db = GetIt.get(database.Database)
2. Prefer Explicit Dependencies
Use constructor injection over service locator lookups when possible:
# ❌ Anti-pattern: Hidden dependencies
class UserService:
def get_user(self, user_id: int):
db = GetIt.get(Database) # Hidden dependency
return db.query(f"SELECT * FROM users WHERE id = {user_id}")
# ✅ Better: Explicit constructor injection
class UserService:
def __init__(self, database: Database):
self.database = database
def get_user(self, user_id: int):
return self.database.query(f"SELECT * FROM users WHERE id = {user_id}")
# Register with dependency
GetIt.register_lazy_singleton(
UserService,
lambda: UserService(GetIt.get(Database))
)
3. Use Named Registrations for Variants
# ✅ Better than creating separate classes
from get_it import GetIt
cache_redis = RedisCache(url="redis://localhost")
cache_memory = MemoryCache()
GetIt.register_singleton(cache_redis, name="redis")
GetIt.register_singleton(cache_memory, name="memory")
redis_cache = GetIt.get(Cache, name="redis")
memory_cache = GetIt.get(Cache, name="memory")
4. Fail Fast with Singletons
Use singletons for critical initialization so failures occur at startup:
@singleton
class CriticalDatabasePool:
def __init__(self):
# Raises exception if connection fails
# Prevents application startup on failure
self.pool = self._establish_connection()
def _establish_connection(self):
import psycopg2
return psycopg2.pool.SimpleConnectionPool(
1, 20,
"postgresql://localhost/critical_db"
)
5. Use Scopes for Request Isolation
# For FastAPI/Flask/etc
@app.middleware("http")
async def request_scope(request: Request, call_next):
GetIt.push_scope(f"request_{id(request)}")
try:
response = await call_next(request)
finally:
await GetIt.pop_scope() # Cleanup
return response
Testing Strategies
Test Isolation with Reset
import pytest
from get_it import GetIt
@pytest.fixture(autouse=True)
def clean_di():
"""Ensure clean DI container for each test."""
GetIt.reset()
yield
GetIt.reset()
def test_user_service():
class MockDatabase:
def query(self, sql):
return [{"id": 1, "name": "Test User"}]
GetIt.register_singleton(MockDatabase())
service = UserService()
users = service.get_users()
assert len(users) == 1
Override Dependencies
from get_it import GetIt
class RealPaymentService:
def charge(self, amount: float) -> bool:
# Real implementation
return self._call_payment_gateway(amount)
class MockPaymentService:
def charge(self, amount: float) -> bool:
return True
def test_checkout():
GetIt.register_singleton(RealPaymentService())
mock = MockPaymentService()
GetIt.override(RealPaymentService, mock)
try:
service = OrderService()
assert service.checkout(100) is True
finally:
GetIt.restore_override(RealPaymentService)
Fixture-Based Setup
import pytest
from get_it import GetIt
@pytest.fixture
def mock_database():
class MockDB:
def query(self, sql):
return []
db = MockDB()
GetIt.register_singleton(db)
yield db
GetIt.reset()
def test_with_mock(mock_database):
service = UserService()
assert service.get_users() == []
API Reference
Registration Methods
# Eager singleton
GetIt.register_singleton(instance, name=None, dispose=None)
# Lazy singleton
GetIt.register_lazy_singleton(Type, factory, name=None, depends_on=None, dispose=None)
# Factory
GetIt.register_factory(Type, factory, name=None, depends_on=None, dispose=None)
# Async singleton
GetIt.register_singleton_async(Type, async_factory, name=None, depends_on=None, dispose=None)
# Async factory
GetIt.register_factory_async(Type, async_factory, name=None, dispose=None)
# Weak singleton
GetIt.register_weak_singleton(Type, factory, name=None, depends_on=None, dispose=None)
# Scoped singleton
GetIt.register_singleton_scoped(Type, instance, name=None, dispose=None)
# Scoped factory
GetIt.register_factory_scoped(Type, factory, name=None, dispose=None)
Resolution Methods
# Synchronous resolution
instance = GetIt.get(Type, name=None)
# Asynchronous resolution
instance = await GetIt.get_async(Type, name=None)
# Instance call
instance = GetIt()(Type)
# Dot notation
instance = GetIt().ServiceName
Async Coordination
# Initialize all async singletons with dependency ordering
await GetIt.all_ready()
Scope Management
# Enter scope
scope = GetIt.push_scope(name: str) -> Scope
# Exit scope and cleanup
await GetIt.pop_scope()
# Get current scope
current = GetIt.current_scope -> Scope
Testing Utilities
# Clear all registrations
GetIt.reset()
# Override a dependency
GetIt.override(Type, instance, name=None)
# Restore overridden dependency
GetIt.restore_override(Type, name=None)
Troubleshooting
DependencyNotRegisteredError
Symptom: DependencyNotRegisteredError: Type 'SomeService' is not registered
Cause: Attempting to resolve an unregistered type.
Solution:
# ❌ Not registered
service = GetIt.get(UnregisteredService)
# ✅ Register first
GetIt.register_lazy_singleton(UnregisteredService, lambda: UnregisteredService())
service = GetIt.get(UnregisteredService)
CircularDependencyError
Symptom: CircularDependencyError: Circular dependency detected: A -> B -> C -> A
Cause: Dependency cycle detected (A depends on B, B depends on C, C depends on A).
Solution: Refactor to break the cycle:
# ❌ Circular
GetIt.register_lazy_singleton(A, factory_a, depends_on=[C])
GetIt.register_lazy_singleton(B, factory_b, depends_on=[A])
GetIt.register_lazy_singleton(C, factory_c, depends_on=[B])
# ✅ Refactored
GetIt.register_lazy_singleton(A, factory_a)
GetIt.register_lazy_singleton(B, lambda: factory_b(GetIt.get(A)))
GetIt.register_lazy_singleton(C, lambda: factory_c(GetIt.get(B)))
DependencyNotReadyError
Symptom: DependencyNotReadyError: Async singleton 'Database' is not ready
Cause: Accessing an async singleton before await GetIt.all_ready().
Solution:
# ❌ Not ready
GetIt.register_singleton_async(Database, create_database_async)
db = GetIt.get(Database)
# ✅ Wait first
GetIt.register_singleton_async(Database, create_database_async)
await GetIt.all_ready()
db = GetIt.get(Database)
InvalidScopeError
Symptom: InvalidScopeError: Cannot pop from empty scope stack
Cause: Calling pop_scope() when no scope is active.
Solution:
# ❌ Empty stack
await GetIt.pop_scope()
# ✅ Push before pop
GetIt.push_scope("request")
try:
# Use scoped services
pass
finally:
await GetIt.pop_scope()
License
MIT License. See LICENSE file for details.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file getit_python-1.0.1.tar.gz.
File metadata
- Download URL: getit_python-1.0.1.tar.gz
- Upload date:
- Size: 54.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
60a4f2a7b9d7214cdc9478f04e253435d7e8fb9bf5408d45d2c16c71ab93cdc9
|
|
| MD5 |
b72e5ca5d1f38d2f6425c12564437f3f
|
|
| BLAKE2b-256 |
5f1c553b7dae2d91e5076027545a173440b0bb36ad4dd6d9de1c0936419e7339
|
File details
Details for the file getit_python-1.0.1-py3-none-any.whl.
File metadata
- Download URL: getit_python-1.0.1-py3-none-any.whl
- Upload date:
- Size: 36.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
665fadd3a8eaff27b672736863ced2039d6cda27963ec8fa4bfad9a5be791c1f
|
|
| MD5 |
9c719a8010f2af943ce05ab9edd1c805
|
|
| BLAKE2b-256 |
a16dad932633b6a780735105ac7c108cfe063f011f10c8ab0e0627fd4fb39c17
|