Logic is pure functions with no I/O; a thin shell executes the effects. It kills mock hell: if a test contains when(repo.findById(...)), logic is tangled with I/O.
Java
// core: a pure function, table-tested without a single mock
static List<LedgerEntry> settle(List<Trade> trades, FxRates rates, LocalDate day) { ... }
// shell: all I/O lives here, no logic
void settleDaily() {
var trades = repo.tradesFor(today); // I/O
var rates = fxClient.ratesFor(today); // I/O
var entries = Settlement.settle(trades, rates, today); // pure
repo.saveAll(entries); // I/O
}Rust
// Rust nudges you here itself: a pure core needs neither async nor Send/Sync
fn settle(trades: &[Trade], rates: &FxRates, day: NaiveDate) -> Vec<LedgerEntry> { ... }
async fn settle_daily(db: &Db, fx: &FxClient) -> Result<()> {
let (trades, rates) = tokio::try_join!(db.trades(today), fx.rates(today))?;
db.save(settle(&trades, &rates, today)).await
}Go
// the core is a package with no I/O imports; a deps linter enforces it
package settlement // imports only time and domain types
func Settle(trades []Trade, rates FxRates, day time.Time) []LedgerEntry { ... }Python
# the I/O-free core is tested with hypothesis; the shell is 5 lines
def settle(trades: list[Trade], rates: FxRates, day: date) -> list[LedgerEntry]:
... # no await, open(), requests — by construction
async def settle_daily(db: Db, fx: FxClient) -> None:
trades, rates = await asyncio.gather(db.trades(TODAY), fx.rates(TODAY))
await db.save(settle(trades, rates, TODAY))Haskell turns this separation from discipline into law: every side effect is marked with the IO type, and the compiler won’t let you call it from a pure function — there, functional core / imperative shell is not a style but a consequence of the type system. (A precision along the way: monads are not “just for I/O”; Maybe, List, State are monads too, IO is merely the best known.) In Java/Go/Python the boundary is held by convention and a linter — which is what the examples show.