preferences-architectural-patterns

star 13

Architectural patterns including hexagonal architecture, CQRS, and event-driven design. Load when making architectural decisions or reviewing system design.

cameronraysmith By cameronraysmith schedule Updated 4/8/2026

name: preferences-architectural-patterns description: Architectural patterns including hexagonal architecture, CQRS, and event-driven design. Load when making architectural decisions or reviewing system design.

Architectural patterns

Purpose

This document describes how to structure applications using functional domain modeling principles.

Topics covered:

  • Onion/hexagonal architecture for domain isolation
  • Workflow pipeline architecture
  • Dependency injection via function parameters
  • Commands and events as workflow boundaries
  • Effect composition and signature

For domain modeling patterns, see preferences-domain-modeling. For theoretical foundations, see preferences-theoretical-foundations.

Onion/hexagonal architecture

Core principle

Structure applications in concentric layers with domain logic at the center and infrastructure at the edges.

┌─────────────────────────────────────┐
│  Infrastructure (I/O, external)     │
│  ┌───────────────────────────────┐  │
│  │  Application (workflows)      │  │
│  │  ┌─────────────────────────┐  │  │
│  │  │  Domain (types, logic)  │  │  │
│  │  │                         │  │  │
│  │  └─────────────────────────┘  │  │
│  └───────────────────────────────┘  │
└─────────────────────────────────────┘

Layer responsibilities:

Domain layer (innermost):

  • Domain types and models
  • Domain logic and invariants
  • State machines
  • Pure functions only, no I/O
  • No dependencies on outer layers

Application layer (middle):

  • Workflows and use cases
  • Orchestration of domain logic
  • May have effects (Result, Async)
  • Depends on domain layer
  • Infrastructure passed as dependencies

Infrastructure layer (outermost):

  • I/O operations (file, network, database)
  • External service clients
  • Serialization/deserialization
  • Framework adapters
  • Depends on application and domain
  • Implements interfaces defined by inner layers

Dependency direction

Dependencies point inward only:

Infrastructure → Application → Domain
     (I/O)      (workflows)   (pure logic)

Never:

Domain → Infrastructure  ⊘
Domain → Application     ⊘

This ensures:

  • Domain logic testable without I/O
  • Infrastructure replaceable without changing domain
  • Compilation order: domain → application → infrastructure

Instantiation 1 - Scientific data processing

Domain layer:
- Measurement types (Temperature, Pressure, QualityScore)
- Calibration models (CalibrationParams, CalibratedData)
- Validation rules (quality thresholds, range checks)
- State transitions (RawObservations → CalibratedData → ProcessedResults)

Application layer:
- process_observations workflow
- calibrate_measurements workflow
- validate_results workflow
- Coordinates domain logic with I/O dependencies

Infrastructure layer:
- File readers (CSV, HDF5, NetCDF)
- Database clients (save/load results)
- Cloud storage (S3, GCS)
- Plotting/visualization
- CLI argument parsing

Instantiation 2 - Computational model training

Domain layer:
- Model architecture types
- Training state machines (Untrained → Training → Converged → Validated)
- Hyperparameter types with constraints
- Convergence criteria

Application layer:
- train_model workflow
- validate_model workflow
- deploy_model workflow
- Orchestrates training steps

Infrastructure layer:
- GPU/TPU allocation
- Checkpoint storage
- Metrics logging (Wandb, TensorBoard)
- Model serving infrastructure
- Data loaders

Instantiation 3 - Order processing

Domain layer:
- Order types (UnvalidatedOrder → ValidatedOrder → PricedOrder)
- Product codes, quantities
- Pricing rules
- Validation logic

Application layer:
- place_order workflow
- validate_order workflow
- price_order workflow
- Coordinates validation, pricing, acknowledgment

Infrastructure layer:
- Database access
- Email sending
- Payment gateway
- Address validation service
- Product catalog

Algebraic view of architecture layers

Each architecture layer can be understood as a module algebra with its own signature, implementation, and composition rules. This perspective formalizes the layer separation and clarifies composition.

Layers as signatures

Each layer defines a signature (interface) hiding implementation details. Domain layer signatures use pure types; infrastructure signatures may include effects.

// Domain layer signature - pure
trait AccountDomain {
    fn validate_account(data: AccountData) -> Result<Account, DomainError>;
    fn apply_interest(account: Account, rate: Rate) -> Account;
}

