Functional Core, Imperative Shell Architecture Pattern

Functional Core, Imperative Shell Architecture Pattern

Overview

The Functional Core, Imperative Shell (FCIS) pattern separates business logic (functional core) from side effects (imperative shell). The core contains pure functions with no I/O, mutations, or dependencies, while the shell handles all interactions with external systems. This architectural boundary dramatically improves testability, reasoning, and reliability.

When to Use

Ideal scenarios:

Avoid when:

Core Concepts

Functional Core

The functional core contains pure business logic:

Imperative Shell

The imperative shell handles all effects:

The shell calls the core with data, receives decisions, then executes effects based on those decisions.

Implementation in Go

Functional Core Example

package core

// Pure domain types
type Order struct {
    ID        string
    Items     []OrderItem
    Status    OrderStatus
    CreatedAt time.Time
}

type OrderItem struct {
    ProductID string
    Quantity  int
    Price     Money
}

type OrderStatus int

const (
    OrderPending OrderStatus = iota
    OrderConfirmed
    OrderShipped
    OrderDelivered
    OrderCancelled
)

// Pure function - no side effects
type OrderDecision struct {
    NewStatus      OrderStatus
    EmailToSend    *Email
    InventoryDelta map[string]int
    RefundAmount   *Money
}

// Pure business logic
func ProcessOrderCancellation(
    order Order,
    reason string,
    now time.Time,
) (*OrderDecision, error) {
    // Validation logic
    if order.Status == OrderDelivered {
        return nil, errors.New("cannot cancel delivered order")
    }
    
    if order.Status == OrderCancelled {
        return nil, errors.New("order already cancelled")
    }
    
    // Calculate refund based on timing
    refund := calculateRefund(order, now)
    
    // Build inventory restoration
    inventory := make(map[string]int)
    for _, item := range order.Items {
        inventory[item.ProductID] = item.Quantity
    }
    
    // Return decision - no effects executed here
    return &OrderDecision{
        NewStatus:      OrderCancelled,
        EmailToSend:    buildCancellationEmail(order, reason),
        InventoryDelta: inventory,
        RefundAmount:   &refund,
    }, nil
}

// Pure helper functions
func calculateRefund(order Order, now time.Time) Money {
    hoursSinceOrder := now.Sub(order.CreatedAt).Hours()
    total := sumOrderTotal(order)
    
    if hoursSinceOrder < 24 {
        return total // Full refund
    } else if hoursSinceOrder < 72 {
        return total.Multiply(0.75) // 75% refund
    }
    return Money{Amount: 0} // No refund
}

func sumOrderTotal(order Order) Money {
    total := Money{Amount: 0}
    for _, item := range order.Items {
        total = total.Add(item.Price.Multiply(float64(item.Quantity)))
    }
    return total
}

Imperative Shell Example

package api

import (
    "context"
    "myapp/core"
    "myapp/db"
)

type OrderService struct {
    db           *db.Database
    emailSender  EmailSender
    inventory    InventoryService
    paymentAPI   PaymentAPI
}

// Shell method - orchestrates I/O around pure core
func (s *OrderService) CancelOrder(
    ctx context.Context,
    orderID string,
    reason string,
) error {
    // 1. Load data (I/O)
    order, err := s.db.GetOrder(ctx, orderID)
    if err != nil {
        return fmt.Errorf("failed to load order: %w", err)
    }
    
    now := time.Now() // Impure - clock access
    
    // 2. Call pure core logic
    decision, err := core.ProcessOrderCancellation(*order, reason, now)
    if err != nil {
        return fmt.Errorf("cancellation validation failed: %w", err)
    }
    
    // 3. Execute effects based on decision
    err = s.db.WithTransaction(ctx, func(tx *db.Tx) error {
        // Update order status
        if err := tx.UpdateOrderStatus(orderID, decision.NewStatus); err != nil {
            return err
        }
        
        // Restore inventory
        for productID, quantity := range decision.InventoryDelta {
            if err := s.inventory.AdjustStock(ctx, productID, quantity); err != nil {
                return err
            }
        }
        
        // Process refund
        if decision.RefundAmount != nil && decision.RefundAmount.Amount > 0 {
            if err := s.paymentAPI.IssueRefund(ctx, orderID, *decision.RefundAmount); err != nil {
                return err
            }
        }
        
        return nil
    })
    
    if err != nil {
        return fmt.Errorf("failed to execute cancellation: %w", err)
    }
    
    // Send email (async, best-effort)
    if decision.EmailToSend != nil {
        go s.emailSender.Send(context.Background(), *decision.EmailToSend)
    }
    
    return nil
}

Implementation in Python

# core.py - Functional core
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from decimal import Decimal

@dataclass(frozen=True)  # Immutable
class Order:
    id: str
    items: tuple[OrderItem, ...]  # Immutable tuple
    status: OrderStatus
    created_at: datetime

