Functional Core, Imperative Shell
Architectural pattern that pushes side effects to the edges and keeps business logic pure. Originally from Gary Bernhardt’s screencast at Destroy All Software.
The split
The functional core is pure business logic. It operates only on data passed in, has no side effects, and returns deterministic output. Testable without mocks.
The imperative shell handles everything side-effectful: database calls, network, file system, the current time. Its job is to fetch real data, call the core with it, and act on what the core returns.
Why bother
Mixing database calls and network requests into core logic makes the logic hard to test, hard to reuse, and hard to reason about. You end up mocking time, mocking the database, mocking external services, and the test suite gets brittle.
Example
Before, mixed concerns:
function processExpiredUsers() {
users = database.getUsers() // I/O mixed with logic
for (user in users) {
if (user.expiryDate < today) {
email.send(user, "Expired") // Side effect in logic
}
}
} After, split:
// Functional Core (pure)
function getExpiredUsers(users, currentDate) {
return users.filter(user => user.expiryDate < currentDate)
}
function generateExpiryEmails(users) {
return users.map(user => ({ to: user.email, body: "..." }))
}
// Imperative Shell (orchestration)
function processExpiredUsers() {
users = database.getUsers()
expired = getExpiredUsers(users, now())
emails = generateExpiryEmails(expired)
emailService.sendBatch(emails)
} Core functions take everything they need as arguments. You test them by calling with data. No mocks, no fixtures.
Applying it
Start by finding your side effects: database, network, file system, current time, random numbers. Move everything else into functions that take their inputs as parameters. Leave a thin shell that does the I/O and calls the core.
Common patterns: pass config as data rather than reading files inside the core; pass the current time from the shell rather than calling now() inside the core; fetch external data in the shell, pass it to the core, and have the core return commands the shell then executes.
Related
- Hexagonal Architecture (Ports and Adapters)
- Clean Architecture
- Pure functions
- Dependency injection