Getting Started¶
Welcome to dioxide! This guide will help you understand dioxide and get your first dependency injection system up and running in minutes.
What is dioxide?¶
dioxide is a declarative dependency injection framework for Python that makes clean architecture simple.
Why dioxide exists¶
Most Python codebases struggle with:
Tight coupling: Business logic hardcoded to specific databases, email providers, etc.
Hard to test: Requires complex mocking setups that test mock behavior, not real code
Architecture drift: No clear boundaries between core logic and infrastructure
Expensive changes: Swapping implementations requires editing dozens of files
dioxide solves this by making the Dependency Inversion Principle trivial to apply:
Define ports (interfaces using Python Protocols)
Implement adapters (concrete implementations for different environments)
Write services (business logic that depends on ports, not adapters)
Let dioxide wire everything automatically based on type hints
Key Benefits¶
Type-Safe: If mypy passes, your wiring is correct
Profile-Based: Different implementations for production, test, development
Fast Fakes: Test with real implementations, not mocks
Rust Performance: Fast container operations via PyO3
Zero Ceremony: No manual
.bind()or.register()callsRequest Scoping: Isolate dependencies per request, task, or any bounded context
Installation¶
Prerequisites¶
dioxide requires:
Python: 3.11, 3.12, 3.13, or 3.14
Platform: Linux (x86_64, ARM64), macOS (Intel, Apple Silicon), Windows (x86_64)
Install via pip¶
The simplest way to install dioxide:
pip install dioxide
Install via uv (recommended)¶
If you’re using uv for fast Python package management:
uv add dioxide
Install via poetry¶
If you’re using Poetry:
poetry add dioxide
Verify installation¶
Check that dioxide is installed correctly:
python -c "import dioxide; print(dioxide.__version__)"
Platform support matrix¶
Platform |
x86_64 |
ARM64/aarch64 |
|---|---|---|
Linux |
✅ |
✅ |
macOS |
✅ |
✅ (M1/M2/M3) |
Windows |
✅ |
❌ |
Your First Example¶
Let’s build a simple notification system to understand dioxide’s core concepts.
The Problem¶
You’re building an app that sends welcome emails. In production, you’ll use a real email service (SendGrid), but in tests, you want fast fakes without mocking frameworks.
Traditional approach (tight coupling):
# ❌ Tightly coupled to SendGrid
class UserService:
def __init__(self):
self.sendgrid_client = SendGridAPIClient(api_key="...")
async def register_user(self, email: str, name: str):
# Hardcoded to SendGrid!
self.sendgrid_client.send(...)
Problems:
Can’t test without hitting SendGrid API or complex mocking
Can’t swap to different email provider without rewriting UserService
Business logic mixed with infrastructure details
The dioxide Way¶
Step 1: Define the Port (Interface)¶
First, define what operations you need using a Python Protocol:
from typing import Protocol
class EmailPort(Protocol):
"""Port (interface) for email operations."""
async def send(self, to: str, subject: str, body: str) -> None:
"""Send an email to recipient."""
...
This is your seam - the boundary between core logic and infrastructure.
Step 2: Create Adapters for Different Environments¶
Now implement the port for production and testing:
from dioxide import adapter, Profile
# Production adapter - real SendGrid
@adapter.for_(EmailPort, profile=Profile.PRODUCTION)
class SendGridAdapter:
"""Production email adapter using SendGrid API."""
def __init__(self):
import os
self.api_key = os.getenv("SENDGRID_API_KEY")
async def send(self, to: str, subject: str, body: str) -> None:
# Real SendGrid API calls
import httpx
async with httpx.AsyncClient() as client:
await client.post(
"https://api.sendgrid.com/v3/mail/send",
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"personalizations": [{"to": [{"email": to}]}],
"from": {"email": "noreply@example.com"},
"subject": subject,
"content": [{"type": "text/plain", "value": body}]
}
)
# Test adapter - fast fake
@adapter.for_(EmailPort, profile=Profile.TEST)
class FakeEmailAdapter:
"""Test email adapter that captures sends in memory."""
def __init__(self):
self.sent_emails = [] # Observable state for assertions
async def send(self, to: str, subject: str, body: str) -> None:
# No I/O - just capture for verification
self.sent_emails.append({
"to": to,
"subject": subject,
"body": body
})
# Development adapter - console logging
@adapter.for_(EmailPort, profile=Profile.DEVELOPMENT)
class ConsoleEmailAdapter:
"""Development email adapter that prints to console."""
async def send(self, to: str, subject: str, body: str) -> None:
print(f"📧 Email to: {to}")
print(f" Subject: {subject}")
print(f" Body: {body}")
Step 3: Write Business Logic (Service)¶
Your core business logic depends on the port, not any specific adapter:
from dioxide import service
@service
class UserService:
"""Core business logic for user operations."""
def __init__(self, email: EmailPort):
# Depends on PORT, not concrete adapter!
# Container auto-injects based on active profile
self.email = email
async def register_user(self, email_addr: str, name: str):
"""Register a new user and send welcome email."""
# Business logic - doesn't know/care which email adapter is active
print(f"Registering user: {name} ({email_addr})")
# Send welcome email via injected adapter
await self.email.send(
to=email_addr,
subject="Welcome to Our Service!",
body=f"Hello {name},\n\nThanks for signing up!\n\nBest regards,\nThe Team"
)
print(f"User {name} registered successfully!")
return True
Step 4: Wire It All Together¶
dioxide automatically wires dependencies based on the active profile:
from dioxide import Container, Profile
async def main():
# Production: auto-scans and activates SendGridAdapter
container = Container(profile=Profile.PRODUCTION)
user_service = container.resolve(UserService)
await user_service.register_user("alice@example.com", "Alice")
# 📧 Sends real email via SendGrid
Step 5: Test Without Mocks¶
Testing is trivial - just change the profile:
import pytest
from dioxide import Container, Profile
@pytest.fixture
def container():
"""Create test container with fakes."""
return Container(profile=Profile.TEST) # Activates FakeEmailAdapter
@pytest.mark.asyncio
async def test_register_user_sends_welcome_email(container):
"""Register user sends welcome email."""
# Arrange
user_service = container.resolve(UserService)
fake_email = container.resolve(EmailPort) # Gets FakeEmailAdapter
# Act
result = await user_service.register_user("bob@example.com", "Bob")
# Assert - check real observable outcomes (no mocks!)
assert result is True
assert len(fake_email.sent_emails) == 1
assert fake_email.sent_emails[0]["to"] == "bob@example.com"
assert fake_email.sent_emails[0]["subject"] == "Welcome to Our Service!"
assert "Bob" in fake_email.sent_emails[0]["body"]
Complete Working Example¶
Here’s the complete code you can copy and run:
"""
Complete dioxide example: User registration with email notifications.
Run with different profiles to see adapter swapping in action:
- DIOXIDE_PROFILE=production python example.py (uses SendGrid)
- DIOXIDE_PROFILE=test python example.py (uses fake)
- DIOXIDE_PROFILE=development python example.py (prints to console)
"""
import asyncio
import os
from typing import Protocol
from dioxide import Container, Profile, adapter, service
# ============================================================================
# PORTS (Interfaces)
# ============================================================================
class EmailPort(Protocol):
"""Port for email operations."""
async def send(self, to: str, subject: str, body: str) -> None:
"""Send an email."""
...
# ============================================================================
# ADAPTERS (Implementations)
# ============================================================================
@adapter.for_(EmailPort, profile=Profile.PRODUCTION)
class SendGridAdapter:
"""Production email via SendGrid."""
def __init__(self):
self.api_key = os.getenv("SENDGRID_API_KEY", "demo-key")
async def send(self, to: str, subject: str, body: str) -> None:
print(f"📧 [SendGrid] Sending email to {to}: {subject}")
# Real API call would go here
# await client.post("https://api.sendgrid.com/v3/mail/send", ...)
@adapter.for_(EmailPort, profile=Profile.TEST)
class FakeEmailAdapter:
"""Test email adapter (captures in memory)."""
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})
print(f"✅ [Fake] Email captured: {to}")
@adapter.for_(EmailPort, profile=Profile.DEVELOPMENT)
class ConsoleEmailAdapter:
"""Development email adapter (prints to console)."""
async def send(self, to: str, subject: str, body: str) -> None:
print(f"📧 [Console] Email to: {to}")
print(f" Subject: {subject}")
print(f" Body: {body[:50]}...")
# ============================================================================
# SERVICES (Business Logic)
# ============================================================================
@service
class UserService:
"""Core business logic for user operations."""
def __init__(self, email: EmailPort):
self.email = email
async def register_user(self, email_addr: str, name: str):
"""Register user and send welcome email."""
print(f"Registering user: {name} ({email_addr})")
await self.email.send(
to=email_addr,
subject="Welcome!",
body=f"Hello {name}, thanks for signing up!"
)
print(f"User {name} registered successfully!")
return True
# ============================================================================
# APPLICATION
# ============================================================================
async def main():
# Get profile from environment (defaults to development)
profile_name = os.getenv("DIOXIDE_PROFILE", "development")
profile = getattr(Profile, profile_name.upper())
print(f"Starting with profile: {profile.value}\n")
# Create container - auto-scans and activates components for profile
container = Container(profile=profile)
# Resolve and use service
user_service = container.resolve(UserService)
await user_service.register_user("alice@example.com", "Alice")
if __name__ == "__main__":
asyncio.run(main())
Run this example with different profiles:
# Development (console output)
DIOXIDE_PROFILE=development python example.py
# Test (fake adapter)
DIOXIDE_PROFILE=test python example.py
# Production (SendGrid)
export SENDGRID_API_KEY="your-api-key"
DIOXIDE_PROFILE=production python example.py
Key Concepts Explained¶
Ports¶
Ports are interfaces defined using Python’s Protocol class. They define what operations you need without specifying how they’re implemented.
from typing import Protocol
class DatabasePort(Protocol):
"""Port for database operations."""
async def save_user(self, user: dict) -> int:
"""Save user to database, return user ID."""
...
async def get_user(self, user_id: int) -> dict | None:
"""Get user by ID, return None if not found."""
...
Why ports?
Define clear boundaries (seams) in your architecture
Business logic depends on ports, not concrete implementations
Easy to swap implementations (PostgreSQL → SQLite → in-memory)
Protocols provide type safety (mypy validates implementations)
Adapters¶
Adapters are concrete implementations of ports for specific environments.
from dioxide import adapter, Profile
@adapter.for_(DatabasePort, profile=Profile.PRODUCTION)
class PostgresAdapter:
"""Production database using PostgreSQL."""
def __init__(self):
self.connection_string = "postgresql://..."
async def save_user(self, user: dict) -> int:
# Real PostgreSQL implementation
pass
async def get_user(self, user_id: int) -> dict | None:
# Real PostgreSQL query
pass
@adapter.for_(DatabasePort, profile=Profile.TEST)
class InMemoryAdapter:
"""Test database using in-memory dictionary."""
def __init__(self):
self.users = {}
self.next_id = 1
async def save_user(self, user: dict) -> int:
user_id = self.next_id
self.users[user_id] = user
self.next_id += 1
return user_id
async def get_user(self, user_id: int) -> dict | None:
return self.users.get(user_id)
Key points:
One port can have multiple adapters (one per profile)
Adapters are singletons by default (one instance per container)
Container activates the correct adapter based on profile
Services¶
Services contain core business logic and depend on ports:
from dioxide import service
@service
class UserService:
"""Core business logic."""
def __init__(self, db: DatabasePort, email: EmailPort):
# Depends on PORTS, not adapters!
self.db = db
self.email = email
async def register_user(self, email: str, name: str):
# Pure business logic
user = {"email": email, "name": name}
user_id = await self.db.save_user(user)
await self.email.send(email, "Welcome!", f"Hello {name}!")
return user_id
Key points:
Services are always singletons (one instance per container)
Available in ALL profiles (doesn’t vary by environment)
Dependencies auto-injected via constructor type hints
Zero knowledge of which adapters are active
Profiles¶
Profiles control which adapters are active for a given environment:
from dioxide import Profile
# Standard profiles
Profile.PRODUCTION # Real implementations (PostgreSQL, SendGrid, AWS)
Profile.TEST # Fast fakes for testing (in-memory, fake email)
Profile.DEVELOPMENT # Dev-friendly (SQLite, console logging)
Profile.STAGING # Pre-production environment
Profile.CI # CI/CD pipelines
Profile.ALL # Available in all profiles
Activation:
from dioxide import Container, Profile
# Production - auto-scans and activates production adapters
prod_container = Container(profile=Profile.PRODUCTION)
# Activates: PostgresAdapter, SendGridAdapter, etc.
# Testing - auto-scans and activates test fakes
test_container = Container(profile=Profile.TEST)
# Activates: InMemoryAdapter, FakeEmailAdapter, etc.
Container¶
The Container is dioxide’s dependency injection engine:
from dioxide import Container, Profile
# Create container with profile (auto-scans for components)
container = Container(profile=Profile.PRODUCTION)
# Resolve dependencies
user_service = container.resolve(UserService)
# UserService auto-injected with production adapters
# Alternative syntax
user_service = container[UserService]
How it works:
Container(profile=...)auto-scans for all@adapterand@servicedecoratorsActivates adapters matching the profile
Builds dependency graph from constructor type hints
container.resolve(Type)walks graph and injects dependenciesSingletons cached (one instance per type per container)
Note
For more control, you can use explicit scanning with custom package paths:
container = Container()
container.scan(package="myapp.services", profile=Profile.PRODUCTION)
container.scan(package="myapp.adapters", profile=Profile.PRODUCTION)
Next Steps¶
Now that you understand the basics, explore:
Hexagonal Architecture - Deep dive into ports-and-adapters pattern
Profiles - Advanced profile configuration and custom profiles
Lifecycle Management - Initialize and cleanup resources with
@lifecycleScoping - Isolate dependencies per request, background task, or CLI command
Testing with Fakes - Best practices for testing without mocks
Framework Integration - Use dioxide with FastAPI, Flask, Django
See also
Hexagonal Architecture with dioxide - Complete guide to ports-and-adapters
Understanding @service vs @adapter - Understanding when to use each decorator
Scoping Guide - Request scoping and bounded contexts
Lifecycle Methods: Async/Sync Patterns - Async resource management
Testing with Fakes - Testing philosophy and patterns
FastAPI Integration - FastAPI integration example
Getting Help¶
GitHub Issues: https://github.com/mikelane/dioxide/issues
Discussions: https://github.com/mikelane/dioxide/discussions
Documentation: https://dioxide.readthedocs.io
Common Questions¶
Q: Do I need to use Rust?
No! dioxide is a Python package. The Rust backend is compiled into binary wheels, so you just pip install dioxide like any other package.
Q: Can I use regular Python classes instead of Protocols?
Yes! Ports can be Protocols or ABC (Abstract Base Classes). Protocols are preferred for structural typing, but ABCs work too.
Q: What if I don’t want hexagonal architecture?
dioxide is designed for hexagonal architecture (ports-and-adapters). If you don’t need that pattern, simpler DI frameworks might be better fit.
Q: How do I debug which adapter is active?
# Resolve the port to see which adapter is active
email_adapter = container.resolve(EmailPort)
print(type(email_adapter)) # <class 'SendGridAdapter'>
Q: Can I have multiple containers?
Yes! Each Container() instance is independent with its own singletons and active profile.
Q: Is dioxide production-ready?
dioxide is stable (v2.0.1 as of Jan 2025). The API is frozen and production-ready.