dioxide.services

Service decorator for core domain logic.

The @service decorator marks classes as core domain logic in hexagonal architecture. Services represent the business rules layer that sits between ports (interfaces) and adapters (implementations), containing the core application logic that doesn’t depend on infrastructure details.

When to Use @service:

Use @service when you are writing core business logic that:

  • Should be the same across all environments (production, test, development)

  • Contains domain rules and use cases

  • Does NOT talk directly to external systems (databases, APIs, filesystems)

  • Depends on ports (Protocols/ABCs) for infrastructure needs

Do NOT use @service if you need different implementations per profile. Use @adapter.for_() instead.

Quick Decision Tree (answer in 10 seconds):

Which decorator should I use?
            |
Does it talk to external systems (DB, API, file, network)?
            |
   +--------+--------+
   |                 |
  YES               NO
   |                 |
   v                 v
@adapter         Should different profiles use
.for_()          different implementations?
                      |
             +--------+--------+
             |                 |
            YES               NO
             |                 |
             v                 v
          @adapter         @service
          .for_()          (this one!)
Key Characteristics:
  • Configurable scope: SINGLETON (default), FACTORY, or REQUEST scope

  • Profile-agnostic: Available in ALL profiles (production, test, development)

  • Depends on ports: Services depend on Protocols/ABCs, not concrete implementations

  • Pure business logic: No knowledge of databases, APIs, or infrastructure

  • Constructor injection: Dependencies resolved from __init__ type hints

Scope Options:
  • @service or @service(scope=Scope.SINGLETON): One shared instance (default)

  • @service(scope=Scope.FACTORY): New instance on every resolve()

  • @service(scope=Scope.REQUEST): One instance per request scope

In hexagonal architecture, services form the hexagon’s center - the core domain that is isolated from external concerns. They depend on ports (abstractions), and the container injects the appropriate adapters based on the active profile.

Basic Example:

Core service with port dependencies:

from typing import Protocol
from dioxide import service, adapter, Profile


# Port (interface) - what the service needs
class EmailPort(Protocol):
    async def send(self, to: str, subject: str, body: str) -> None: ...


class UserRepository(Protocol):
    async def find_by_email(self, email: str) -> User | None: ...
    async def save(self, user: User) -> None: ...


# Service - core business logic
@service
class UserService:
    def __init__(self, email: EmailPort, users: UserRepository):
        # Depends on PORTS, not concrete adapters
        self.email = email
        self.users = users

    async def register_user(self, email_addr: str, name: str) -> User:
        # Pure business logic
        existing = await self.users.find_by_email(email_addr)
        if existing:
            raise ValueError(f'User {email_addr} already exists')

        user = User(email=email_addr, name=name)
        await self.users.save(user)
        await self.email.send(email_addr, 'Welcome!', f'Hello {name}!')
        return user
Advanced Example:

Service with multiple dependencies and complex logic:

@service
class NotificationService:
    def __init__(self, email: EmailPort, sms: SMSPort, users: UserRepository, clock: ClockPort):
        self.email = email
        self.sms = sms
        self.users = users
        self.clock = clock

    async def send_welcome(self, user_id: int) -> bool:
        user = await self.users.find_by_id(user_id)
        if not user:
            return False

        # Throttle: Don't send if already sent within 30 days
        if user.last_welcome_sent:
            elapsed = self.clock.now() - user.last_welcome_sent
            if elapsed < timedelta(days=30):
                return False

        # Send notifications
        await self.email.send(user.email, 'Welcome!', '...')
        if user.phone:
            await self.sms.send(user.phone, 'Welcome to our service!')

        # Update user
        user.last_welcome_sent = self.clock.now()
        await self.users.save(user)
        return True
Testing Example:

Services are testable with fakes, no mocks needed:

import pytest
from dioxide import Container, Profile


@pytest.fixture
def container():
    c = Container()
    c.scan(profile=Profile.TEST)  # Activates fake adapters
    return c


async def test_user_registration(container):
    # Arrange: Get service and fakes
    service = container.resolve(UserService)
    email = container.resolve(EmailPort)  # FakeEmailAdapter
    users = container.resolve(UserRepository)  # InMemoryUserRepository

    # Act: Call real service
    user = await service.register_user('alice@example.com', 'Alice')

    # Assert: Check real observable outcomes
    assert user.email == 'alice@example.com'
    assert len(email.sent_emails) == 1
    assert email.sent_emails[0]['to'] == 'alice@example.com'
    saved_user = await users.find_by_email('alice@example.com')
    assert saved_user is not None

See also

Attributes

T

Functions

service(…)

Mark a class as a core domain service.

Module Contents

dioxide.services.T[source]
dioxide.services.service(cls: type[T]) type[T][source]
dioxide.services.service(*, scope: dioxide.scope.Scope = Scope.SINGLETON) collections.abc.Callable[[type[T]], type[T]]

Mark a class as a core domain service.

Services are components that represent core business logic. They are available in all profiles (production, test, development) and support automatic dependency injection.

Key characteristics: - Uses SINGLETON scope by default (one shared instance) - Can use FACTORY scope for fresh instances per resolution - Can use REQUEST scope for per-request instances - Does not require profile specification (available everywhere) - Represents core domain logic in hexagonal architecture

Usage:
Basic service (SINGLETON by default):
>>> from dioxide import service
>>>
>>> @service
... class UserService:
...     def create_user(self, name: str) -> dict:
...         return {'name': name, 'id': 1}
Service with dependencies:
>>> @service
... class EmailService:
...     pass
>>>
>>> @service
... class NotificationService:
...     def __init__(self, email: EmailService):
...         self.email = email
Factory-scoped service (new instance each time):
>>> from dioxide import service, Scope
>>>
>>> @service(scope=Scope.FACTORY)
... class TransactionContext:
...     def __init__(self):
...         self.transaction_id = str(uuid.uuid4())
>>>
>>> # Each resolve() returns a fresh instance:
>>> ctx1 = container.resolve(TransactionContext)
>>> ctx2 = container.resolve(TransactionContext)
>>> assert ctx1 is not ctx2
Request-scoped service:
>>> from dioxide import service, Scope
>>>
>>> @service(scope=Scope.REQUEST)
... class RequestContext:
...     def __init__(self):
...         self.request_id = str(uuid.uuid4())
Auto-discovery and resolution:
>>> from dioxide import container
>>>
>>> container.scan()
>>> notifications = container.resolve(NotificationService)
>>> assert isinstance(notifications.email, EmailService)
Parameters:
  • cls – The class being decorated (when used without parentheses).

  • scope – The lifecycle scope for this service. Defaults to SINGLETON. - SINGLETON: One shared instance for the lifetime of the container - REQUEST: One instance per scope (via container.create_scope()) - FACTORY: New instance on every resolve()

Returns:

The decorated class with dioxide metadata attached, or a decorator function if called with keyword arguments.

Note

  • Services default to SINGLETON scope

  • Services are available in all profiles

  • Dependencies are resolved from constructor (__init__) type hints

  • For profile-specific implementations, use @adapter.for_()