Using Futures in Scala application code and tests
How Scala Future starts, composes and fails; how ExecutionContext choices affect Play services; and how to test asynchronous behaviour without sleeps or misleading mocks.
A Future represents one running or completed result
Scala Future[A] represents a value that may become available later or fail with a Throwable. Creating a Future with Future { ... } submits its body to an ExecutionContext immediately. It is eager, unlike an effect value such as Cats Effect IO that describes work for later interpretation.
A Future memoises its completion. Several callbacks attached to the same instance observe the same success or failure; they do not rerun its body. Calling a method that creates a new Future several times is different because each call creates and schedules new work.
Keep construction visible when timing matters. A val holding an existing Future and a def returning a fresh Future have similar types at the call site but different execution behaviour.
def fresh(): Future[UUID] = Future(UUID.randomUUID())
val shared: Future[UUID] = fresh()
val first = shared.map(identity)
val second = shared.map(identity) // observes the same completion
val another: Future[UUID] = fresh() // schedules new workmap and flatMap sequence successful completion
map transforms a successful result and propagates an existing failure. flatMap is required when the next function returns another Future. If a callback throws, the Future returned by map or flatMap completes as a failure.
A for-comprehension translates to flatMap and map. Creating every independent Future before the comprehension lets the operations start independently; creating the second inside a generator makes it wait for the first.
Parallelism is not automatically faster or safer. Start work independently only when there is no data dependency, the downstream system can accept the concurrency and failure of one operation does not require preventing the other from starting.
val accountF: Future[Account] = accounts.find(accountId)
val scoreF: Future[Score] = scores.find(accountId)
val assessmentF: Future[Assessment] = for {
account <- accountF
score <- scoreF
} yield Assessment(account, score)
// Dependent: the second call starts only after account arrives.
val historyF = accounts.find(accountId).flatMap { account =>
history.forAccount(account.id)
}The ExecutionContext is an operational dependency
Every transformation needs an ExecutionContext on which its callback can run. In a Play application, the default context is intended for asynchronous, non-blocking application work. Blocking database, file or network calls can occupy those threads and reduce request throughput.
Moving a blocking call into Future does not make the call non-blocking; it moves the wait to a thread. Isolate unavoidable blocking work on an appropriately configured execution context and keep the ownership of that context clear in application wiring.
Do not create a new thread pool per request or service instance. Pool size, queueing and shutdown are application concerns. Monitor saturation and latency rather than assuming the presence of Future means concurrency is healthy.
import scala.concurrent.{blocking, ExecutionContext, Future}
final class LegacyAccountRepository(
client: BlockingAccountClient,
blockingEc: ExecutionContext
) {
def find(id: AccountId): Future[Option[Account]] =
Future {
blocking(client.find(id))
}(blockingEc)
}
// The controller and service need not know how the adapter blocks.Keep expected outcomes distinct from failed Futures
A failed Future means no normal result was produced: an unexpected exception escaped, a database was unavailable or another operational failure occurred. A value such as Future[Either[RenameError, Account]] can distinguish that channel from a known application rejection.
recover transforms selected failures into successful values; recoverWith starts another asynchronous recovery. Catch only failures the current boundary can interpret. Recovering every Throwable into an empty result can turn an outage or defect into false business data.
Future has no general cancellation or resource-lifetime model. A request timing out does not necessarily stop the underlying work. APIs that acquire resources must own their release independently of whether a caller continues waiting.
def find(id: AccountId): Future[Either[FindError, Account]] =
repository.find(id).map {
case Some(account) => Right(account)
case None => Left(FindError.NotFound(id))
}.recover {
case error: InvalidStoredAccount =>
Left(FindError.InvalidData(error.field))
}
// Connection failures still fail the Future for the outer boundary to handle.Test completion instead of waiting for time to pass
Never use Thread.sleep to give a Future time to finish. It makes tests slow and timing-dependent. ScalaTest provides ScalaFutures and asynchronous suite styles that complete the test from the Future itself.
futureValue is convenient for focused tests when patience settings are sensible. When testing a failure, inspect failed.futureValue or recoverToExceptionIf rather than wrapping Await.result in an intercept block. Await is best kept at process boundaries and out of ordinary production composition.
Use Future.successful and Future.failed for dependencies whose answer is already known. Constructing Future { value } in a stub schedules work that the test does not need and makes execution depend unnecessarily on a thread pool.
class AccountServiceSpec
extends AsyncWordSpec
with Matchers {
"rename" should {
"return the updated account" in {
val repository = new StubRepository(Some(account))
service(repository).rename(account.id, newName).map { result =>
result shouldBe Right(expected)
}
}
"retain repository failure" in {
val failure = new RuntimeException("database unavailable")
val repository = StubRepository.failed(failure)
recoverToExceptionIf[RuntimeException] {
service(repository).rename(account.id, newName)
}.map(_ shouldBe failure)
}
}
}Control promises only when the timing is under test
Promise[A] gives a test explicit control over when a Future completes. It is useful for proving that a second operation does not begin before a prerequisite, or that independently created work is not accidentally serialised.
A Promise-heavy test can be as coupled as a mock-heavy one. Use it for a concurrency property visible in the service contract, not to reproduce every callback. Complete every Promise or ensure the test can terminate on failure.
ExecutionContext.parasitic can execute short callbacks on the completing thread, but using it broadly in tests can hide scheduling assumptions that differ in production. Prefer the same execution model as the application unless the test is deliberately isolated to pure, immediate transformations.
val accountResult = Promise[Account]()
val repository = new StubRepository(accountResult.future)
val result = service(repository).loadAndScore(accountId)
scoring.requests shouldBe empty
accountResult.success(account)
whenReady(result) { assessment =>
assessment.account shouldBe account
}
scoring.requests should contain(account.id)Test the adapters Future cannot abstract away
A unit test can prove how a service composes successful and failed Futures. It cannot prove that the real database client is genuinely asynchronous, that the configured pool is large enough or that a Play action serialises failures correctly.
Add integration tests for the real repository and HTTP boundary, and observe thread pools, latency and failures under a representative workload. Production support needs logs with useful context at the boundary that finally handles a failed Future—not the same exception logged in every map and flatMap.
Review Future code by tracing construction, dependency and failure. Ask when the work starts, which execution context runs it, whether operations are independent, what a failed Future means and how the test knows completion occurred.
- Is blocking work isolated from Play’s default execution context?
- Are independent Futures created before they are combined?
- Do typed application rejections remain values rather than failed Futures?
- Does recovery preserve unexpected operational failures?
- Do tests wait on completion rather than elapsed time?
- Are real adapters and failure paths covered beyond mocked unit tests?