Architecture Diagrams¶
This page provides visual explanations of dioxide’s hexagonal architecture patterns through comprehensive Mermaid diagrams. These diagrams illustrate how dioxide enables clean architecture through ports, adapters, profiles, and lifecycle management.
The Golden Path¶
Before diving into detailed diagrams, here’s the core mental model in one picture:
┌─────────────────┐
│ @service │
│ UserService │
│ (business │
│ logic) │
└────────┬────────┘
│
│ depends on
▼
┌─────────────────┐
│ Port │
│ (Protocol) │
│ EmailPort │
└────────┬────────┘
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ @adapter │ │ @adapter │ │ @adapter │
│ PRODUCTION │ │ TEST │ │ DEVELOPMENT │
│ SendGrid │ │ FakeEmail │ │ ConsoleEmail │
└───────────────┘ └───────────────┘ └───────────────┘
This is the Dependency Inversion Principle in action: your business logic (@service) depends
on abstractions (Ports/Protocols), not concrete implementations. Adapters implement those ports
for different environments. At runtime, dioxide wires the correct adapter based on the active
profile - your service code never changes, only the adapters do.
Hexagonal Architecture Overview¶
The hexagonal architecture (also known as ports-and-adapters) places your core business logic at the center, surrounded by ports that define interfaces, with adapters plugging into those ports from the outside. This creates natural seams for testing and implementation swapping.
flowchart TB
subgraph EXTERNAL["External Systems"]
direction TB
DB[(PostgreSQL)]
API[SendGrid API]
FS[File System]
CACHE[(Redis)]
end
subgraph ADAPTERS["Adapters Layer"]
direction TB
subgraph PROD_ADAPTERS["Production Adapters"]
PGA[PostgresUserRepository]
SGA[SendGridEmailAdapter]
FSA[S3StorageAdapter]
RCA[RedisCacheAdapter]
end
subgraph TEST_ADAPTERS["Test Adapters"]
FUR[FakeUserRepository]
FEA[FakeEmailAdapter]
FFS[FakeStorageAdapter]
FCA[FakeCacheAdapter]
end
end
subgraph PORTS["Ports Layer"]
direction TB
UP{{UserRepository}}
EP{{EmailPort}}
SP{{StoragePort}}
CP{{CachePort}}
end
subgraph CORE["Core Domain"]
direction TB
US[UserService]
NS[NotificationService]
OS[OrderService]
end
%% External to Production Adapters
DB --- PGA
API --- SGA
FS --- FSA
CACHE --- RCA
%% Production Adapters to Ports
PGA --> UP
SGA --> EP
FSA --> SP
RCA --> CP
%% Test Adapters to Ports
FUR --> UP
FEA --> EP
FFS --> SP
FCA --> CP
%% Core depends on Ports
US --> UP
US --> EP
NS --> EP
NS --> UP
OS --> UP
OS --> SP
OS --> CP
Key Concepts:
Core Domain (center): Business logic in
@serviceclasses that depend only on portsPorts Layer: Python
Protocolclasses defining interfaces (no decorators needed)Adapters Layer: Concrete implementations with
@adapter.for_(Port, profile=...)decoratorsExternal Systems: Real databases, APIs, and services that production adapters connect to
The core domain never knows which adapter is active - it only sees the port interface. This enables testing with fast fakes and easy implementation swapping.
Profile-Based Adapter Selection¶
dioxide uses profiles to determine which adapter implementation is active for each port. When you scan with a specific profile, only adapters matching that profile are activated.
flowchart TB
subgraph REGISTRATION["Adapter Registration (Decoration Time)"]
direction LR
A1["@adapter.for_(EmailPort, profile=Profile.PRODUCTION)
class SendGridAdapter"]
A2["@adapter.for_(EmailPort, profile=Profile.TEST)
class FakeEmailAdapter"]
A3["@adapter.for_(EmailPort, profile=Profile.DEVELOPMENT)
class ConsoleEmailAdapter"]
end
subgraph REGISTRY["Global Registry"]
direction TB
REG[("Adapter Registry
EmailPort:
- PRODUCTION: SendGridAdapter
- TEST: FakeEmailAdapter
- DEVELOPMENT: ConsoleEmailAdapter")]
end
subgraph SCANNING["Container Creation (Auto-Scans)"]
direction TB
SCAN_PROD["Container(profile=Profile.PRODUCTION)"]
SCAN_TEST["Container(profile=Profile.TEST)"]
SCAN_DEV["Container(profile=Profile.DEVELOPMENT)"]
end
subgraph ACTIVATION["Active Adapters"]
direction TB
ACT_PROD["EmailPort -> SendGridAdapter"]
ACT_TEST["EmailPort -> FakeEmailAdapter"]
ACT_DEV["EmailPort -> ConsoleEmailAdapter"]
end
subgraph RESOLUTION["Resolution"]
direction TB
RES["container.resolve(EmailPort)
Returns active adapter for current profile"]
end
A1 --> REG
A2 --> REG
A3 --> REG
REG --> SCAN_PROD
REG --> SCAN_TEST
REG --> SCAN_DEV
SCAN_PROD --> ACT_PROD
SCAN_TEST --> ACT_TEST
SCAN_DEV --> ACT_DEV
ACT_PROD --> RES
ACT_TEST --> RES
ACT_DEV --> RES
How Profile Selection Works:
Registration: When Python loads your modules,
@adapter.for_()decorators register each adapter class in a global registry, associated with its port and profile(s)Scanning: When you create a container with
Container(profile=...), the container automatically:Discovers all registered adapters
Filters to only those matching the active profile
Registers them as providers for their respective ports
Resolution: When you call
container.resolve(Port), the container returns the adapter instance registered for that port in the current profile
Multiple Profiles: An adapter can be registered for multiple profiles:
@adapter.for_(EmailPort, profile=[Profile.TEST, Profile.DEVELOPMENT])
class SimpleEmailAdapter:
"""Available in both TEST and DEVELOPMENT profiles."""
pass
Dependency Resolution Flow¶
When you call container.resolve(UserService), dioxide performs dependency resolution
by inspecting constructor type hints and recursively resolving dependencies.
sequenceDiagram
participant App as Application
participant Container as Container
participant Registry as Provider Registry
participant Cache as Singleton Cache
App->>Container: resolve(UserService)
Container->>Registry: lookup(UserService)
Registry-->>Container: ServiceProvider(UserService)
Container->>Container: inspect __init__ type hints
Note over Container: UserService.__init__(self, db: UserRepository, email: EmailPort)
Container->>Container: resolve(UserRepository)
Container->>Registry: lookup(UserRepository)
Registry-->>Container: AdapterProvider(PostgresUserRepository)
Container->>Cache: check cache(PostgresUserRepository)
Cache-->>Container: not cached
Container->>Container: create PostgresUserRepository()
Container->>Cache: store(PostgresUserRepository, instance)
Container-->>Container: PostgresUserRepository instance
Container->>Container: resolve(EmailPort)
Container->>Registry: lookup(EmailPort)
Registry-->>Container: AdapterProvider(SendGridAdapter)
Container->>Cache: check cache(SendGridAdapter)
Cache-->>Container: not cached
Container->>Container: create SendGridAdapter()
Container->>Cache: store(SendGridAdapter, instance)
Container-->>Container: SendGridAdapter instance
Container->>Container: create UserService(db, email)
Container->>Cache: store(UserService, instance)
Container-->>App: UserService instance
Resolution Steps:
Lookup Provider: Container finds the registered provider for the requested type
Inspect Dependencies: Container reads
__init__type hints to discover dependenciesRecursive Resolution: Each dependency is resolved recursively (depth-first)
Singleton Caching: By default, instances are cached (singleton scope):
First resolution creates the instance
Subsequent resolutions return the cached instance
Dependency Injection: Constructor is called with all resolved dependencies
Circular Dependency Detection: If A depends on B and B depends on A, dioxide
detects this at scan() time and raises a clear error before any resolution occurs.
Lifecycle Initialization Order¶
When using @lifecycle decorated components, dioxide initializes them in dependency order
and disposes them in reverse order. This ensures dependencies are ready before dependents
and cleaned up after dependents.
sequenceDiagram
participant App as Application
participant Container as Container
participant Graph as Dependency Graph
participant Config as AppConfig
participant DB as Database
participant Cache as CacheService
participant User as UserService
Note over App,User: Dependency Order: AppConfig -> Database -> CacheService -> UserService
App->>Container: async with container:
Container->>Graph: topological_sort(lifecycle_components)
Graph-->>Container: [AppConfig, Database, CacheService, UserService]
rect rgb(230, 245, 230)
Note over Container,User: Initialization Phase (dependency order)
Container->>Config: initialize()
Config-->>Container: ready
Container->>DB: initialize()
Note over DB: Connects to PostgreSQL
DB-->>Container: ready
Container->>Cache: initialize()
Note over Cache: Connects to Redis
Cache-->>Container: ready
Container->>User: initialize()
Note over User: All dependencies ready
User-->>Container: ready
end
Container-->>App: context entered
Note over App: Application runs...
App->>Container: resolve(UserService)
Container-->>App: UserService (already initialized)
App->>Container: exit context
rect rgb(245, 230, 230)
Note over Container,User: Disposal Phase (reverse dependency order)
Container->>User: dispose()
User-->>Container: disposed
Container->>Cache: dispose()
Note over Cache: Disconnects from Redis
Cache-->>Container: disposed
Container->>DB: dispose()
Note over DB: Closes PostgreSQL connection
DB-->>Container: disposed
Container->>Config: dispose()
Config-->>Container: disposed
end
Container-->>App: context exited
Lifecycle Management:
Topological Sort: Container builds a dependency graph and sorts components so that dependencies come before dependents (using Kahn’s algorithm)
Initialization: Components are initialized in dependency order:
AppConfigfirst (no dependencies)Databasesecond (depends on AppConfig)CacheServicethird (depends on AppConfig)UserServicelast (depends on Database and CacheService)
Disposal: Components are disposed in reverse dependency order:
UserServicefirst (so it can still use Database/Cache during cleanup)CacheServiceandDatabasenextAppConfiglast
Usage with Context Manager:
from dioxide import Container, Profile
async with Container(profile=Profile.PRODUCTION) as container:
# All @lifecycle components are initialized here
service = container.resolve(UserService)
await service.do_something()
# All @lifecycle components are disposed here (reverse order)
The Container(profile=...) constructor accepts both Profile enum values and string profiles,
and automatically triggers scanning when created.
Testing with Fakes¶
dioxide’s architecture enables testing with fast, deterministic fakes instead of mocks. The profile system makes swapping between production and test implementations trivial.
flowchart TB
subgraph PRODUCTION["Production Environment"]
direction TB
subgraph PROD_CONTAINER["Container(profile=PRODUCTION)"]
PUS[UserService]
PNS[NotificationService]
end
subgraph PROD_ADAPTERS["Production Adapters"]
PPG[(PostgreSQL)]
PSG[SendGrid API]
PRC[(Redis)]
end
PUS --> PPG
PUS --> PSG
PNS --> PSG
PNS --> PRC
end
subgraph TESTING["Test Environment"]
direction TB
subgraph TEST_CONTAINER["Container(profile=TEST)"]
TUS[UserService]
TNS[NotificationService]
end
subgraph TEST_FAKES["Fast Fakes (In-Memory)"]
TFU["FakeUserRepository
users: dict[int, User]
+ seed(*users)
+ clear()"]
TFE["FakeEmailAdapter
sent_emails: list[dict]
+ verify_sent_to(email)
+ clear()"]
TFC["FakeCacheAdapter
cache: dict[str, Any]
+ clear()"]
end
TUS --> TFU
TUS --> TFE
TNS --> TFE
TNS --> TFC
end
subgraph TEST_CODE["Test Code"]
direction TB
ARRANGE["Arrange:
fake_users.seed(User(id=1, email='alice@example.com'))
fake_clock.set_time(datetime(2024, 1, 1))"]
ACT["Act:
result = await service.register_user('Alice', 'alice@example.com')"]
ASSERT["Assert:
assert result['email'] == 'alice@example.com'
assert fake_email.verify_sent_to('alice@example.com')
assert len(fake_email.sent_emails) == 1"]
end
TEST_CONTAINER --> TEST_CODE
ARRANGE --> ACT
ACT --> ASSERT
Testing Philosophy:
Same Service Code:
UserServiceis identical in production and test - only adapters differFast Fakes: Test adapters are simple in-memory implementations:
FakeUserRepository: Dict-based storage withseed()helperFakeEmailAdapter: Captures sent emails in a list for verificationFakeClock: Controllable time for testing time-dependent logic
No Mocking: Instead of
@patchandMock(), use real fake implementations:Fakes run actual code paths
No brittle mock configurations
Tests verify behavior, not implementation
Natural Verification: Check fake state directly:
# Instead of: mock_email.send.assert_called_once_with(...) assert len(fake_email.sent_emails) == 1 assert fake_email.sent_emails[0]["to"] == "alice@example.com"
Test Fixture Pattern:
import pytest
from dioxide import Container, Profile
@pytest.fixture
def container():
"""Fresh container with test fakes for each test."""
return Container(profile=Profile.TEST)
@pytest.fixture
def fake_email(container):
"""Get the fake email adapter."""
return container.resolve(EmailPort)
@pytest.fixture
def fake_users(container):
"""Get the fake user repository."""
return container.resolve(UserRepository)
async def test_welcome_email_sent(container, fake_email, fake_users):
"""Sends welcome email when user registers."""
# Arrange
fake_users.seed(User(id=1, email="alice@example.com", name="Alice"))
# Act
service = container.resolve(UserService)
await service.send_welcome_email(user_id=1)
# Assert
assert fake_email.verify_sent_to("alice@example.com")
assert "Welcome" in fake_email.sent_emails[0]["subject"]
Summary¶
These diagrams illustrate dioxide’s core architectural patterns:
Pattern |
Purpose |
Key Benefit |
|---|---|---|
Hexagonal Architecture |
Separate core logic from external systems |
Testability and flexibility |
Profile-Based Adapters |
Different implementations per environment |
Easy environment configuration |
Dependency Resolution |
Automatic constructor injection |
Zero-ceremony DI |
Lifecycle Management |
Ordered initialization and cleanup |
Resource safety |
Testing with Fakes |
Fast, deterministic test doubles |
No mocking frameworks needed |
For more details:
Hexagonal Architecture Guide - Detailed patterns and examples
Testing with Fakes - Comprehensive testing philosophy
API Reference - Full API documentation