// Infrastructure layer signature - effectful
trait AccountRepository {
    fn find(&self, id: AccountId) -> impl Future<Output = Result<Account, DbError>>;
    fn save(&self, account: &Account) -> impl Future<Output = Result<(), DbError>>;
}

Layers as algebras

Each layer implements its signature, potentially using services from inner layers. The domain layer is pure; outer layers add effects.

Composition via functors

Layer boundaries are crossed via natural transformations (functors mapping between algebras). The application layer composes domain and infrastructure through these transformations.

// Application layer composes domain + infrastructure
async fn process_account(
    id: AccountId,
    repo: &impl AccountRepository,
) -> Result<Account, AppError> {
    let account = repo.find(id).await.map_err(AppError::from)?;
    let validated = AccountDomain::validate_account(account.into())?;
    let updated = AccountDomain::apply_interest(validated, current_rate());
    repo.save(&updated).await.map_err(AppError::from)?;
    Ok(updated)
}

See preferences-domain-modeling for the full signature/algebra/interpreter pattern of module algebra for domain services.

Workflow pipeline architecture

Pattern

Model domain processes as pipelines of transformations where each step:

  • Takes domain input (data or command)
  • Performs transformation
  • Returns domain output (result or events)
  • Declares dependencies explicitly
  • Documents effects in type signature
Input → [Step1] → [Step2] → [Step3] → Output
         ↓dep      ↓dep      ↓dep
       Service1  Service2  Service3

Workflow anatomy

def workflow(
    dependency1: Service1,    # External dependencies first
    dependency2: Service2,
    config: Config,           # Configuration
    input_data: InputType     # Input data last
) -> Result[OutputType, Error]:  # Explicit error handling
    """
    Workflow description.

    Dependencies:
    - dependency1: Purpose of this dependency
    - dependency2: Purpose of this dependency

    Returns:
    - Ok(OutputType): On success
    - Error(...): On failure with reason
    """
    # Implementation

Key aspects:

  1. Dependencies first: Enables partial application to create specialized versions
  2. Input last: Allows piping: input |> workflow(dep1, dep2, config)
  3. Explicit effects: Result type documents possible failures
  4. Pure core: Domain logic separate from I/O dependencies

Composition example

# Individual steps with dependencies
def calibrate(
    calibration_model: CalibrationModel,
    quality_threshold: float,
    raw: RawObservations
) -> Result[CalibratedData, CalibrationError]:
    ...

def infer(
    inference_algorithm: InferenceAlgorithm,
    calibrated: CalibratedData
) -> Result[InferredResults, InferenceError]:
    ...

def validate(
    validation_metrics: ValidationMetrics,
    inferred: InferredResults
) -> Result[ValidatedResults, ValidationError]:
    ...

# Composed pipeline
def process_observations(
    calibration_model: CalibrationModel,
    quality_threshold: float,
    inference_algorithm: InferenceAlgorithm,
    validation_metrics: ValidationMetrics,
    raw: RawObservations
) -> Result[ValidatedResults, ProcessingError]:
    """Complete processing pipeline."""
    return (
        calibrate(calibration_model, quality_threshold, raw)
        .map_error(ProcessingError.from_calibration)
        .bind(lambda cal: infer(inference_algorithm, cal))
        .map_error(ProcessingError.from_inference)
        .bind(lambda inf: validate(validation_metrics, inf))
        .map_error(ProcessingError.from_validation)
    )

See preferences-railway-oriented-programming for Result composition details.

Dependency injection via function parameters

Pattern

Pass dependencies as function parameters rather than using dependency injection frameworks or global state.

Benefits:

  1. Explicit: Function signature documents all dependencies
  2. Testable: Easy to pass mocks/stubs
  3. Composable: Partial application creates specialized functions
  4. Type-safe: Compiler verifies correct dependency types

Partial application for specialization

from functools import partial

# Full signature with all dependencies
def process_data(
    calibration_model: CalibrationModel,      # dependency 1
    quality_threshold: QualityThreshold,      # dependency 2
    inference_algo: InferenceAlgorithm,       # dependency 3
    data: RawObservations                     # input
) -> Result[ProcessedData, ProcessingError]:
    """Process observations with given dependencies."""
    ...

