You want a default order when the lookup misses, or a new UUID only if you actually insert a row, or a repository that should not hit the database at class-load. Passing an already-built value means you paid for it even on the path that never uses it. Passing a Supplier means “build this if you need it.”

A Supplier produces a value when asked, so the caller can wait until it actually needs one. The series hub owns the glossary; here we only care about get, the JDK slots that take a Supplier, and the difference between orElse and orElseGet.

The default you computed too early

Optional.orElse always evaluates its argument. That is fine for a constant. It is a bug when the fallback is work:

Order order = findOrder(id)
        .orElse(loadGuestOrderFromDisk()); // always hits disk

orElseGet takes a Supplier. The disk is touched only on empty:

Order order = findOrder(id)
        .orElseGet(this::loadGuestOrderFromDisk);

Same return type. Different when. That is the whole point of the shape: no input, one output, called at most when the API decides.

The contract

@FunctionalInterface
public interface Supplier<T> {
    T get();
}

No default methods. get is the SAM. There is nothing to compose — a supplier does not see an argument, so andThen would have nothing to thread. If you need “produce, then map,” wrap at the call site (mapper.apply(supplier.get())) or use Optional / CompletableFuture, which already chain.

MethodJob
get()Produce the value, now

A constructor reference with a no-arg constructor is a Supplier:

Supplier<ArrayList<Order>> newList = ArrayList::new;
Supplier<String> id = () -> UUID.randomUUID().toString();

ArrayList::new can also match a capacity Function. That choice is on the hub; the rule here is: zero-arg production is a Supplier.

Where the JDK already asks for Supplier

APISlotTypical lambda
Optional.orElseGetSupplier<? extends T>this::loadGuestOrderFromDisk
Optional.orElseThrowSupplier<? extends X>() -> new OrderNotFoundException(id)
Objects.requireNonNullElseGetSupplier<? extends T> (Java 9)fallback factory
CompletableFuture.supplyAsyncSupplier<U>this::loadOrder
ThreadLocal.withInitialSupplier<? extends T>ArrayList::new
Logger / guarded logsSupplier<String> (Java 9+ / some facades)() -> expensiveDump()

orElseThrow is a Supplier of the exception, not of the missing value — so you do not build the exception on the hit path:

Order order = findOrder(id)
        .orElseThrow(() -> new OrderNotFoundException(id));

CompletableFuture.supplyAsync is the async cousin: the supplier runs on another thread. CompletableFuture is a separate API; the type it takes for “produce a result” is still Supplier.

A later holder, LazyConstant (preview, not Java 8), extends Supplier<T>. It computes once, then get is cheap. If you already take a Supplier, you can pass one without an adapter — when that API is actually on your JDK.

static final LazyConstant<PaymentGateway> GATEWAY =
        LazyConstant.of(PaymentGateway::connect);

PaymentGateway gateway = GATEWAY.get(); // Supplier.get

Note: orElse(x) vs orElseGet(supplier) is the teaching pair. Use orElse for a cheap constant (orElse(Order.GUEST)). Use orElseGet the moment the fallback does I/O, allocates a graph, or throws a constructed exception you only want on the miss path.

Factories, not singletons

A Supplier is a factory slot, not a reason to hide new. Passing ArrayList::new into ThreadLocal.withInitial is good: the API calls get per thread. Passing () -> AppContext.getInstance().orders() is a Singleton in a lambda costume — the supplier did not remove the global; it wrapped it.

When the thing you produce is a domain collaborator (PaymentGateway), prefer a named factory or constructor injection. A Supplier<PaymentGateway> is appropriate when creation must be deferred (first use, per request, after a config load). It is not a substitute for knowing who owns new.

Pitfalls

Calling get yourself too early. Supplier only defers if someone else calls get. orElse(supplier.get()) is orElse with extra steps — you just forced the work.

Checked exceptions. get does not declare them. Callable.call does — that is why it lives in java.util.concurrent and why supplyAsync takes a Supplier instead. Wrap at the boundary.

Sharing a supplier that mutates. () -> { count++; return nextOrder(); } is a factory with a side effect. Fine if the owner of count is obvious. A race if two threads call get and you pretended it was a pure default. ThreadLocal.withInitial is the JDK’s answer for per-thread mutable starts.

Capturing a changing id. () -> new OrderNotFoundException(id) closes over id. If you build one supplier and reuse it after id changed, the exception lies. For orElseThrow the usual writing is an inline lambda next to the lookup, so the capture is the id in scope now.

Cheat sheet

SAM          T get()
No defaults  nothing to compose; chain at the call site or via Optional / Future

JDK slots    Optional.orElseGet / orElseThrow
             Objects.requireNonNullElseGet   (Java 9)
             CompletableFuture.supplyAsync
             ThreadLocal.withInitial
             LazyConstant.of          later preview; extends Supplier

orElse(x)       always evaluate x
orElseGet(s)    s.get() only on empty

Do           this::loadGuestOrderFromDisk in orElseGet
             ArrayList::new as withInitial
Don't        orElse(expensive())          supplier.get() then orElse
             wrap a singleton and call it lazy

Do:

  • Pass a Supplier when the API should decide whether to produce.
  • Use constructor / method references for zero-arg factories (ArrayList::new, this::connect).
  • Prefer orElseGet / orElseThrow over orElse the moment the fallback is work.

Don’t:

  • Call get() and then pass the value into an API that wanted a Supplier.
  • Hide a global getInstance() behind () -> … and call it inversion.
  • Put checked I/O in get without a boundary policy.

Wrap-up

Supplier<T> is the JDK name for “produce a T when asked.” get is the SAM; there is nothing to compose on the type itself. You meet it in orElseGet, supplyAsync, and withInitial. Use it to defer work. The next family keeps the same type on the way in and out — operators, replaceAll, reduce.

Next optional step in the series Same type in, same type out — replaceAll, reduce, minBy / maxBy. Unary and Binary Operators: Same Type In, Same Type Out