Understanding @service vs @adapter

This guide clarifies the mental model for when to use @service versus @adapter.for_() decorators in dioxide.

The Decision Tree

Use this flowchart when deciding which decorator to apply.

Decision tree for choosing @service vs @adapter

Key questions:

  • Need to swap implementations based on profile? → Port + @adapter.for_()

  • Core business logic that shouldn’t change?@service

  • Talks to external systems (DB, network, filesystem)? → Port + @adapter

The Mental Model

Understanding the relationship between services, ports, and adapters is fundamental to dioxide and hexagonal architecture.

External World<br/>(Driving Side)

@service: The Core

Services represent core business logic - the domain rules that define your application’s behavior. They sit at the center of the hexagon.

Key characteristics of @service:

Characteristic

Description

Singleton

One shared instance across the application

Profile-agnostic

Available in ALL profiles (production, test, development)

Depends on Ports

Uses Protocol types, not concrete implementations

Pure business logic

No knowledge of databases, APIs, or infrastructure

Why @service doesn’t require a port:

Services ARE the abstraction. They represent your domain logic, which doesn’t need to be swapped based on environment. The same UserService runs in production and tests - only its dependencies (the ports) change.

@adapter.for_(): The Boundaries

Adapters are concrete implementations of ports that connect your application to the outside world. They live at the hexagon’s edges.

Key characteristics of @adapter.for_():

Characteristic

Description

Profile-specific

Different adapter per environment

Implements a Port

Satisfies a Protocol contract

Singleton by default

One instance per profile (can be overridden)

Infrastructure code

Talks to databases, APIs, filesystems

Why @adapter requires a port:

Adapters implement abstractions (ports). Multiple adapters can satisfy the same port contract - one for production (real database), one for testing (in-memory), one for development (local file). The container selects the right one based on the active profile.

Ports: The Contracts

Ports are interfaces (Python Protocols) that define contracts between your core and the outside world.

Important: Ports have NO decorator. They’re just type definitions.

from typing import Protocol

class EmailPort(Protocol):
    """Port defining email operations - no decorator needed."""
    async def send(self, to: str, subject: str, body: str) -> None: ...

Practical Examples

Example 1: Email System

Port (the interface):

from typing import Protocol

class EmailPort(Protocol):
    async def send(self, to: str, subject: str, body: str) -> None: ...

Adapters (profile-specific implementations):

from dioxide import adapter, Profile

@adapter.for_(EmailPort, profile=Profile.PRODUCTION)
class SendGridAdapter:
    """Real email via SendGrid API."""
    async def send(self, to: str, subject: str, body: str) -> None:
        # Real API calls
        ...

@adapter.for_(EmailPort, profile=Profile.TEST)
class FakeEmailAdapter:
    """Fast fake for testing."""
    def __init__(self):
        self.sent_emails = []

    async def send(self, to: str, subject: str, body: str) -> None:
        self.sent_emails.append({"to": to, "subject": subject, "body": body})

@adapter.for_(EmailPort, profile=Profile.DEVELOPMENT)
class ConsoleEmailAdapter:
    """Console logging for development."""
    async def send(self, to: str, subject: str, body: str) -> None:
        print(f"EMAIL TO: {to}\nSUBJECT: {subject}")

Service (core business logic):

from dioxide import service

@service
class NotificationService:
    """Business logic - depends on EmailPort, not concrete adapter."""
    def __init__(self, email: EmailPort):
        self.email = email  # Could be SendGrid, Fake, or Console

    async def send_welcome(self, user_email: str, user_name: str) -> None:
        # Business rule: validate email
        if "@" not in user_email:
            raise ValueError("Invalid email")

        # Delegate to port - service doesn't know/care which adapter
        await self.email.send(
            to=user_email,
            subject="Welcome!",
            body=f"Hello {user_name}, welcome to our service!"
        )

Example 2: Configuration

Sometimes you need something that IS a service (singleton, profile-agnostic) but also loads from environment:

from dioxide import service
from pydantic_settings import BaseSettings

@service
class AppConfig(BaseSettings):
    """Configuration - it's a service, not an adapter.

    Why @service?
    - Same config class runs in all profiles
    - Doesn't implement a port
    - Just stores configuration values
    """
    database_url: str = "sqlite:///dev.db"
    sendgrid_api_key: str = ""

