How to use service definition

This guide shows how to use the ServiceDefinition class for advanced service configuration.

When to use service definition

Use ServiceDefinition when you need:

  • Custom factory functions for service creation

  • Named service registrations (for future multi-registration support)

  • Pre-configured service definitions to share across modules

  • More explicit control over service configuration

Basic usage

Creating a service definition

from hdmi import ServiceDefinition

# Basic definition (singleton is default)
definition = ServiceDefinition(MyService)  # singleton (scoped=False, transient=False)

# Or explicitly specify scoped or transient
scoped_definition = ServiceDefinition(MyService, scoped=True)
transient_definition = ServiceDefinition(MyService, transient=True)

# Note: service_type must be positional, boolean flags must be keyword
# This will fail:
# definition = ServiceDefinition(service_type=MyService)  # TypeError
# definition = ServiceDefinition(MyService, True)  # TypeError - use scoped=True

Registering with container builder

from hdmi import ContainerBuilder, ServiceDefinition

builder = ContainerBuilder()

# Register a ServiceDefinition
definition = ServiceDefinition(DatabaseService)  # singleton (default)
builder.register(definition)

# IMPORTANT: Cannot specify boolean flags when registering ServiceDefinition
# This will raise ValueError:
# builder.register(definition, scoped=True)  # ValueError!

# For simple registrations, use the shorthand:
builder.register(UserService, scoped=True)  # scoped service

Using custom factories

ServiceDefinition allows you to provide a custom factory function:

from hdmi import ContainerBuilder, ServiceDefinition

def create_database_connection():
    """Custom factory that configures the database."""
    return DatabaseConnection(
        host="localhost",
        port=5432,
        database="myapp"
    )

# Create definition with factory (singleton by default)
db_definition = ServiceDefinition(
    DatabaseConnection,
    factory=create_database_connection
)

import asyncio

async def main():
    builder = ContainerBuilder()
    builder.register(DatabaseConnection, factory=create_database_connection)

    async with builder.build() as container:
        db = await container.get(DatabaseConnection)  # Uses the factory

asyncio.run(main())

Factory requirements

  • Must be callable (function, method, or callable object)

  • Must be passed as keyword argument

  • Will be validated when ServiceDefinition is created

# Valid factories
def factory_function():
    return MyService()

definition = ServiceDefinition(MyService, factory=factory_function)

# Invalid - not callable
definition = ServiceDefinition(MyService, factory="not_callable")
# Raises: ValueError: factory must be callable

Using lifecycle hooks

Services can have initializers (called after instantiation) and finalizers (called when the container exits). Both can be sync or async.

Initializers

Initializers are called after a service is instantiated:

import asyncio
from hdmi import ContainerBuilder

class DatabaseConnection:
    def __init__(self):
        self.connected = False

    def connect(self):
        self.connected = True

async def main():
    builder = ContainerBuilder()

    # Sync initializer
    builder.register(
        DatabaseConnection,
        initializer=lambda db: db.connect()
    )

    async with builder.build() as container:
        db = await container.get(DatabaseConnection)
        assert db.connected  # initializer was called

asyncio.run(main())

Async initializers are also supported:

async def async_connect(db):
    await db.connect_async()

builder.register(DatabaseConnection, initializer=async_connect)

Finalizers

Finalizers are called when the container or scope exits:

import asyncio
from hdmi import ContainerBuilder

class DatabaseConnection:
    def __init__(self):
        self.connected = True

    def disconnect(self):
        self.connected = False

async def main():
    builder = ContainerBuilder()
    builder.register(
        DatabaseConnection,
        finalizer=lambda db: db.disconnect()
    )

    db_instance = None
    async with builder.build() as container:
        db_instance = await container.get(DatabaseConnection)
        assert db_instance.connected

    # After exiting the context, finalizer is called
    assert not db_instance.connected

asyncio.run(main())

Combined initializer and finalizer

