Skip to Content
ArchitectureHexagonal Pattern

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

PortPurposeCurrent AdapterAlternatives
StoragePortPersist dataPostgreSQLOracle, MySQL, MongoDB
CachePortCache hot dataRedisMemcached, DynamoDB
EventRouterPortRoute eventsNATSRabbitMQ, Kafka, SNS

UI & Presentation Ports

PortPurposeCurrent AdaptersAlternatives
UIRendererPortRender UIsAppsmith, RJSF, ECharts, TanStackCustom React, Vue, Angular

Policy & Decision Ports

PortPurposeCurrent AdaptersAlternatives
PolicyPortBusiness rulesOPA, Camunda DMNCustom logic, Drools, IBM ODM

Sequence Storage Ports

PortPurposeCurrent AdapterAlternatives
ContextStorePortOrdered sequencesRedisPostgreSQL, DynamoDB
VectorStorePortVector similarityRedisPinecone, Weaviate, Milvus

Agent & Reasoning Ports

PortPurposeCurrent AdapterAlternatives
AgentPortLLM integrationLangChainCrewAI, AutoGPT, Custom

Orchestration Ports

PortPurposeCurrent AdapterAlternatives
ActionExecutorPortExecute activitiesDocker, KnativeTemporal (built-in), Lambda, Cloud Functions

Security & Identity Ports

PortPurposeCurrent AdapterAlternatives
AuthenticationPortUser authenticationOry KratosAuth0, AWS Cognito, Keycloak
AuthorizationPortPermission checksSpiceDBCasbin, OPA (for RBAC), custom

Observability Ports

PortPurposeCurrent AdaptersAlternatives
MetricsPortCollect metricsPrometheusDataDog, NewRelic, CloudWatch
TracePortDistributed tracingJaegerDatadog, New Relic, Zipkin
LogPortAggregate logsLokiELK, 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 adapter

Benefits 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

  1. Day 1: Both adapters deployed
  2. Day 2-7: 10% traffic to RJSF, monitor
  3. Day 8-14: 50% traffic to RJSF
  4. Day 15: 100% traffic to RJSF
  5. 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

ConceptPurposeBenefit
PortInterface specificationStable contracts
AdapterTechnology implementationTechnology independence
KernelBusiness logicNever changes
SeparationClear boundariesEasy to test, extend, swap

Next Steps

Ready to see how the system works? → Process Orchestration

Last updated on