Example 3: Repository Pattern

Port:

class UserRepository(Protocol):
    async def save(self, user: dict) -> None: ...
    async def find_by_id(self, user_id: str) -> dict | None: ...

Production adapter:

@adapter.for_(UserRepository, profile=Profile.PRODUCTION)
class PostgresUserRepository:
    """Real database."""
    def __init__(self, db: Database):
        self.db = db

    async def save(self, user: dict) -> None:
        # Real SQL
        ...

Test adapter:

@adapter.for_(UserRepository, profile=Profile.TEST)
class InMemoryUserRepository:
    """Fast fake - no database needed."""
    def __init__(self):
        self.users = {}

    async def save(self, user: dict) -> None:
        self.users[user["id"]] = user

    async def find_by_id(self, user_id: str) -> dict | None:
        return self.users.get(user_id)

    def seed(self, *users: dict) -> None:
        """Test helper - seed with test data."""
        for user in users:
            self.users[user["id"]] = user

Quick Reference Table

Question

@service

@adapter.for_()

What is it?

Core domain logic

Boundary implementation

Requires a port?

No

Yes

Profile-specific?

No (same in all profiles)

Yes (different per profile)

Scope

Singleton

Singleton (default)

Dependencies

Depends on Ports

May depend on services or other adapters

Testing

Same service, different adapters injected

Different adapter per profile

Common Patterns

Pattern 1: Service Depends on Multiple Ports

@service
class OrderService:
    def __init__(
        self,
        orders: OrderRepository,
        payments: PaymentGateway,
        email: EmailPort,
        clock: ClockPort
    ):
        # All dependencies are ports - injectable via profiles
        self.orders = orders
        self.payments = payments
        self.email = email
        self.clock = clock

    async def process_order(self, order_id: str) -> None:
        # Pure business logic using ports
        order = await self.orders.find_by_id(order_id)
        await self.payments.charge(order["amount"], order["card_token"])
        await self.email.send(order["customer_email"], "Order Confirmed", "...")

Pattern 2: Adapter Depends on Configuration

@adapter.for_(PaymentGateway, profile=Profile.PRODUCTION)
class StripeAdapter:
    def __init__(self, config: AppConfig):
        # Adapters can depend on services like AppConfig
        self.api_key = config.stripe_api_key

Pattern 3: Adapter for Multiple Profiles

@adapter.for_(CachePort, profile=[Profile.TEST, Profile.DEVELOPMENT])
class InMemoryCacheAdapter:
    """Simple cache for both test and dev environments."""
    def __init__(self):
        self._cache = {}

Anti-Patterns to Avoid

Anti-Pattern 1: Service with Infrastructure

# BAD - service contains infrastructure
@service
class UserService:
    async def register(self, email: str) -> None:
        # Don't do this!
        conn = psycopg2.connect("dbname=prod")  # Infrastructure in service!
        sendgrid.send(to=email, subject="Welcome!")  # More infrastructure!

Fix: Depend on ports instead.

Anti-Pattern 2: Adapter with Business Logic

# BAD - adapter contains business logic
@adapter.for_(EmailPort, profile=Profile.PRODUCTION)
class SendGridAdapter:
    async def send(self, to: str, subject: str, body: str) -> None:
        # Don't put business rules here!
        if "@" not in to:
            raise ValueError("Invalid email")  # This is a business rule!
        # ... actual sending

Fix: Move business logic to the service.

Anti-Pattern 3: Service that Should Be an Adapter

# BAD - this should be an adapter
@service
class EmailSender:
    async def send(self, to: str, subject: str, body: str) -> None:
        # This talks to external systems - should be an adapter!
        async with httpx.AsyncClient() as client:
            await client.post("https://api.sendgrid.com/...")

Fix: Create a port and use @adapter.for_().

Summary

Use @service when:

  • Implementing core business logic

  • The component shouldn’t change between environments

  • You’re writing domain rules and use cases

Use @adapter.for_() when:

  • Connecting to external systems (DB, API, filesystem)

  • You need different implementations for different profiles

  • You want to swap real implementations for fakes in tests

The key insight: Services depend on ports (abstractions), adapters implement ports (concrete). This IS the Dependency Inversion Principle in action.

Next Steps