# Create specialized version with default dependencies
process_with_defaults = partial(
    process_data,
    default_calibration_model,
    strict_quality_threshold,
    standard_inference_algo
)

# Now just: RawObservations → Result[ProcessedData, Error]
result = process_with_defaults(observations)

# Alternative specialization for testing
process_for_testing = partial(
    process_data,
    mock_calibration_model,
    permissive_threshold,
    fast_test_algo
)

Dependency interfaces

Define dependencies as simple function types, not heavyweight interfaces:

# Instead of interface with many methods:
class IProductCatalog(Protocol):  ⊘
    def get_product(self, code: ProductCode) -> Product: ...
    def list_products(self) -> List[Product]: ...
    def update_stock(self, code: ProductCode, qty: int) -> None: ...
    # ... many methods

# Use specific function types:
GetProductPrice = Callable[[ProductCode], Price]  ●
CheckProductExists = Callable[[ProductCode], bool]

def price_order(
    get_price: GetProductPrice,      # Only what we need
    check_exists: CheckProductExists,
    order: ValidatedOrder
) -> Result[PricedOrder, PricingError]:
    ...

Advantages:

  • Function type documents exactly what's needed
  • No mock framework needed (just pass lambdas)
  • Can compose functions to create dependencies
  • Smaller, focused interfaces

Example: Reader monad pattern

For workflows with many shared dependencies, use Reader pattern:

@dataclass(frozen=True)
class Dependencies:
    """Shared dependencies for workflows."""
    calibration_model: CalibrationModel
    quality_threshold: float
    inference_algo: InferenceAlgorithm
    database: Database

def process_observations(
    deps: Dependencies,
    raw: RawObservations
) -> Result[ValidatedResults, Error]:
    """Workflow using shared dependencies."""
    return (
        calibrate(deps.calibration_model, deps.quality_threshold, raw)
        .bind(lambda cal: infer(deps.inference_algo, cal))
        .bind(lambda inf: save_results(deps.database, inf))
    )

# Specialize with partial application
process = partial(process_observations, production_dependencies)

# Or create Reader
from expression import Reader

def process_observations_reader(
    raw: RawObservations
) -> Reader[Dependencies, Result[ValidatedResults, Error]]:
    return Reader(lambda deps: process_observations(deps, raw))

See preferences-theoretical-foundations references/effects-handlers.md (heading "A transformer stack is one interpreter, not the interface") for why the Reader capability is the primitive and a transformer stack is only one interpreter of it.

Dependency injection via Kleisli arrows

Beyond simple function parameters, Kleisli arrows (A -> M B) compose effectful computations with embedded dependencies. The Reader monad is a Kleisli category over the function type.

// Kleisli-style service composition
type ServiceOp<A> = impl Fn(&ServiceContext) -> Result<A, Error>;

fn find_account(id: AccountId) -> ServiceOp<Account> {
    move |ctx| ctx.account_repo.find(id)
}

fn validate_balance(account: Account, amount: Amount) -> ServiceOp<Account> {
    move |_ctx| {
        if account.balance >= amount {
            Ok(account)
        } else {
            Err(Error::InsufficientBalance)
        }
    }
}

// Compose via Kleisli composition (>=>)
fn withdraw(id: AccountId, amount: Amount) -> ServiceOp<Account> {
    find_account(id)
        .and_then(|a| validate_balance(a, amount))
        .and_then(|a| debit(a, amount))
}

This pattern:

  • Defers dependency injection to the execution site
  • Enables pure composition before execution
  • Simplifies testing with mock contexts

See preferences-railway-oriented-programming for combining Result with other effects.

Commands and events as workflow boundaries

Commands as inputs

Commands represent intent to perform an action:

@dataclass(frozen=True)
class ProcessObservationsCommand:
    """Command to process raw observations."""
    observations: RawObservations
    timestamp: datetime
    user_id: UserId
    request_id: RequestId  # For idempotency, logging

@dataclass(frozen=True)
class TrainModelCommand:
    """Command to train a model."""
    training_data: TrainingDataset
    architecture: ModelArchitecture
    hyperparameters: HyperParameters
    timestamp: datetime
    user_id: UserId

