Production Apex test suites grow with the code they cover, because Salesforce requires code coverage to deploy. In a mature org, a full test run often takes tens of minutes, sometimes hours. That delay hurts:
- Every agent or developer who runs the tests before committing waits for the same slow suite.
- Continuous integration takes longer to report. CI queues can back up.
- Deployment is slower because of test coverage requirements.
- Packaging is slower because of test coverage requirements.
- Agents that create more tests may be suppressed, thus losing the value of their speed and coverage.
The functional programming world has a fix: the functional core, imperative shell pattern.
Domain logic lives in a functional core, ideally as pure calculations that take values in and return values out.
Wrapped around it is a thin imperative shell where the side effects happen. In Apex, those side effects are database reads and writes: SOQL queries and DML.
Put simply: keep your logic in memory-only methods, and keep the database out of as much of your domain logic as possible.
To measure the difference, we wrote a Streamline script that runs the same Apex tests against three styles of trigger handler:
- A typical imperative handler.
- An imperative shell around a functional core.
- The functional core tested in memory without the database.
Here are the timings from its debug log:
The functional tests assert the same business logic as the imperative tests.
Observations
1You can have this now. Copy this prompt and paste it into any agent that can read your Apex code and tests:
Review my Apex code and tests and find where tests are slow because domain logic is mixed with SOQL and DML.
Read this example to learn the functional core, imperative shell pattern and how to test the core in memory: https://github.com/nextdoc/apex-optimisation/blob/master/apex/fast-testing.apex
Apply the same approach to my code. Start with the slowest test classes, and keep every existing assertion passing.
2This technique works as long as your tests and their assertions don’t rely on platform features outside your code. If a result depends on a workflow or flow running as part of the database transaction, an in-memory test can’t cover it. You can still arrange the code so the logic is tested in isolation and the combination is tested separately.
In other words, the in-memory tests are true unit tests, and the tests that combine code with the database and platform automation are integration tests. You still need some integration tests, but you’ll want fewer of them because they’re slow. You can also split your CI runs: skip integration tests on regular commits and run them nightly or before a release.
3A functional core is also more composable. Pure methods take values and return values, so you can combine and reuse them freely without worrying about database state. Streamline magnifies that value: it combines and invokes these small units in under a second, so you can try out coding ideas or build tests the same way this script runs.
4A common alternative is mocking the database. Mocks can make tests fast too, but the logic still reads and writes through the database layer, so you don’t get the composability of a functional core.