@dataclass(frozen=True)
class OrderDecision:
    new_status: OrderStatus
    email_to_send: Optional[Email]
    inventory_delta: dict[str, int]
    refund_amount: Optional[Decimal]

def process_order_cancellation(
    order: Order,
    reason: str,
    now: datetime
) -> OrderDecision:
    """Pure function - deterministic, no side effects"""
    if order.status == OrderStatus.DELIVERED:
        raise ValueError("Cannot cancel delivered order")
    
    refund = calculate_refund(order, now)
    inventory = {item.product_id: item.quantity for item in order.items}
    
    return OrderDecision(
        new_status=OrderStatus.CANCELLED,
        email_to_send=build_cancellation_email(order, reason),
        inventory_delta=inventory,
        refund_amount=refund
    )

# service.py - Imperative shell
class OrderService:
    def __init__(
        self,
        db: Database,
        email_sender: EmailSender,
        inventory: InventoryService,
        payment_api: PaymentAPI
    ):
        self.db = db
        self.email_sender = email_sender
        self.inventory = inventory
        self.payment_api = payment_api
    
    def cancel_order(self, order_id: str, reason: str) -> None:
        # Load data (I/O)
        order = self.db.get_order(order_id)
        now = datetime.now()  # Impure
        
        # Call pure core
        decision = process_order_cancellation(order, reason, now)
        
        # Execute effects
        with self.db.transaction() as tx:
            tx.update_order_status(order_id, decision.new_status)
            
            for product_id, qty in decision.inventory_delta.items():
                self.inventory.adjust_stock(product_id, qty)
            
            if decision.refund_amount and decision.refund_amount > 0:
                self.payment_api.issue_refund(order_id, decision.refund_amount)
        
        if decision.email_to_send:
            self.email_sender.send_async(decision.email_to_send)

Testing Strategy

Testing the Functional Core

func TestProcessOrderCancellation_FullRefundWithin24Hours(t *testing.T) {
    // Arrange - pure data, no mocks needed
    order := core.Order{
        ID:        "order-123",
        Items:     []core.OrderItem{{ProductID: "prod-1", Quantity: 2, Price: Money{Amount: 1000}}},
        Status:    core.OrderPending,
        CreatedAt: parseTime("2025-11-12T10:00:00Z"),
    }
    
    now := parseTime("2025-11-12T12:00:00Z") // 2 hours later
    
    // Act - deterministic, fast
    decision, err := core.ProcessOrderCancellation(order, "customer request", now)
    
    // Assert
    require.NoError(t, err)
    assert.Equal(t, core.OrderCancelled, decision.NewStatus)
    assert.Equal(t, Money{Amount: 2000}, *decision.RefundAmount) // Full refund
    assert.Equal(t, 2, decision.InventoryDelta["prod-1"])
    assert.NotNil(t, decision.EmailToSend)
}

// No database, no network, no mocks - just fast, reliable tests

Testing the Imperative Shell

func TestOrderService_CancelOrder_Integration(t *testing.T) {
    // Integration test with test database
    db := setupTestDB(t)
    emailSender := &FakeEmailSender{}
    service := &OrderService{db: db, emailSender: emailSender}
    
    // Given an existing order
    orderID := db.CreateTestOrder(t, testOrder)
    
    // When cancelling
    err := service.CancelOrder(context.Background(), orderID, "test")
    
    // Then verify effects were executed
    require.NoError(t, err)
    
    order := db.GetOrder(t, orderID)
    assert.Equal(t, OrderCancelled, order.Status)
    assert.Len(t, emailSender.SentEmails, 1)
}

Trade-offs

Advantages

Testability: Core logic tested with simple unit tests - no mocks, databases, or network Reasoning: Pure functions are predictable and easy to understand Reusability: Core logic can be called from multiple shells (HTTP API, CLI, batch jobs) Parallelism: Pure functions are naturally thread-safe Debugging: No hidden state or side effects to track

Disadvantages

Learning curve: Requires functional programming mindset shift Verbosity: More data passing between layers Performance: Potential overhead from data copying (usually negligible) Over-engineering: Overkill for simple CRUD applications

Practical Guidelines

  1. Start at the boundaries: Identify all I/O points, push them to the shell
  2. Pass time explicitly: Don’t call time.Now() in core - pass it as parameter
  3. Return decisions, not effects: Core returns “what to do”, shell executes it
  4. Keep core small: Only business logic; everything else goes in shell
  5. Use immutable data: Prevents accidental mutations in core

Conclusion

The Functional Core, Imperative Shell pattern creates a clear architectural boundary that improves testability and maintainability. While it requires discipline, the payoff in test speed and reliability is substantial for systems with complex business logic.

For Principal Engineers: This pattern works exceptionally well in domains where correctness is critical and logic complexity is high. Consider it for payment processing, order management, pricing engines, and ML feature engineering pipelines.