Characteristics:

  • Immutable (frozen dataclass)
  • Contains all data needed for workflow
  • Includes metadata (timestamp, user, request ID)
  • Named in imperative (ProcessObservations, TrainModel)

Events as outputs

Events represent facts about what happened:

@dataclass(frozen=True)
class ObservationsProcessed:
    """Event: observations were successfully processed."""
    results: ValidatedResults
    processing_time: timedelta
    timestamp: datetime

@dataclass(frozen=True)
class ProcessingFailed:
    """Event: processing failed."""
    error: ProcessingError
    timestamp: datetime

# Union of possible events
ProcessingEvent = ObservationsProcessed | ProcessingFailed

@dataclass(frozen=True)
class ModelTrained:
    """Event: model training completed."""
    model_id: ModelId
    final_metrics: Metrics
    checkpoint_path: Path
    timestamp: datetime

Characteristics:

  • Immutable (frozen dataclass)
  • Named in past tense (ObservationsProcessed, ModelTrained)
  • Contain result data
  • Multiple events may be emitted from one workflow

Workflow signature with commands/events

def process_observations_workflow(
    deps: Dependencies,
    command: ProcessObservationsCommand
) -> AsyncResult[list[ProcessingEvent], WorkflowError]:
    """
    Process observations workflow.

    Input: ProcessObservationsCommand
    Output: List of events (ObservationsProcessed, etc.)
    Effects: Async I/O, may fail
    """
    ...

# At application boundary (HTTP API, message queue):
async def handle_command(command_json: dict) -> Response:
    # Deserialize command
    command = ProcessObservationsCommand.from_dict(command_json)

    # Execute workflow
    result = await process_observations_workflow(deps, command)

    # Handle result
    match result:
        case Ok(events):
            # Publish events, return success
            for event in events:
                await event_bus.publish(event)
            return Response(status=200, body={"events": events})

        case Error(error):
            # Log error, return failure
            logger.error(f"Workflow failed: {error}")
            return Response(status=500, body={"error": str(error)})

Benefits

  1. Explicit contracts: Commands document inputs, events document outputs
  2. Asynchronous workflows: Commands can be queued, events can trigger other workflows
  3. Event sourcing: Events as system of record
  4. Testing: Easy to create command instances for testing
  5. Audit trail: Commands + events provide complete history

See preferences-domain-modeling for workflows as type-safe pipelines, and preferences-event-sourcing for full event sourcing patterns where events become the authoritative source of truth.

Effect composition and signatures

Explicit effects in type signatures

Document all effects in function signatures:

# No effects (pure function)
def calculate_quality(
    measurement: Measurement,
    baseline: Baseline
) -> QualityScore:
    ...

# May fail
def validate_measurement(
    measurement: Measurement
) -> Result[ValidatedMeasurement, ValidationError]:
    ...

# Async I/O
async def fetch_calibration_data(
    experiment_id: ExperimentId
) -> CalibrationData:
    ...

# Async + may fail
async def save_results(
    database: Database,
    results: Results
) -> AsyncResult[SaveConfirmation, DatabaseError]:
    ...

# Multiple effects combined
async def process_with_retry(
    max_retries: int,
    operation: Callable[[T], AsyncResult[U, E]],
    input: T
) -> AsyncResult[U, E]:
    ...

Common effect combinations

# Result + Async
AsyncResult[T, E] = Async[Result[T, E]]

# Option + Async
AsyncOption[T] = Async[Option[T]]

# Result + Option (rare, usually use Result with error for "not found")
ResultOption[T, E] = Result[Option[T], E]

Effect signatures as documentation

Type aliases document the effects a computation requires. This makes effect requirements explicit in signatures.

// Effect signature documents: this operation needs DB access and may fail
type DbOperation<A> = impl Future<Output = Result<A, DbError>>;

// Effect signature documents: needs config and DB, may fail with app errors
type AppOperation<A> = impl Fn(&Config) -> impl Future<Output = Result<A, AppError>>;

// Pure domain operations have no effect signature
fn calculate_interest(principal: Amount, rate: Rate, days: u32) -> Amount { ... }

Effect signatures serve as documentation:

  • Which effects does this operation perform?
  • What context does it need?
  • What can go wrong?

This connects to the module algebra pattern where signatures declare capabilities.

See preferences-domain-modeling for the module-algebra signature/algebra/interpreter patterns for domain services.

