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:
- Complex business logic requiring extensive testing
- Systems where correctness is critical (financial, healthcare, ML pipelines)
- Applications with multiple I/O boundaries (databases, APIs, message queues)
- Teams wanting to minimize integration test dependencies
Avoid when:
- Building simple CRUD applications with minimal logic
- Working with highly stateful, imperative domains (game engines, UI frameworks)
- Team lacks familiarity with functional programming concepts
Core Concepts
Functional Core
The functional core contains pure business logic:
- Pure functions (same inputs → same outputs, no side effects)
- Immutable data structures
- All decisions, calculations, and transformations
- Zero I/O, no clock access, no randomness
Imperative Shell
The imperative shell handles all effects:
- Database queries and commands
- HTTP requests and responses
- File system operations
- Logging, metrics, and observability
- Dependency injection and configuration
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
- Start at the boundaries: Identify all I/O points, push them to the shell
- Pass time explicitly: Don’t call
time.Now()in core - pass it as parameter - Return decisions, not effects: Core returns “what to do”, shell executes it
- Keep core small: Only business logic; everything else goes in shell
- 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.