Skip to main content

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

Python Version License: MIT Code Style: Black

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

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, @factory decorators at point of definition
  • Full Type Hint Support — Complete type annotations with @overload for 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

getit_python-1.0.1.tar.gz (54.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

getit_python-1.0.1-py3-none-any.whl (36.4 kB view details)

Uploaded Python 3

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

Hashes for getit_python-1.0.1.tar.gz
Algorithm Hash digest
SHA256 60a4f2a7b9d7214cdc9478f04e253435d7e8fb9bf5408d45d2c16c71ab93cdc9
MD5 b72e5ca5d1f38d2f6425c12564437f3f
BLAKE2b-256 5f1c553b7dae2d91e5076027545a173440b0bb36ad4dd6d9de1c0936419e7339

See more details on using hashes here.

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

Hashes for getit_python-1.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 665fadd3a8eaff27b672736863ced2039d6cda27963ec8fa4bfad9a5be791c1f
MD5 9c719a8010f2af943ce05ab9edd1c805
BLAKE2b-256 a16dad932633b6a780735105ac7c108cfe063f011f10c8ab0e0627fd4fb39c17

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page