Effect composition guidelines

  1. Keep stacks shallow: Maximum 2-3 combined effects
  2. Use type aliases: AsyncResult instead of Async[Result[T, E]]
  3. Document effect semantics: What does each effect mean?
  4. Prefer specialized types: Custom AsyncResult over generic transformer
  5. Avoid effect soup: Don't mix too many effects

Example of effect overload (avoid):

# Too many effects - hard to reason about
ReaderStateAsyncResultOption[Config, State, T, E] = ...  ⊘

Better approach:

# Limit to essential effects
WorkflowResult[T] = AsyncResult[T, WorkflowError]  ●

# Pass config/state as parameters
def workflow(
    config: Config,
    initial_state: State,
    input: Input
) -> WorkflowResult[Output]:
    ...

See preferences-railway-oriented-programming for Result composition.

See preferences-theoretical-foundations references/effects-handlers.md (heading "Indexed monads: one tool for typestate, not the organizing principle") for why an effect signature is a capability interface discharged by handlers, with indexed monads reserved for typestate rather than treated as the organizing principle.

Cross-language integration

Principles for multi-language systems

When integrating code across languages (Python ↔ Rust ↔ TypeScript):

  1. Preserve effect signatures: Effects explicit at language boundaries
  2. Use standard serialization: JSON, Protocol Buffers, MessagePack
  3. Share type definitions: Generate types from schemas (JSON Schema, Protobuf)
  4. Validate at boundaries: Don't trust data crossing language boundaries
  5. Document effect transformations: How effects map between languages

Example: Python calling Rust

# Python wrapper for Rust function
def calibrate_rust(
    params: CalibrationParams,
    data: ndarray
) -> Result[CalibratedData, CalibrationError]:
    """
    Call Rust calibration function.

    Effect transformation:
    - Rust: Result<CalibratedData, CalibrationError>
    - Python: Result[CalibratedData, CalibrationError]

    Serialization:
    - params: JSON
    - data: NumPy → zero-copy buffer
    - result: JSON
    """
    try:
        # Serialize inputs
        params_json = params.to_json()
        data_buffer = data.tobytes()

        # Call Rust via FFI/subprocess
        result_json = _rust_calibrate(params_json, data_buffer)

        # Deserialize output
        if result_json["ok"]:
            return Ok(CalibratedData.from_json(result_json["value"]))
        else:
            return Error(CalibrationError.from_json(result_json["error"]))

    except Exception as e:
        # Infrastructure error (FFI failure, serialization error)
        return Error(CalibrationError(f"FFI failed: {e}"))

Shared type definitions

Use schema to generate types in multiple languages:

// calibration.schema.json
{
  "CalibrationParams": {
    "type": "object",
    "properties": {
      "baseline": {"type": "number"},
      "threshold": {"type": "number"}
    },
    "required": ["baseline", "threshold"]
  },
  "CalibrationError": {
    "type": "object",
    "oneOf": [
      {"type": "object", "properties": {"InvalidParams": {"type": "string"}}},
      {"type": "object", "properties": {"InsufficientData": {"type": "string"}}}
    ]
  }
}

Generate code:

  • Python: dataclasses with Pydantic validators
  • TypeScript: type definitions
  • Rust: structs with serde

Strategic architecture

Architectural decisions should align with the strategic importance of different domains rather than applying uniform approaches across the entire system. The Core/Supporting/Generic domain classification described in preferences-strategic-domain-analysis directly influences architectural choices about integration patterns, deployment topology, and team boundaries.

Domain classification shapes architecture

Core domains warrant the most sophisticated architectural treatment because they represent competitive advantage. These contexts receive dedicated infrastructure, independent deployment pipelines, and careful isolation from other system components. The hexagonal architecture patterns described earlier in this document apply most rigorously to core domains, with explicit ports and adapters ensuring that domain logic remains insulated from infrastructure concerns.

Supporting sub-domains receive solid but less elaborate architectural treatment. These contexts often share infrastructure with other supporting contexts, deploy together in service groups, and use standardized integration patterns. The workflow pipeline patterns remain appropriate, but the level of isolation and custom infrastructure investment is reduced compared to core domains.

