Hexagonal Architecture: Ports & Adapters
Cascade Platform uses the Hexagonal Architecture pattern to keep the business logic independent of technology choices.
The Core Insight
Instead of this (❌ tightly coupled):
┌────────────────────────────────────┐
│ Business Logic │
├────────────────────────────────────┤
│ • Direct PostgreSQL calls │
│ • OPA policy evaluation │
│ • Appsmith UI rendering │
│ • Temporal workflow calls │
└────────────────────────────────────┘
(Hard to test, hard to swap technologies)Cascade does this (✅ loosely coupled):
┌─────────────────────┐ ┌──────────────────────────┐
│ Kernel/Core │ │ Adapters │
│ (Business Logic) │ │ (Technology) │
├─────────────────────┤ ├──────────────────────────┤
│ • Workflows │ ┌──→│ PostgreSQL (Storage) │
│ • State machines │ │ ├──────────────────────────┤
│ • Decisions │ Ports │ OPA (Policy) │
│ • Activities │ │ ├──────────────────────────┤
│ │ └──→│ Appsmith (UI) │
│ (No external deps) │ ├──────────────────────────┤
└─────────────────────┘ │ NATS (Events) │
│ ... 13 total │
└──────────────────────────┘
(Easy to test, easy to swap)How It Works
Ports (Interfaces)
A port is a Go interface that represents something the kernel needs:
// internal/ports/storage.go
type StoragePort interface {
Query(ctx context.Context, sql string) ([]map[string]interface{}, error)
Execute(ctx context.Context, sql string, args ...interface{}) error
BeginTransaction(ctx context.Context) (Transaction, error)
}
// internal/ports/policy.go
type PolicyPort interface {
Evaluate(ctx context.Context, policyURN string, input map[string]interface{}) (map[string]interface{}, error)
}
// internal/ports/ui_renderer.go
type UIRendererPort interface {
RenderUI(ctx context.Context, req RenderUIRequest) (RenderUIResponse, error)
SupportsUIType(uiType string) bool
}Adapters (Implementations)
An adapter is a concrete implementation of a port:
// internal/adapters/postgres/storage.go
type PostgresAdapter struct {
db *sql.DB
}
func (p *PostgresAdapter) Query(ctx context.Context, sql string) ([]map[string]interface{}, error) {
// Real PostgreSQL implementation
}
// internal/adapters/opa/policy.go
type OPAAdapter struct {
compiler *rego.Compiler
}
func (o *OPAAdapter) Evaluate(ctx context.Context, policyURN string, input map[string]interface{}) (map[string]interface{}, error) {
// Real OPA Rego evaluation
}
// internal/adapters/appsmith/renderer.go
type AppsmithRenderer struct {
client *http.Client
}
func (a *AppsmithRenderer) RenderUI(ctx context.Context, req RenderUIRequest) (RenderUIResponse, error) {
// Call real Appsmith API
}Kernel Uses Ports, Not Adapters
// internal/kernel/engine.go
func ExecuteWorkflow(
ctx context.Context,
storage ports.StoragePort, // Interface - could be PostgreSQL, Oracle, etc.
policy ports.PolicyPort, // Interface - could be OPA, DMN, WASM, etc.
ui ports.UIRendererPort, // Interface - could be Appsmith, RJSF, etc.
) error {
// Business logic - doesn't know or care about implementations
// Save workflow state (works with any storage adapter)
state, err := storage.Query(ctx, "SELECT * FROM workflows WHERE id=?")
// Evaluate policy (works with any policy adapter)
decision, err := policy.Evaluate(ctx, "urn:cascade:policy:approval", input)
// Render UI (works with any UI adapter)
ui, err := ui.RenderUI(ctx, renderRequest)
// All using interfaces - swappable!
}The 13 Ports
Core Processing Ports
| Port | Purpose | Current Adapter | Alternatives |
|---|---|---|---|
| StoragePort | Persist data | PostgreSQL | Oracle, MySQL, MongoDB |
| CachePort | Cache hot data | Redis | Memcached, DynamoDB |
| EventRouterPort | Route events | NATS | RabbitMQ, Kafka, SNS |
UI & Presentation Ports
| Port | Purpose | Current Adapters | Alternatives |
|---|---|---|---|
| UIRendererPort | Render UIs | Appsmith, RJSF, ECharts, TanStack | Custom React, Vue, Angular |
Policy & Decision Ports
| Port | Purpose | Current Adapters | Alternatives |
|---|---|---|---|
| PolicyPort | Business rules | OPA, Camunda DMN | Custom logic, Drools, IBM ODM |
Sequence Storage Ports
| Port | Purpose | Current Adapter | Alternatives |
|---|---|---|---|
| ContextStorePort | Ordered sequences | Redis | PostgreSQL, DynamoDB |
| VectorStorePort | Vector similarity | Redis | Pinecone, Weaviate, Milvus |
Agent & Reasoning Ports
| Port | Purpose | Current Adapter | Alternatives |
|---|---|---|---|
| AgentPort | LLM integration | LangChain | CrewAI, AutoGPT, Custom |
Orchestration Ports
| Port | Purpose | Current Adapter | Alternatives |
|---|---|---|---|
| ActionExecutorPort | Execute activities | Docker, Knative | Temporal (built-in), Lambda, Cloud Functions |
Security & Identity Ports
| Port | Purpose | Current Adapter | Alternatives |
|---|---|---|---|
| AuthenticationPort | User authentication | Ory Kratos | Auth0, AWS Cognito, Keycloak |
| AuthorizationPort | Permission checks | SpiceDB | Casbin, OPA (for RBAC), custom |
Observability Ports
| Port | Purpose | Current Adapters | Alternatives |
|---|---|---|---|
| MetricsPort | Collect metrics | Prometheus | DataDog, NewRelic, CloudWatch |
| TracePort | Distributed tracing | Jaeger | Datadog, New Relic, Zipkin |
| LogPort | Aggregate logs | Loki | ELK, Splunk, CloudWatch |
Benefits for Developers
Testing (Mock Adapters)
// tests/unit/workflow_test.go
type MockStorageAdapter struct {
mock.Mock
}
func (m *MockStorageAdapter) Query(ctx context.Context, sql string) ([]map[string]interface{}, error) {
args := m.Called(ctx, sql)
return args.Get(0).([]map[string]interface{}), args.Error(1)
}
// In your test
func TestWorkflowExecution(t *testing.T) {
mockStorage := new(MockStorageAdapter)
mockStorage.On("Query", mock.Anything, mock.Anything).Return(testData, nil)
// Test against interface, not implementation
err := engine.ExecuteWorkflow(ctx, mockStorage, mockPolicy, mockUI)
require.NoError(t, err)
}Dependency Injection
// Build system with real adapters
storage := postgres.NewAdapter(db)
policy := opa.NewAdapter(compiler)
ui := appsmith.NewAdapter(client)
// Inject into kernel
engine := workflow.NewEngine(storage, policy, ui)
// Later: swap policy adapter
policy = camunda.NewAdapter(dmn_client)
engine = workflow.NewEngine(storage, policy, ui) // Same code, different adapterBenefits for Operations
Swap Technologies
Scenario: You want to use Oracle instead of PostgreSQL
Before (no ports): Rewrite all database logic in kernel With ports: Create OracleAdapter implementing StoragePort
// internal/adapters/oracle/storage.go
type OracleAdapter struct {
db *sql.DB
}
func (o *OracleAdapter) Query(ctx context.Context, sql string) ([]map[string]interface{}, error) {
// Oracle-specific implementation
}
// In main.go
storage := oracle.NewAdapter(oracleDB) // Swap PostgreSQL for Oracle
engine := workflow.NewEngine(storage, policy, ui)Multi-Adapter Support
Run multiple adapters simultaneously:
// Use both Appsmith and RJSF
appsmithUI := appsmith.NewAdapter(appsmithClient)
rjsfUI := rjsf.NewAdapter()
// Route based on user preference
uiAdapter := func() ports.UIRendererPort {
if userPrefersRJSF {
return rjsfUI
}
return appsmithUI
}()
engine := workflow.NewEngine(storage, policy, uiAdapter)Gradual Migration
Scenario: Migrate from Appsmith to RJSF
- Day 1: Both adapters deployed
- Day 2-7: 10% traffic to RJSF, monitor
- Day 8-14: 50% traffic to RJSF
- Day 15: 100% traffic to RJSF
- Day 16: Remove Appsmith adapter
No kernel changes needed!
Adding a New Adapter
Step 1: Understand the Port
Read the port interface:
// internal/ports/storage.go
type StoragePort interface {
Query(ctx context.Context, sql string) ([]map[string]interface{}, error)
Execute(ctx context.Context, sql string, args ...interface{}) error
BeginTransaction(ctx context.Context) (Transaction, error)
}Step 2: Create Adapter Package
mkdir -p internal/adapters/my_technology/Step 3: Implement the Interface
// internal/adapters/my_technology/adapter.go
package my_technology
type MyAdapter struct {
// Your client/connection
client *MyClient
}
// Implement all required methods
func (a *MyAdapter) Query(ctx context.Context, sql string) ([]map[string]interface{}, error) {
// Implementation
}
func (a *MyAdapter) Execute(ctx context.Context, sql string, args ...interface{}) error {
// Implementation
}
func (a *MyAdapter) BeginTransaction(ctx context.Context) (Transaction, error) {
// Implementation
}Step 4: Write Contract Tests
// tests/contract/my_technology_test.go
func TestMyAdapterImplementsStoragePort(t *testing.T) {
var adapter ports.StoragePort = my_technology.NewAdapter(client)
require.NotNil(t, adapter)
}
func TestMyAdapterQueryMethod(t *testing.T) {
adapter := my_technology.NewAdapter(testClient)
result, err := adapter.Query(ctx, "SELECT * FROM test")
require.NoError(t, err)
require.NotEmpty(t, result)
}Step 5: Wire in Dependency Injection
// internal/bootstrap/container.go
func BuildContainer(cfg *Config) *Container {
var storage ports.StoragePort
switch cfg.StorageType {
case "postgres":
storage = postgres.NewAdapter(cfg.PostgresConnStr)
case "oracle":
storage = oracle.NewAdapter(cfg.OracleConnStr)
case "my_technology":
storage = my_technology.NewAdapter(cfg.MyTechClient)
default:
panic("Unknown storage type")
}
return &Container{
Storage: storage,
}
}Step 6: Test the Kernel Works
func TestKernelWithNewAdapter(t *testing.T) {
adapter := my_technology.NewAdapter(testClient)
engine := workflow.NewEngine(adapter, mockPolicy, mockUI)
err := engine.ExecuteWorkflow(ctx, testWorkflow)
require.NoError(t, err)
}Design Patterns
Pattern 1: Factory Method
// Create adapters with environment-aware factories
func NewStorageAdapter(env string) (ports.StoragePort, error) {
switch env {
case "dev":
return sqlite.NewAdapter(":memory:"), nil
case "prod":
return postgres.NewAdapter(os.Getenv("DB_URL")), nil
default:
return nil, fmt.Errorf("unknown env: %s", env)
}
}Pattern 2: Adapter Chain
// Compose adapters for complex behavior
type CachedStorageAdapter struct {
cache ports.CachePort
real ports.StoragePort
}
func (c *CachedStorageAdapter) Query(ctx context.Context, sql string) ([]map[string]interface{}, error) {
// Try cache first
if cached, ok := c.cache.Get(ctx, sql); ok {
return cached, nil
}
// Fall back to real storage
result, err := c.real.Query(ctx, sql)
if err != nil {
return nil, err
}
// Cache result
c.cache.Set(ctx, sql, result)
return result, nil
}Pattern 3: Strategy Pattern
// Different adapters for different strategies
type PolicyEvaluator struct {
adapters map[string]ports.PolicyPort
}
func (p *PolicyEvaluator) Evaluate(ctx context.Context, engine string, policy string, input map[string]interface{}) (map[string]interface{}, error) {
adapter, ok := p.adapters[engine]
if !ok {
return nil, fmt.Errorf("unknown engine: %s", engine)
}
return adapter.Evaluate(ctx, policy, input)
}
// Use
evaluator := &PolicyEvaluator{
adapters: map[string]ports.PolicyPort{
"opa": opa.NewAdapter(compiler),
"dmn": camunda.NewAdapter(client),
"wasm": wasm.NewAdapter(runtime),
},
}Lifecycle of an Adapter
┌─────────────┐
│ Designed │ Interface matches port expectations
└──────┬──────┘
│
▼
┌─────────────┐
│ Implemented │ All methods from port interface
└──────┬──────┘
│
▼
┌─────────────┐
│ Tested │ Unit tests + contract tests
└──────┬──────┘
│
▼
┌─────────────┐
│ Wired │ Registered in dependency injection
└──────┬──────┘
│
▼
┌─────────────┐
│ Deployed │ Available in production
└──────┬──────┘
│
▼
┌─────────────┐
│ Monitored │ Metrics, logs, traces
└─────────────┘Summary
| Concept | Purpose | Benefit |
|---|---|---|
| Port | Interface specification | Stable contracts |
| Adapter | Technology implementation | Technology independence |
| Kernel | Business logic | Never changes |
| Separation | Clear boundaries | Easy to test, extend, swap |
Next Steps
- Process Orchestration - How workflows execute
- Security Model - Multi-layer defense
- Ports Reference - Detailed port documentation
- Adapters Reference - All 20 adapters explained
Ready to see how the system works? → Process Orchestration
Last updated on