Resource<T> as composable managed resource — alternative to try-with-resources
Context
try-with-resources works well for a single AutoCloseable but becomes awkward when
multiple resources must be acquired sequentially, composed, or conditionally released.
It cannot be stored, passed, or reused across call sites as a value, and it cannot
integrate with the library’s typed error model (Try, Result, Either).
Functional pipelines cannot express resource acquisition and release as composable values.
Decision
Provide Resource<T> — a final class that pairs an acquisition function with a
guaranteed release action. The resource is only live during the execution of use(fn):
the resource is acquired just before the body runs, and the release function is
always called when the body completes, whether it succeeds or throws.
Factories:
Resource.of(CheckedSupplier<? extends T> acquire, CheckedConsumer<? super T> release)— the primary factory; acquires and releases on everyuse()call.Resource.fromAutoCloseable(CheckedSupplier<? extends T> acquire)— convenience wrapper forAutoCloseabletypes; usesAutoCloseable::closeas the release function.Resource.eval(Try<? extends T> acquired, CheckedConsumer<? super T> release)— wraps a pre-computedTry<T>; if theTryis already a failure,use()returns that failure immediately andreleaseis never called. One-shot contract: callinguse()more than once releases the same value; preferof()when reuse is required.
Core operations:
use(CheckedFunction<? super T, ? extends R> body)→Try<R>— acquires, runs the body, releases, and returns the result. Both success and failure are captured as values.useAsResult(body, onError)→Result<R, E>— likeuse(), but the body returns aResultdirectly; anyThrowablefrom acquire, release, or an unexpected body exception is mapped toEviaonError, eliminatingTry<Result<R,E>>nesting.useAsEither(body, onError)→Either<E, R>— symmetric withuseAsResult()for code that models results as neutralEithervalues.
Transformations (composition without nesting):
map(Function<? super T, ? extends R> fn)→Resource<R>— transforms the resource value without changing acquire/release; iffnthrows, the resource is still released.flatMap(Function<? super T, ? extends Resource<R>> fn)→Resource<R>— sequences two resources; both are released in reverse acquisition order (inner first, then outer), mirroring nestedtry-with-resourcessemantics.mapTry(Function<? super T, ? extends Try<? extends R>> fn)→Resource<R>— likemap(), but the mapping function returns aTry<R>; useful when the transformation is itself a fallible operation (e.g., parsing or validation).
Exception-merging contract (matches try-with-resources):
| Body | Release | Outcome |
|---|---|---|
| Success | Success | Try.success(result) |
| Success | Throws | Try.failure(releaseException) |
| Throws | Success | Try.failure(bodyException) |
| Throws | Throws | Try.failure(bodyException) — release exception suppressed onto body |
The body exception always takes priority; the release exception is suppressed (via
Throwable.addSuppressed), not discarded. This is identical to the JDK behaviour for
try-with-resources.
Internal design: Resource<T> wraps a private Effect<T> interface whose single
method is <R> Try<R> run(CheckedFunction<? super T, ? extends R> body). Because this
method carries its own type parameter <R>, it cannot be implemented by a lambda
(Java lambdas cannot introduce new type parameters). Anonymous class instances are used
throughout instead. The CheckedFunction, CheckedSupplier, and CheckedConsumer
types are the API surface defined in
ADR-019 — Checked functional interfaces.
Consequences
Positive:
- Resource acquisition and release are expressed as values — testable, storable, and composable across call sites.
flatMapchains resources whose lifetimes overlap without nestedtryblocks; the release order is deterministic (reverse acquisition).use()is independent on each call: the sameResource<T>can be reused — each invocation goes through a full acquire/run/release cycle (forof/fromAutoCloseable).- Typed integration:
useAsResultanduseAsEithereliminate theTry<Result<R,E>>nesting that would otherwise appear at domain service boundaries. - Exception behaviour is identical to
try-with-resources— no surprises for teams already familiar with JDK resource handling.
Negative / tradeoffs:
- Less familiar than
try-with-resources; requires developers to understand the acquire/use/release value model. eval()has a one-shot contract that is easy to violate: callinguse()more than once on aneval-backed resource releases the same underlying value each time.- The internal
Effect<T>anonymous-class pattern (required by the generic method) is more verbose than a lambda-based design would be. flatMapuses asneakyThrowto re-propagate inner failures through the outer resource’s lifecycle without wrapping them — the JVM sees the original throwable, but the technique relies on type-erasure and requires a@SuppressWarnings("unchecked").
Alternatives considered
try-with-resources: idiomatic Java, guaranteed release, but not composable as a value; cannot be stored, passed, or chained without introducing nesting.CompletableFuturewithwhenComplete: asynchronous semantics overpowers synchronous resource management; adds complexity and thread-switching overhead.- Loan pattern (callback only): similar to
Resource.use, but the resource itself cannot be stored and composed as a first-class value before callinguse. - Making
Resource<T>a record or sealed interface:Effect<T>requires a method with its own type parameter<R>, which Java lambdas cannot implement; an interface or record cannot carry anonymous-class implementations in the same ergonomic way.