Generic sub-domains receive minimal custom architecture, favoring integration with third-party services or shared platform capabilities. These contexts are often thin adapter layers that translate between internal domain models and external service interfaces. The dependency injection patterns enable swapping providers without modifying domain logic, but the domain logic itself is minimal.

Context boundaries as architectural units

Bounded contexts, detailed in preferences-bounded-context-design, serve as the fundamental architectural unit in domain-driven systems. Each context represents a deployment unit, an ownership boundary, and an integration interface.

The context relationship patterns (Partnership, Customer-Supplier, Anti-Corruption Layer) directly influence architectural decisions about synchronous versus asynchronous communication, data replication versus service calls, and tight versus loose coupling. Anti-Corruption Layers manifest as adapter modules that translate between external types and internal domain types, implementing the functor mappings that preserve semantic relationships across context boundaries.

When multiple contexts are owned by the same team, they may share deployment infrastructure while maintaining logical separation. When contexts are owned by different teams, deployment independence becomes more valuable, trading some efficiency for reduced coordination overhead.

Team topology alignment

Architectural patterns should support the communication patterns implied by team structure. Conway's Law ensures that architecture and organization will eventually align, so deliberate design should anticipate this alignment rather than fighting it.

Stream-aligned teams owning core domain contexts need architectural patterns that maximize autonomy and minimize dependencies on other teams. Platform teams providing generic capabilities need architectural patterns that emphasize stability, backward compatibility, and clear versioning. Enabling teams helping multiple teams adopt new patterns need architectural patterns that are portable and technology-agnostic.

The Team Topologies interaction modes (Collaboration, X-as-a-Service, Facilitating) map to context relationship patterns. Collaboration implies Partnership or Shared Kernel relationships with tight coupling. X-as-a-Service implies Open Host Service with stable, versioned interfaces. Facilitating implies enabling teams help stream-aligned teams design their own context architectures.

See also

See preferences-strategic-domain-analysis for detailed Core/Supporting/Generic classification frameworks and investment prioritization.

See preferences-bounded-context-design for context relationship patterns, the Bounded Context Canvas, and the algebraic interpretation of Anti-Corruption Layers as functors between type algebras.

See preferences-discovery-process for how strategic and organizational analysis fits into the broader discovery workflow.

Observability in layered architecture

Observability instrumentation follows the same layered discipline as other cross-cutting concerns in hexagonal/onion architecture. Telemetry is a side effect and belongs at the boundaries, not in the domain core.

The domain layer contains pure computation with no telemetry side effects. Domain functions take inputs and return outputs, including Result types for errors. Observability for domain behavior is achieved by the application layer wrapping domain calls in spans and recording the domain's return values as span attributes. The domain itself does not import tracing, logging, or metrics libraries. This preserves the domain's testability and composability, the same properties that motivate keeping I/O at the boundaries.

The application layer handles workflow orchestration with span creation. Each workflow or use case creates a parent span that encompasses the entire operation. Correlation IDs (trace_id) flow through the application layer via context propagation. Business-meaningful events such as command acceptance, aggregate state changes, and notification delivery are recorded as span events or structured log entries at this layer. Error classification happens here: domain errors become span attributes with appropriate status, while infrastructure errors trigger different observability treatment (see preferences-railway-oriented-programming).

The infrastructure layer performs I/O operations with automatic instrumentation. Database calls, HTTP requests, message queue operations, and cache interactions emit spans via automatic instrumentation through OpenTelemetry wrappers. These spans are children of the application layer's workflow span, creating a hierarchical trace. Health endpoints, metrics exposition, and telemetry export configuration live here.

The presentation layer creates request/response spans at the outer boundary. HTTP middleware creates the root span for each request, capturing method, path, status code, and duration. This is typically auto-instrumented and requires no manual code. Health check endpoints covering liveness, readiness, and startup probes are presentation-layer concerns.

The key principle is that observability instrumentation mirrors the dependency rule. Inner layers are unaware of telemetry; outer layers add observability context progressively. The domain remains a pure function from the perspective of instrumentation.

This layered approach to observability also clarifies where to draw the line on structured logging versus tracing. Structured log entries at the application layer carry the trace_id and span_id from the current context, enabling correlation between logs and traces without the domain layer needing awareness of either mechanism. Infrastructure-layer spans provide timing and error data that would otherwise require manual log instrumentation, reducing the surface area where developers must remember to add observability code.

