Choosing Between @service and @adapter¶
Goal: Answer “which decorator?” in 10 seconds.
The Decision Tree¶
Quick Summary¶
Use This |
When |
Examples |
|---|---|---|
|
Core business logic that stays the same everywhere |
|
|
Connects to external systems OR needs profile switching |
|
Code Examples¶
Use @service for Business Logic¶
Business rules, use cases, and domain services that don’t change between environments.
from dioxide import service
@service
class OrderService:
"""Core business logic - same in production and tests."""
def __init__(self, orders: OrderRepository, payments: PaymentGateway):
self.orders = orders # Depends on PORTS, not implementations
self.payments = payments
async def process_order(self, order_id: str) -> bool:
order = await self.orders.find(order_id)
if order.total > 1000:
# Business rule: require approval for large orders
return False
await self.payments.charge(order.total)
return True
Key insight: The OrderService logic is identical whether running against a real database or an in-memory fake. Only its dependencies change.
Use @adapter.for_() for External Integrations¶
Implementations that talk to databases, APIs, filesystems, or need different behavior per profile.
from typing import Protocol
from dioxide import adapter, Profile
# First, define the Port (interface)
class OrderRepository(Protocol):
async def find(self, order_id: str) -> Order: ...
async def save(self, order: Order) -> None: ...
# Production adapter - real database
@adapter.for_(OrderRepository, profile=Profile.PRODUCTION)
class PostgresOrderRepository:
def __init__(self, db: Database):
self.db = db
async def find(self, order_id: str) -> Order:
# Real SQL query
...
# Test adapter - fast fake
@adapter.for_(OrderRepository, profile=Profile.TEST)
class FakeOrderRepository:
def __init__(self):
self.orders = {}
async def find(self, order_id: str) -> Order:
return self.orders.get(order_id)
def seed(self, *orders: Order) -> None:
"""Test helper to populate data."""
for order in orders:
self.orders[order.id] = order
Common Patterns¶
Pattern: Configuration as @service¶
Configuration classes ARE services - they don’t need profile switching because you control config via environment variables.
from pydantic_settings import BaseSettings
from dioxide import service
@service
class AppConfig(BaseSettings):
"""Configuration is a service, not an adapter."""
database_url: str = "sqlite:///dev.db"
stripe_api_key: str = ""
Pattern: Adapter Depends on @service¶
Adapters can depend on services (like config) to get their settings.
@adapter.for_(PaymentGateway, profile=Profile.PRODUCTION)
class StripeAdapter:
def __init__(self, config: AppConfig): # Depends on config service
self.api_key = config.stripe_api_key
Pattern: Adapter for Multiple Profiles¶
Use a list when the same implementation works for multiple profiles.
@adapter.for_(CachePort, profile=[Profile.TEST, Profile.DEVELOPMENT])
class InMemoryCache:
"""Simple cache for both test and dev."""
def __init__(self):
self._cache = {}
The Mental Model¶
@service Port @adapter
┌─────────────────┐ ┌───────────┐ ┌─────────────────┐
│ │ │ │ │ │
│ Business │─depends→ │ Protocol │←implements│ Real DB │
│ Logic │ │ │ │ Real Email │
│ │ │ │ │ Real Payment │
└─────────────────┘ └───────────┘ └─────────────────┘
↑ ↑ ↑
Same in all profiles No decorator! Profile-specific
Services contain business rules and depend on Ports (Protocols). Adapters implement those ports for specific environments.
Anti-Patterns to Avoid¶
Wrong: Service with Infrastructure¶
# BAD - service contains infrastructure
@service
class UserService:
async def register(self, email: str) -> None:
conn = psycopg2.connect("dbname=prod") # Infrastructure leak!
sendgrid.send(to=email, subject="Welcome!") # More leakage!
Fix: Depend on ports, not implementations.
Wrong: Adapter with Business Logic¶
# BAD - adapter contains business rules
@adapter.for_(EmailPort, profile=Profile.PRODUCTION)
class SendGridAdapter:
async def send(self, to: str, subject: str, body: str) -> None:
if "@" not in to: # Business rule in adapter!
raise ValueError("Invalid email")
# ... send email
Fix: Move validation to the service layer.
Still Not Sure?¶
Ask these questions:
Would I want a different implementation in tests?
Yes →
@adapter.for_(Port, profile=Profile.TEST)for the test versionNo → Probably
@service
Does this code make network calls, touch files, or use a database?
Yes →
@adapter.for_()with a PortNo →
@service
Is this pure business logic with no side effects?
Yes →
@serviceNo → Consider whether it should be split
Next Steps¶
Understanding @service vs @adapter - Deep dive with more examples
Hexagonal Architecture with dioxide - Full architectural context
Testing with Fakes - Testing patterns with profile-based fakes