Humble Objects and the Functional Core
Purpose
Make the interesting part of a component testable by moving it out of the part that is hard
to test. A decision — which discount applies, whether to retry, which shard to route to,
what to render — is a function of data. An effect — writing to a socket, a database, a
screen — is not. Mixing them produces code where the only way to check a business rule is to
stand up the world.
The techniques overlap. Humble Object extracts logic from a hard-to-test component so
its remaining boundary responsibilities can be tested narrowly; the extracted logic need
not be pure. Functional core, imperative shell additionally makes decisions functions
of explicit data while the shell owns effects and their consistency.
The two failures this exists to prevent: logic trapped inside a framework component, so a
rule change is verified by a slow test that spins up HTTP and a database; and the opposite
excess, where every effect is wrapped in ceremony and the reader loses the actual work in a
pipeline of indirection.
Workflow
Inspect the project's Java release/toolchain and framework/transaction configuration before
refactoring. The sealed outcomes and pattern switches below use Java 21 without preview;
retain existing alternatives on older targets rather than upgrading the project. Preserve
observable behavior, authorization, transaction boundaries and failure ordering. Report the
decision extracted, the effect contract retained and actual core/boundary checks; test speed
or reliability improvements remain unmeasured unless compared.
- Find the decision. In the component, identify the branch that would be worth a test if
it were reachable — the conditional, the calculation, the selection.
- Name its inputs. Everything the decision reads: parameters, fetched data, the clock,
configuration. If an input arrives through I/O, the decision does not need the I/O, it
needs the value.
- Move the decision to a function of those inputs. No fetching, no writing, no clock, no
randomness — those become parameters. The result is a pure function or a small class with
no collaborators.
- Leave the shell humble. What remains fetches, calls the decision, and performs the result.
Branches expressing infrastructure policy may remain, but test them at the cheapest level that
observes their effects rather than forcing every branch into a pure core.
- Represent the outcome as data where the shell must act on it. "Retry after 200 ms",
"reject with this reason" — an outcome the shell interprets, not an effect the core
performs.
- Check the payoff. The extraction earned its cost only if a real test got faster,
simpler, or possible at all. If the same tests still need the same setup, revert it.
The split
┌──────────────────────── SHELL (imperative, humble) ──┐
request ──►│ fetch what the decision needs │
│ │ │
│ ▼ │
│ ┌── CORE (pure) ─────────────────────────┐ │
│ │ inputs in, decision out. │ │
│ │ No I/O, no clock, no randomness, │ │
│ │ no framework, no mutation of anything │ │
│ │ the caller can see. │ │
│ └────────────────────────────────────────┘ │
│ │ outcome (data) │
│ ▼ │
│ perform the effect it describes │
└──────────────────────────────────────────────────────┘
Tests of the core: explicit input fixtures, outcomes and boundary cases.
Tests of the shell: effects, wiring, failure order and consistency.
What makes the core pure is not the absence of the word void — it is that calling it twice
with the same inputs gives the same answer, and calling it zero times changes nothing. The
clock and the random source are inputs like any other; passing Instant rather than calling
Instant.now() is usually the single highest-value move in this whole technique.
Decision rules
The logic is inside a controller, listener, scheduled method or UI
component, and has a branch worth testing
→ extract it. The framework component becomes humble: bind,
delegate, respond.
The logic needs data from a repository or a remote call
→ the shell fetches, the core receives the values. Do not pass
the repository into the core "so it can fetch what it needs" —
that reintroduces the collaborator you were removing.
The decision needs the current time, a random value or a generated id
→ pass the sampled value for a pure core. Injecting Clock into
a service gives controllable time, not production purity.
The core must cause something to happen
→ return a description of it. The shell interprets. This is what
makes retry, fallback and routing policy unit-testable
(retries-and-backoff, circuit-breakers).
The component has no interesting branch — it maps, binds or forwards
→ leave it. There is nothing to extract, and wrapping it in a
port produces indirection (enterprise-architecture-smells).
The work IS the effect: streaming bytes, a bulk UPDATE, a batch insert
→ do not split it. There is no decision to isolate, and the
set-based operation belongs in the database
(domain-logic-organization).
Purity would force loading a large result set into memory to keep the
core pure
→ the boundary is in the wrong place. Push the filtering into
the query and let the core decide over what comes back
(architecture-and-performance).
Rules
- Humility minimizes logic in the hard-to-test boundary; it does not make boundary tests worthless.
Binding, authentication, transaction demarcation, serialization and failure translation can all
deserve focused integration tests even when business decisions live in the core.
- Extract the decision, not the I/O. Wrapping a repository in an interface does not make the
logic testable if the logic still lives in the shell; it only adds a seam
(
java-dependency-inversion).
- Heavy interaction mocking can signal that a decision is entangled with collaborators, but mocks
are also legitimate for protocols, failure injection and orchestration. Inspect whether the test
asserts stable outcomes or incidental call order before changing the design.
- Pass controllable ambient inputs when exact outcomes or boundary cases matter. Direct time/random
calls do not automatically make every property test flaky, but they obstruct replay and precise
failure diagnosis.
Clock is in the JDK for the time calls; the id and the
random source are supplied the same way, by the shell.
- Purity is about observable effect, not about avoiding assignment. A core that builds a
local
ArrayList and returns a stable result can be pure when no mutable aliases or
mutable elements escape or change concurrently. Local mutation can simplify sequential code
(java-immutability).
- The core is where records and sealed types pay for themselves: inputs as records, outcomes
as a sealed hierarchy, the shell's handling as an exhaustive
switch the compiler checks
when a new outcome is added (java-composition-over-inheritance).
- Many distributed policies have a pure decision kernel, but breakers, adaptive limits and routing
depend on concurrent, time-varying state. Model transitions explicitly and test both deterministic
policy and thread-safe state/effect integration. Separating them from the call they govern is
what makes them testable without a network, and what stops policy from being reimplemented
slightly differently at each call site.
- Do not push effects into the core disguised as parameters. A
Runnable, a Consumer or a
callback handed to the core so it can "just call this" restores the impurity while hiding
it from the signature.
- The shell is allowed to be dull and repetitive. Resisting duplication there, by inventing an
abstraction over the effects, is how a humble shell becomes a framework nobody understands.
- This is a component-level technique, not an architecture. It composes with any layering
and any domain-logic organisation, and it does not require ports, adapters or a hexagon
(
layering-and-boundaries).
References
- Applying the pattern to real components — the
recurring applications: controller and presenter, scheduled job, message listener, gateway,
and view; what stays in the framework component and what leaves; the Spring-specific version
of each; and the diagnosis for a component that resists extraction. Read when restructuring a
specific class that is hard to test.
- The functional core in Java — expressing the core
with records, sealed outcomes and exhaustive switches; effects as returned data; where
mutation is legitimate; passing the clock and other ambient inputs; the allocation and
readability costs and when they exceed the benefit. Read when writing or reviewing the core
itself.
1---2name: humble-objects-and-functional-core3description: Splitting a component into the part that decides and the part that acts, so the decision is pure, deterministic and cheap to test while the effectful part stays thin enough for a small set of boundary/integration tests—the Humble Object pattern and the functional core / imperative shell shape of the same idea. Use when a rule can only be exercised by standing up the framework because the decision lives inside the component that performs the effect, when logic sits in a controller, scheduler, message listener or UI component, when a test needs a mocking framework to reach the branch it cares about, or when retry, fallback or routing policy is entangled with the call it governs. Does not cover which test level to use (architecture-testing, java-testing-strategy), choosing and writing the doubles themselves (java-test-doubles), where business rules belong across layers (domain-logic-organization), the mechanics of immutable types (java-immutability), or module dependency direction (layering-and-boundaries).4---56# Humble Objects and the Functional Core78## Purpose910Make the interesting part of a component testable by moving it out of the part that is hard11to test. A decision — which discount applies, whether to retry, which shard to route to,12what to render — is a function of data. An effect — writing to a socket, a database, a13screen — is not. Mixing them produces code where the only way to check a business rule is to14stand up the world.1516The techniques overlap. **Humble Object** extracts logic from a hard-to-test component so17its remaining boundary responsibilities can be tested narrowly; the extracted logic need18not be pure. **Functional core, imperative shell** additionally makes decisions functions19of explicit data while the shell owns effects and their consistency.2021The two failures this exists to prevent: logic trapped inside a framework component, so a22rule change is verified by a slow test that spins up HTTP and a database; and the opposite23excess, where every effect is wrapped in ceremony and the reader loses the actual work in a24pipeline of indirection.2526## Workflow2728Inspect the project's Java release/toolchain and framework/transaction configuration before29refactoring. The sealed outcomes and pattern switches below use Java 21 without preview;30retain existing alternatives on older targets rather than upgrading the project. Preserve31observable behavior, authorization, transaction boundaries and failure ordering. Report the32decision extracted, the effect contract retained and actual core/boundary checks; test speed33or reliability improvements remain unmeasured unless compared.34351. **Find the decision.** In the component, identify the branch that would be worth a test if36 it were reachable — the conditional, the calculation, the selection.372. **Name its inputs.** Everything the decision reads: parameters, fetched data, the clock,38 configuration. If an input arrives through I/O, the decision does not need the I/O, it39 needs the value.403. **Move the decision to a function of those inputs.** No fetching, no writing, no clock, no41 randomness — those become parameters. The result is a pure function or a small class with42 no collaborators.434. **Leave the shell humble.** What remains fetches, calls the decision, and performs the result.44 Branches expressing infrastructure policy may remain, but test them at the cheapest level that45 observes their effects rather than forcing every branch into a pure core.465. **Represent the outcome as data where the shell must act on it.** "Retry after 200 ms",47 "reject with this reason" — an outcome the shell interprets, not an effect the core48 performs.496. **Check the payoff.** The extraction earned its cost only if a real test got faster,50 simpler, or possible at all. If the same tests still need the same setup, revert it.5152## The split5354```text55 ┌──────────────────────── SHELL (imperative, humble) ──┐56 request ──►│ fetch what the decision needs │57 │ │ │58 │ ▼ │59 │ ┌── CORE (pure) ─────────────────────────┐ │60 │ │ inputs in, decision out. │ │61 │ │ No I/O, no clock, no randomness, │ │62 │ │ no framework, no mutation of anything │ │63 │ │ the caller can see. │ │64 │ └────────────────────────────────────────┘ │65 │ │ outcome (data) │66 │ ▼ │67 │ perform the effect it describes │68 └──────────────────────────────────────────────────────┘6970 Tests of the core: explicit input fixtures, outcomes and boundary cases.71 Tests of the shell: effects, wiring, failure order and consistency.72```7374What makes the core pure is not the absence of the word `void` — it is that **calling it twice75with the same inputs gives the same answer, and calling it zero times changes nothing**. The76clock and the random source are inputs like any other; passing `Instant` rather than calling77`Instant.now()` is usually the single highest-value move in this whole technique.7879## Decision rules8081```text82The logic is inside a controller, listener, scheduled method or UI83component, and has a branch worth testing84 → extract it. The framework component becomes humble: bind,85 delegate, respond.8687The logic needs data from a repository or a remote call88 → the shell fetches, the core receives the values. Do not pass89 the repository into the core "so it can fetch what it needs" —90 that reintroduces the collaborator you were removing.9192The decision needs the current time, a random value or a generated id93 → pass the sampled value for a pure core. Injecting Clock into94 a service gives controllable time, not production purity.9596The core must cause something to happen97 → return a description of it. The shell interprets. This is what98 makes retry, fallback and routing policy unit-testable99 (retries-and-backoff, circuit-breakers).100101The component has no interesting branch — it maps, binds or forwards102 → leave it. There is nothing to extract, and wrapping it in a103 port produces indirection (enterprise-architecture-smells).104105The work IS the effect: streaming bytes, a bulk UPDATE, a batch insert106 → do not split it. There is no decision to isolate, and the107 set-based operation belongs in the database108 (domain-logic-organization).109110Purity would force loading a large result set into memory to keep the111core pure112 → the boundary is in the wrong place. Push the filtering into113 the query and let the core decide over what comes back114 (architecture-and-performance).115```116117## Rules118119- Humility minimizes logic in the hard-to-test boundary; it does not make boundary tests worthless.120 Binding, authentication, transaction demarcation, serialization and failure translation can all121 deserve focused integration tests even when business decisions live in the core.122- Extract the decision, not the I/O. Wrapping a repository in an interface does not make the123 logic testable if the logic still lives in the shell; it only adds a seam124 (`java-dependency-inversion`).125- Heavy interaction mocking can signal that a decision is entangled with collaborators, but mocks126 are also legitimate for protocols, failure injection and orchestration. Inspect whether the test127 asserts stable outcomes or incidental call order before changing the design.128- Pass controllable ambient inputs when exact outcomes or boundary cases matter. Direct time/random129 calls do not automatically make every property test flaky, but they obstruct replay and precise130 failure diagnosis. `Clock` is in the JDK for the time calls; the id and the131 random source are supplied the same way, by the shell.132- **Purity is about observable effect, not about avoiding assignment.** A core that builds a133 local `ArrayList` and returns a stable result can be pure when no mutable aliases or134 mutable elements escape or change concurrently. Local mutation can simplify sequential code135 (`java-immutability`).136- The core is where records and sealed types pay for themselves: inputs as records, outcomes137 as a sealed hierarchy, the shell's handling as an exhaustive `switch` the compiler checks138 when a new outcome is added (`java-composition-over-inheritance`).139- Many distributed policies have a pure decision kernel, but breakers, adaptive limits and routing140 depend on concurrent, time-varying state. Model transitions explicitly and test both deterministic141 policy and thread-safe state/effect integration. Separating them from the call they govern is142 what makes them testable without a network, and what stops policy from being reimplemented143 slightly differently at each call site.144- Do not push effects into the core disguised as parameters. A `Runnable`, a `Consumer` or a145 callback handed to the core so it can "just call this" restores the impurity while hiding146 it from the signature.147- The shell is allowed to be dull and repetitive. Resisting duplication there, by inventing an148 abstraction over the effects, is how a humble shell becomes a framework nobody understands.149- **This is a component-level technique, not an architecture.** It composes with any layering150 and any domain-logic organisation, and it does not require ports, adapters or a hexagon151 (`layering-and-boundaries`).152153## References154155- [Applying the pattern to real components](references/humble-object-patterns.md) — the156 recurring applications: controller and presenter, scheduled job, message listener, gateway,157 and view; what stays in the framework component and what leaves; the Spring-specific version158 of each; and the diagnosis for a component that resists extraction. Read when restructuring a159 specific class that is hard to test.160- [The functional core in Java](references/functional-core-in-java.md) — expressing the core161 with records, sealed outcomes and exhaustive switches; effects as returned data; where162 mutation is legitimate; passing the clock and other ambient inputs; the allocation and163 readability costs and when they exceed the benefit. Read when writing or reviewing the core164 itself.