Cross-reference preferences-observability-engineering for the foundational observability model and preferences-production-readiness for operational readiness requirements.

Theoretical ideal

In the ideal case, all systems—regardless of language—converge asymptotically on a single typed calculus: the conjectural internal language of compositional software architecture. There is no single canonical "category of effects" standing in for that calculus, and no canonical monad-transformer stack realizing it. The practical primitive that approaches the ideal is a capability interface discharged by handlers — an effect signature whose meaning is supplied by an interpreter chosen separately from the program text. A monad-transformer stack is one leaky interpreter of such an interface, never the interface itself and never the ideal. See preferences-theoretical-foundations for the conjectural internal language the design converges on, and its references/effects-handlers.md for why a capability interface, not a transformer tower, is the primitive.

Ideal properties:

  • Emphasize type-safety and functional programming patterns as feasible within each programming language or framework's ecosystem
  • Use relevant libraries to achieve functional programming and type-safety where languages don't natively support it (e.g., basedpyright, beartype, and dbrattli/Expression in Python, Effect-TS in TypeScript, ZIO in Scala)
  • Balance practical implementation constraints against the theoretical ideal of composable, lawful abstractions
  • Design cross-language integrations to preserve functional composition, monadic structure, and explicit effect handling at API/FFI boundaries
  • Encode all effects explicitly in type signatures at language boundaries to avoid hidden side effects or runtime surprises
  • Ensure bind/flatMap operations satisfy monad laws across language boundaries when composing multi-language systems
  • Where a transformer stack is the chosen interpreter, lift operations allow values to traverse its layers (liftIO, liftState, etc.); this is a property of that one interpreter, not of the ideal, and the deep lift chains it forces are part of why the stack is leaky
  • A transformer stack is one way to realize the capability interface across languages, with each language implementing a specific effect transformer over the layers below it (e.g., Rust base IO/Result, TypeScript middle StateT s (EitherT e IO), Python top ReaderT config (StateT s (EitherT e IO))); these per-language stack shapes are illustrative of that one interpreter, not the prescribed target — the prescribed target is the capability interface the stack happens to discharge here
  • Use Result/Either/Option types for error handling that composes vertically through all layers rather than runtime exceptions
  • Thread state explicitly through function parameters or state monads rather than hiding it in global mutable variables
  • Isolate IO and unsafe effects to specific layers or boundaries rather than scattering them throughout the codebase
  • Compose async/concurrency via effect systems or monad transformers rather than ad-hoc callbacks or unstructured promises
  • Maintain the ideal that multi-language system integration converges on the conjectural internal language of compositional software architecture, realized today through capability interfaces discharged by handlers that preserve referential transparency end-to-end; the transformer-stack shapes above are one interpreter of those interfaces, not the calculus itself

Practical path to ideal:

Start with:

  1. Onion architecture (domain/application/infrastructure layers)
  2. Explicit effect signatures
  3. Simple effect types (Result, Async)
  4. Dependency injection via parameters

Progress toward: 5. Capability interfaces discharged by handlers — the correct-by-construction primitive the ladder culminates in: an effect signature whose meaning a separately-chosen interpreter supplies 6. Algebraic effects and handlers (Effect-TS, ZIO, Polysemy, OCaml runtime effect handlers) as the modern discharge of those interfaces, where available 7. A shallow monad-transformer stack (2-3 effects, hidden behind a type alias) as one optional interpreter of a capability interface when an effects library is unavailable — never the goal state, and the layer-ordering it bakes in is a semantic commitment 8. Indexed monads as one optional tool for typestate and protocol safety, reserved for the critical properties that warrant their cost, not as a rung toward a transformer-stack apex

The apex is a capability interface discharged by handlers, not a full transformer stack; the stack and indexed monads above are optional interpreters and tools, demoted accordingly.

See preferences-theoretical-foundations references/effects-handlers.md (heading "A transformer stack is one interpreter, not the interface") for why a capability interface, not a transformer tower, is the primitive, and its heading "Indexed monads: one tool for typestate, not the organizing principle" for the typestate scoping.

Install via CLI
npx skills add https://github.com/cameronraysmith/vanixiets --skill preferences-architectural-patterns
Repository Details
star Stars 13
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
cameronraysmith
cameronraysmith Explore all skills →