Ports
In Portico's hexagonal architecture, ports are the stable, framework-free interfaces that define the contract between your business logic and the outside world. They are pure domain abstractions - no implementation details, no external dependencies, just clear interfaces that express what your application needs.
What is a Port?
A port is an abstract interface that defines a capability your application requires without specifying how that capability is implemented. Think of ports as the "shape" of a dependency - they describe what operations are available and what data flows in and out, but not where that data comes from or how the operations work internally.
In traditional applications, you might write code like this:
# Direct dependency on implementation
from redis import Redis
class UserService:
def __init__(self):
self.cache = Redis(host='localhost') # Coupled to Redis!
async def get_user(self, user_id: str):
cached = self.cache.get(f"user:{user_id}")
if cached:
return json.loads(cached)
# ... fetch from database
With ports, you instead depend on an abstract interface:
# Dependency on interface (port)
from portico.ports.cache import CacheAdapter
class UserService:
def __init__(self, cache: CacheAdapter): # Depends on port!
self.cache = cache
async def get_user(self, user_id: str):
cache_key = CacheKey(key=f"user:{user_id}")
cached = await self.cache.get(cache_key)
if cached:
return cached.value
# ... fetch from database
The second example depends on CacheAdapter (a port), not Redis (an implementation). This means:
- You can swap Redis for Memcached without changing
UserService - You can test with an in-memory cache without running Redis
- Your business logic stays clean and focused on domain concerns
Port Anatomy
Every Portico port typically contains three types of components:
1. Domain Models
These are the core entities and value objects that represent concepts in your domain. They're built with Pydantic for validation and serialization:
from pydantic import BaseModel, Field
from uuid import UUID, uuid4
from datetime import datetime
class User(BaseModel):
"""Domain model representing a user in the system."""
id: UUID = Field(default_factory=uuid4)
username: str
email: str
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
# Domain methods that express business logic
def is_active(self) -> bool:
"""Check if user account is active."""
return self.status == UserStatus.ACTIVE
Domain models are frozen in time - they represent a snapshot of data at a point in time. They can have domain methods that express business logic, but they don't perform I/O operations.
2. Request/Response Models
These define the shape of data flowing into and out of operations:
class CreateUserRequest(BaseModel):
"""Request model for creating a new user."""
username: str = Field(min_length=3, max_length=50)
email: str = Field(pattern=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
password: str = Field(min_length=8)
# Pydantic validators ensure data quality
@field_validator('username')
def username_must_be_alphanumeric(cls, v):
if not v.isalnum():
raise ValueError('Username must be alphanumeric')
return v
These models use Pydantic's validation to ensure data integrity at the boundary of your system.
3. Abstract Interfaces
These define the operations available for a particular capability:
from abc import ABC, abstractmethod
from typing import Optional
class UserRepository(ABC):
"""Port defining user persistence operations."""
@abstractmethod
async def create(self, request: CreateUserRequest) -> User:
"""Create a new user.
Args:
request: User creation data
Returns:
Created user with generated ID and timestamps
"""
pass
@abstractmethod
async def get_by_id(self, user_id: UUID) -> Optional[User]:
"""Retrieve user by ID.
Args:
user_id: Unique user identifier
Returns:
User if found, None otherwise
"""
pass
Interfaces use Python's ABC (Abstract Base Class) to enforce that implementations provide all required methods.
Port Types
Portico uses semantic naming conventions to indicate what kind of operations a port provides:
Repository
Pattern: {Entity}Repository
Purpose: Persistence and retrieval of domain entities
Examples: UserRepository, GroupRepository, PermissionRepository
Repositories abstract database operations. They provide CRUD (Create, Read, Update, Delete) operations and domain-specific queries:
class UserRepository(ABC):
@abstractmethod
async def create(self, request: CreateUserRequest) -> User:
"""Persist a new user."""
pass
@abstractmethod
async def get_by_email(self, email: str) -> Optional[User]:
"""Find user by email address."""
pass
@abstractmethod
async def update(self, user_id: UUID, request: UpdateUserRequest) -> User:
"""Update existing user."""
pass
Provider
Pattern: {Capability}Provider
Purpose: External service integration for computational operations
Examples: ChatCompletionProvider, EmbeddingProvider
Providers integrate with external services, typically for AI/ML capabilities:
class ChatCompletionProvider(ABC):
@abstractmethod
async def complete(
self,
request: ChatCompletionRequest
) -> ChatCompletionResponse:
"""Generate chat completion using an LLM service."""
pass
Adapter
Pattern: {Capability}Adapter
Purpose: Technology-specific integration for infrastructure
Examples: CacheAdapter, FileStorageAdapter, AuditAdapter, NotificationAdapter
Adapters integrate with infrastructure services like caching, storage, logging, and notifications:
class CacheAdapter(ABC):
@abstractmethod
async def get(self, key: CacheKey) -> Optional[CacheEntry]:
"""Retrieve cached value."""
pass
@abstractmethod
async def set(self, key: CacheKey, value: Any, ttl: Optional[int] = None) -> None:
"""Store value in cache with optional TTL."""
pass
Note
Don't confuse "adapter" in the port name (like CacheAdapter) with the architectural concept of adapters. CacheAdapter is a port (interface). The adapter (implementation) would be something like RedisCacheAdapter or MemoryCacheAdapter.
Registry
Pattern: {Entity}Registry
Purpose: Registration and lookup of configured entities
Examples: TemplateRegistry, SettingsRegistry
Registries manage collections of configured items, typically loaded at startup:
class TemplateRegistry(ABC):
@abstractmethod
async def register(self, template: Template) -> None:
"""Register a template for later use."""
pass
@abstractmethod
async def get_by_name(self, name: str) -> Optional[Template]:
"""Retrieve registered template by name."""
pass
Storage
Pattern: {Capability}Storage
Purpose: Low-level data persistence (typically non-relational)
Examples: VectorStore
Storage ports define persistence operations for specific data structures, typically for specialized data types like vectors.
Processor
Pattern: {Operation}Processor
Purpose: Data transformation and analysis
Examples: DocumentProcessor
Processors transform data from one format to another or extract structured information:
class DocumentProcessor(ABC):
@abstractmethod
async def process_document(
self,
content: DocumentContent
) -> ProcessedDocument:
"""Process raw document into structured chunks."""
pass
Port Categories by Domain
Portico's ports can be grouped by the business domain they serve:
User & Access Management
Ports for managing users, groups, permissions, and authentication:
- User Port (
user.py) - User CRUD, authentication, role management - Group Port (
group.py) - Organizational hierarchies and group membership - Permissions Port (
permissions.py) - Role-based access control (RBAC)
Common Pattern: These ports often work together. For example, authentication uses the User port to verify credentials and manage user sessions through the auth kit.
Infrastructure Services
Ports for external infrastructure like caching, storage, and notifications:
- Cache Port (
cache.py) - High-performance caching with TTL and tag-based invalidation - File Storage Port (
file_storage.py) - File upload, download, and metadata management - Notification Port (
notification.py) - Email and SMS delivery with template support - Audit Port (
audit.py) - Activity logging and compliance tracking
Common Pattern: These are typically used as cross-cutting concerns. Kits inject these adapters to add caching, notifications, or audit logging to business operations.
AI & Machine Learning
Ports for integrating with LLM and vector/embedding services:
- LLM Port (
llm.py) - Chat completions, conversations, and prompt management - Embedding Port (
embedding.py) - Text vectorization for semantic search - Vector Store Port (
vector_store.py) - Vector similarity search and document retrieval - Document Processor Port (
document_processor.py) - Document chunking and analysis - Managed RAG Port (
managed_rag.py) - Integrated RAG platforms (Graphlit, etc.)
Common Pattern: RAG (Retrieval-Augmented Generation) workflows chain these together: documents are processed into chunks, chunks are embedded, embeddings are stored in a vector store, and retrieved chunks augment LLM prompts.
Background Processing
Ports for asynchronous job processing and scheduling:
- Job Port (
job.py) - Job lifecycle and status tracking (domain models) - Job Queue Port (
job_queue.py) - Queue operations (enqueue, dequeue, acknowledge) - Job Handler Port (
job_handler.py) - Business logic for processing jobs - Job Trigger Port (
job_trigger.py) - Event sources that create jobs (cron, webhooks, etc.) - Job Creator Port (
job_creator.py) - Interface for creating jobs (used by triggers)
Common Pattern: Triggers detect events and use the Job Creator port to enqueue jobs. Jobs are pulled from the queue and dispatched to the appropriate handler based on job_type.
Configuration & Templates
Ports for managing application configuration and templating:
- Template Port (
template.py) - Jinja2 template registry and rendering - Settings Port (
settings.py) - Application configuration from multiple sources - Config Schema Port (
config.py) - Declarative configuration schema building
Common Pattern: Templates are used throughout Portico - for LLM prompts, notification emails, and HTML rendering. Settings provide runtime configuration.
Organization & Structure
Ports for representing organizational hierarchies and permissions:
- Organization Port (
organization.py) - Hierarchical organization models and permission matrices
Common Pattern: This port provides read models (data structures) for visualizing org charts and permission reports, typically used by organization management kits.
Common Design Patterns
Async-First
Nearly all port operations are async to support high-concurrency web applications:
class UserRepository(ABC):
@abstractmethod
async def create(self, request: CreateUserRequest) -> User:
"""Async allows non-blocking I/O."""
pass
Even if your initial adapter implementation is synchronous (like SQLite), defining the port as async allows you to swap in async implementations (like PostgreSQL with asyncpg) without changing business logic.
Pydantic Models for Validation
All data crossing port boundaries uses Pydantic models:
class CreateUserRequest(BaseModel):
username: str = Field(min_length=3, max_length=50)
email: str = Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")
@field_validator('email')
def normalize_email(cls, v):
return v.lower().strip()
This ensures:
- Data integrity: Invalid data is rejected at the boundary
- Documentation: Field types and constraints are self-documenting
- Serialization: Easy conversion to/from JSON, dict, etc.
Optional User Ownership
Many domain models support optional user ownership for multi-tenant scenarios:
class Template(BaseModel):
id: UUID
name: str
content: str
user_id: Optional[UUID] = None # If None, template is global
is_public: bool = False # If True, visible to all users
This pattern allows:
- Global resources:
user_id = Nonefor system-wide templates - User-owned resources:
user_id = <uuid>for user-specific items - Sharing:
is_public = Trueto share user-owned resources
Metadata Fields for Extensibility
Most domain models include a metadata: Dict[str, Any] field:
class FileMetadata(BaseModel):
id: UUID
filename: str
content_type: str
size_bytes: int
metadata: Dict[str, Any] = Field(default_factory=dict) # Extensible!
This allows you to attach custom data without modifying the port schema.
Pagination Support
List operations typically support limit/offset pagination:
class UserRepository(ABC):
@abstractmethod
async def list_users(
self,
limit: int = 100,
offset: int = 0
) -> list[User]:
"""Retrieve paginated user list."""
pass
Namespace Isolation
Infrastructure ports often support namespaces for multi-tenancy:
class VectorStoreConfig(BaseModel):
namespace: Optional[str] = None # Isolate by tenant
# Each tenant's data is isolated
tenant_a_store = VectorStore(config=VectorStoreConfig(namespace="tenant_a"))
tenant_b_store = VectorStore(config=VectorStoreConfig(namespace="tenant_b"))
How Ports Relate to Adapters and Kits
Ports sit at the center of Portico's hexagonal architecture:
┌─────────────────────────────────────────┐
│ Kits (Business Logic) │
│ - Depend on PORTS (interfaces) │
│ - Never import adapters directly │
└─────────────────┬───────────────────────┘
│
│ Uses
↓
┌─────────────────────────────────────────┐
│ Ports (Interfaces) │
│ - Define what operations exist │
│ - Domain models, enums, requests │
│ - No implementation, no external deps │
└─────────────────┬───────────────────────┘
↑
│ Implements
│
┌─────────────────────────────────────────┐
│ Adapters (Implementations) │
│ - Implement port interfaces │
│ - Integrate with external services │
│ - Can import SDKs, databases, etc. │
└─────────────────────────────────────────┘
Ports Define the Contract
# portico/ports/cache.py
from abc import ABC, abstractmethod
class CacheAdapter(ABC):
"""Port: defines WHAT caching operations are available."""
@abstractmethod
async def get(self, key: CacheKey) -> Optional[CacheEntry]:
pass
@abstractmethod
async def set(self, key: CacheKey, value: Any, ttl: Optional[int]) -> None:
pass
Adapters Implement the Contract
# portico/adapters/cache/redis_adapter.py
from redis.asyncio import Redis
from portico.ports.cache import CacheAdapter
class RedisCacheAdapter(CacheAdapter):
"""Adapter: implements HOW caching works with Redis."""
def __init__(self, redis_url: str):
self.redis = Redis.from_url(redis_url)
async def get(self, key: CacheKey) -> Optional[CacheEntry]:
value = await self.redis.get(key.key)
if value:
return CacheEntry(value=json.loads(value))
return None
async def set(self, key: CacheKey, value: Any, ttl: Optional[int]) -> None:
await self.redis.set(key.key, json.dumps(value), ex=ttl)
Kits Use the Port
# portico/kits/user/service.py
from portico.ports.user import UserRepository
from portico.ports.cache import CacheAdapter
class UserService:
"""Kit: uses ports to implement business logic."""
def __init__(
self,
user_repository: UserRepository, # Port dependency!
cache: CacheAdapter # Port dependency!
):
self.users = user_repository
self.cache = cache
async def get_user(self, user_id: UUID) -> Optional[User]:
# Try cache first
cache_key = CacheKey(key=f"user:{user_id}")
cached = await self.cache.get(cache_key)
if cached:
return User(**cached.value)
# Cache miss - fetch from repository
user = await self.users.get_by_id(user_id)
if user:
await self.cache.set(cache_key, user.dict(), ttl=300)
return user
Notice how UserService has no idea whether it's using Redis or Memcached for caching, or PostgreSQL or SQLite for persistence. It only knows the port interfaces.
Composition Root Wires It Together
# portico/compose.py
def cache(**config):
"""Factory function that creates cache kit with chosen adapter."""
from portico.adapters.cache import RedisCacheAdapter
from portico.kits.cache import CacheKit
def factory(database, events):
# THIS is where the adapter is chosen
adapter = RedisCacheAdapter(redis_url=config["redis_url"])
return CacheKit.create(database, events, config, adapter)
return factory
The compose.py module is the only place in Portico where adapters are imported and instantiated. This enforces clean architecture - kits can never accidentally import adapters.
How to Use Ports
When Writing Business Logic (Kits)
Import and depend on ports, never adapters:
# ✅ CORRECT - Import port
from portico.ports.user import UserRepository, CreateUserRequest
class SignupService:
def __init__(self, users: UserRepository): # Depend on interface
self.users = users
async def signup(self, username: str, email: str, password: str):
request = CreateUserRequest(
username=username,
email=email,
password=password
)
return await self.users.create(request)
# ❌ WRONG - Import adapter
from portico.adapters.storage.postgres import PostgresUserRepository # NO!
class SignupService:
def __init__(self):
self.users = PostgresUserRepository() # Couples to PostgreSQL!
When Implementing a Custom Adapter
Implement the port interface:
from portico.ports.cache import CacheAdapter, CacheKey, CacheEntry
from typing import Optional, Any
import memcache
class MemcachedAdapter(CacheAdapter):
"""Custom adapter implementing the CacheAdapter port."""
def __init__(self, servers: list[str]):
self.client = memcache.Client(servers)
async def get(self, key: CacheKey) -> Optional[CacheEntry]:
value = self.client.get(key.key)
if value:
return CacheEntry(value=value, key=key.key)
return None
async def set(self, key: CacheKey, value: Any, ttl: Optional[int] = None) -> None:
self.client.set(key.key, value, time=ttl or 0)
# ... implement other required methods
When Testing
Use fake/mock implementations of ports:
from portico.ports.user import UserRepository, User, CreateUserRequest
from uuid import uuid4
class FakeUserRepository(UserRepository):
"""In-memory fake for testing."""
def __init__(self):
self.users = {}
async def create(self, request: CreateUserRequest) -> User:
user = User(
id=uuid4(),
username=request.username,
email=request.email
)
self.users[user.id] = user
return user
async def get_by_id(self, user_id: UUID) -> Optional[User]:
return self.users.get(user_id)
# Now test your service without real database
def test_signup():
fake_users = FakeUserRepository()
service = SignupService(users=fake_users)
user = await service.signup("alice", "alice@example.com", "secret123")
assert user.username == "alice"
Summary
Ports are the foundation of Portico's clean architecture:
- Ports are interfaces - They define what operations exist without specifying how they work
- Ports contain domain models - Pydantic models represent your business entities
- Ports are technology-agnostic - No databases, no SDKs, no implementation details
- Ports enable testing - Mock implementations for fast, isolated tests
- Ports enable flexibility - Swap implementations without changing business logic
When building with Portico:
- Depend on ports in your business logic (kits)
- Implement ports when creating custom adapters
- Never import adapters directly - let the composition root wire dependencies
Understanding ports is essential to working effectively with Portico's hexagonal architecture. They are the contracts that keep your codebase clean, testable, and maintainable as your application grows.