builder.register(
    DatabaseConnection,
    initializer=lambda db: db.connect(),
    finalizer=lambda db: db.disconnect()
)

Controlling autowiring

The autowire parameter controls whether a service is automatically injected into optional dependencies. By default, autowire=True.

from hdmi import ContainerBuilder

class OptionalFeature:
    pass

class MyService:
    def __init__(self, feature: OptionalFeature | None = None):
        self.feature = feature

# With autowire=True (default), OptionalFeature is injected if registered
builder = ContainerBuilder()
builder.register(OptionalFeature)  # autowire=True by default
builder.register(MyService)

async with builder.build() as container:
    service = await container.get(MyService)
    assert service.feature is not None  # OptionalFeature was injected

# With autowire=False, the service is not injected into optional dependencies
builder = ContainerBuilder()
builder.register(OptionalFeature, autowire=False)
builder.register(MyService)

async with builder.build() as container:
    service = await container.get(MyService)
    assert service.feature is None  # Default value used instead

Named services

ServiceDefinition supports named registrations for future multi-registration scenarios:

primary_db = ServiceDefinition(
    DatabaseConnection,
    name="primary"  # singleton by default
)

readonly_db = ServiceDefinition(
    DatabaseConnection,
    name="readonly",
    factory=create_readonly_connection  # singleton by default
)

builder = ContainerBuilder()
builder.register(primary_db)
builder.register(readonly_db)

Note

Named service resolution is planned for future releases. Currently, names are stored but not used for resolution.

Exporting and importing definitions

ServiceDefinition is now exported from the main hdmi package:

# Direct import from hdmi
from hdmi import ServiceDefinition, ContainerBuilder

# Create reusable definitions (all singleton by default)
STANDARD_SERVICES = [
    ServiceDefinition(LoggingService),
    ServiceDefinition(ConfigService),
    ServiceDefinition(CacheService),
]

# Use in multiple places
def configure_container():
    builder = ContainerBuilder()
    for definition in STANDARD_SERVICES:
        builder.register(definition)
    return builder

Common patterns

Module-level definitions

Define services at module level for reuse:

# services.py
from hdmi import ServiceDefinition
from myapp.database import DatabaseConnection
from myapp.cache import RedisCache

# Export pre-configured definitions (singleton by default)
database_definition = ServiceDefinition(
    DatabaseConnection,
    factory=lambda: DatabaseConnection.from_env()
)

cache_definition = ServiceDefinition(
    RedisCache,
    name="main_cache"  # singleton by default
)

# main.py
from hdmi import ContainerBuilder
from services import database_definition, cache_definition

builder = ContainerBuilder()
builder.register(database_definition)
builder.register(cache_definition)

Testing with factories

Use factory functions to override services in tests:

# test_services.py
import pytest
from hdmi import ContainerBuilder

@pytest.mark.anyio
async def test_with_mock_database():
    # Create mock with custom factory (singleton by default)
    builder = ContainerBuilder()
    builder.register(DatabaseConnection, factory=lambda: MockDatabase())
    builder.register(UserService)  # depends on DatabaseConnection

    async with builder.build() as container:
        service = await container.get(UserService)
        # UserService will use the mock database

Best practices

  1. Use shorthand for simple registrations: If you only need type and scope, use builder.register(Type, scoped=True) or builder.register(Type, transient=True) directly.

  2. Always use async context manager: Use async with builder.build() as container: to ensure proper lifecycle management and finalizer execution.

  3. Use initializers for setup: Rather than calling setup methods manually, use initializers to ensure services are properly configured.

  4. Use finalizers for cleanup: Register finalizers for services that need cleanup (database connections, file handles, etc.) to ensure resources are released.

  5. Use autowire=False for optional features: When a service should only be injected explicitly, not automatically into optional dependencies.

  6. Validate early: ServiceDefinition validates the factory, initializer, and finalizer parameters immediately, catching errors early.

See also