- Chapter
- Chapter 1 — Strategy
- Prerequisites
- Topic 1.1; Book 2 lambdas and functional programming basics
- You will be able to
- Implement strategies as interfaces, enums, lambdas and method references, pick the right java.util.function type or a custom functional interface, and register strategies as Spring beans selected at runtime
- Covers
- Interfaces; Lambdas; Functional interfaces
On this page
- 1. Concept / Definition
- 2. Why It Exists
- 3. Core Concepts
- 4. How It Works Internally — Lambdas on the JVM
- 4A. Deep Dive — Strategies as Spring Beans
- 5. APIs / Features / Syntax
- 6. Practical Examples
- 7. Common Problems & Mistakes
- 8. Performance & Production Considerations
- 9. Architecture & Design Considerations
- 10. Interview Questions & Answers
1Concept / Definition
The Strategy pattern is language-independent, but how you write it in Java has changed dramatically. Since Java 8, any interface with a single abstract method is a functional interface that can be implemented by a lambda or method reference — which removes most of the boilerplate the classic pattern required.

| Java form | Code size per strategy | Best for |
|---|---|---|
| Named class | 10–30 lines | Strategies with dependencies, names, tests of their own |
| Anonymous class | 5–10 lines | Legacy code (pre-Java 8) |
| Enum constant | 3–8 lines | Fixed, known set of variants |
| Lambda | 1–3 lines | Small, stateless, local strategies |
| Method reference | 1 line | Reusing an existing method as a strategy |
| Record | 3–10 lines | Parameterised strategies with validation |
2Why It Exists
Before Java 8, every strategy needed a class — often an anonymous inner class with more ceremony than logic. This friction pushed developers towards if/else instead. Lambdas and functional interfaces made passing behaviour as cheap as passing data, turning Strategy from a "design pattern" into everyday Java style: list.sort(comparing(Order::placedAt)) is a strategy in one line. Knowing the forms lets you choose the lightest one that still keeps the design clear.
3Core Concepts
3.1 Interfaces
The strategy contract is a Java interface. Design it deliberately:
@FunctionalInterface // compiler enforces single method
public interface RoundingPolicy {
BigDecimal round(BigDecimal amount);
// default methods add composition without breaking the functional shape
default RoundingPolicy andThen(RoundingPolicy next) {
return v -> next.round(round(v));
}
// static factories give names to common strategies
static RoundingPolicy bankers(int scale) {
return v -> v.setScale(scale, RoundingMode.HALF_EVEN);
}
static RoundingPolicy commercial(int scale) {
return v -> v.setScale(scale, RoundingMode.HALF_UP);
}
}| Interface design choice | Recommendation |
|---|---|
| Number of abstract methods | One — keeps it lambda-compatible |
@FunctionalInterface | Always, to document and enforce intent |
| Parameter types | Domain types (Parcel, Money), not Object or maps |
| Exceptions | Unchecked, or a result type; checked exceptions don't mix well with lambdas |
| Default methods | For composition (andThen, orElse) and convenience |
| Static factories | Named, reusable standard strategies |
Enum strategies suit a fixed set of variants that also needs names (for configuration or persistence):
public enum VatMode implements RoundingPolicy {
INVOICE_TOTAL { public BigDecimal round(BigDecimal v) {
return v.setScale(2, RoundingMode.HALF_UP); } },
PER_LINE { public BigDecimal round(BigDecimal v) {
return v.setScale(2, RoundingMode.HALF_EVEN); } };
}
VatMode mode = VatMode.valueOf(config.get("vat.mode")); // selection by name3.2 Lambdas
A lambda implements a functional interface inline. For small, stateless strategies it removes almost all ceremony:
// Classic anonymous class (pre-Java 8)
orders.sort(new Comparator<Order>() {
@Override public int compare(Order a, Order b) {
return a.placedAt().compareTo(b.placedAt());
}
});
// Lambda
orders.sort((a, b) -> a.placedAt().compareTo(b.placedAt()));
// Method reference + comparator combinators
orders.sort(Comparator.comparing(Order::placedAt).thenComparing(Order::total).reversed());Lambdas can capture effectively-final variables, which lets you build parameterised strategies on the fly:
static ShippingPolicy flat(Money amount) {
return (parcel, dest) -> amount; // captures 'amount'
}
static ShippingPolicy perKg(Money base, Money perKg) {
return (parcel, dest) -> base.plus(perKg.times(parcel.weightKg()));
}
| Consideration | Lambda | Named class |
|---|---|---|
| Stack traces / logs | lambda$select$3 — anonymous | ZoneBasedShipping — readable |
| Dependencies | Captured variables | Constructor-injected beans |
| Testing in isolation | Through the factory method | Directly |
| Configuration by name | Needs a registry key | Bean name / enum constant |
| Size of logic | A few lines | Any size |
3.3 Functional interfaces
The JDK's java.util.function package provides ready-made strategy shapes. Using them avoids declaring new interfaces for simple cases:

public final class ReportService {
private final Predicate<Order> include; // filtering strategy
private final Function<Order, String> label; // formatting strategy
private final Comparator<Order> order; // ordering strategy
public ReportService(Predicate<Order> include, Function<Order, String> label,
Comparator<Order> order) {
this.include = include;
this.label = label;
this.order = order;
}
public List<String> lines(List<Order> orders) {
return orders.stream().filter(include).sorted(order).map(label).toList();
}
}
var service = new ReportService(
o -> o.status() == Status.OPEN,
o -> o.id() + " " + o.total(),
Comparator.comparing(Order::total).reversed());Generic vs domain-specific interfaces. BiFunction<Parcel, Destination, Money> works, but ShippingPolicy is self-documenting, can carry default methods and static factories, and appears meaningfully in stack traces and Javadoc. Rule of thumb: use java.util.function for local, technical strategies; declare a named functional interface for strategies that are part of your domain or public API.
| Built-in type | Domain alternative | When to prefer the domain type |
|---|---|---|
Predicate<Order> | OrderFilter | When it appears in public APIs or configuration |
Function<Cart, Money> | DiscountPolicy | Always for business rules |
Supplier<Instant> | Clock (JDK) | Use the existing JDK abstraction |
BiFunction<Parcel, Destination, Money> | ShippingPolicy | Always — two-argument generics are hard to read |
4How It Works Internally — Lambdas on the JVM
1. The compiler turns the lambda body into a private synthetic method in the enclosing class.
2. At the call site it emits an invokedynamic instruction bootstrapped by LambdaMetafactory.
3. On first execution the JVM spins a hidden class implementing the functional interface; non-capturing lambdas are cached as a single instance.
4. Subsequent calls are ordinary interface calls — typically inlined by the JIT.
| Lambda kind | Allocation per evaluation |
|---|---|
Non-capturing ((a, b) -> a.compareTo(b)) | None — cached singleton |
Capturing ((p, d) -> amount) | One small object per evaluation of the lambda expression |
| Method reference to static method | None |
Bound method reference (obj::method) | One object capturing obj |
For strategies created once (at startup or per configuration change), these costs are irrelevant.
4ADeep Dive — Strategies as Spring Beans

When strategies need dependencies (repositories, HTTP clients, configuration), make each a Spring bean and let the container build the registry:
public interface FraudCheck {
FraudVerdict assess(Payment p);
String name();
}
@Component class VelocityCheck implements FraudCheck { /* uses Redis counters */ }
@Component class GeoMismatchCheck implements FraudCheck { /* uses GeoIP client */ }
@Component class DeviceFingerprintCheck implements FraudCheck { /* uses vendor API */ }
@Service
class FraudService {
private final Map<String, FraudCheck> checks; // bean name → strategy
FraudService(List<FraudCheck> all) {
this.checks = all.stream()
.collect(Collectors.toUnmodifiableMap(FraudCheck::name, c -> c));
}
// per-tenant selection of enabled checks
FraudVerdict assess(Payment p, List<String> enabled) {
return enabled.stream().map(checks::get).map(c -> c.assess(p))
.reduce(FraudVerdict.PASS, FraudVerdict::worst);
}
}| Spring mechanism | Use |
|---|---|
List<T> injection | All implementations, in @Order order |
Map<String, T> injection | Keyed by bean name |
@Qualifier / @Primary | Pick one strategy at injection time |
@ConditionalOnProperty | Enable a strategy by configuration |
ObjectProvider<T> | Lazy or optional strategy resolution |
Validate the registry at startup: if configuration references "deviceCheck" but no bean has that name, fail fast rather than at the first payment.
5APIs / Features / Syntax
| Feature | Strategy use |
|---|---|
@FunctionalInterface | Declare strategy contracts |
| Lambdas, method references | Inline strategies |
Comparator.comparing/thenComparing/reversed | Composable ordering strategies |
Predicate.and/or/negate | Composable filters |
Function.andThen/compose | Pipelines of transformations |
| Enums with bodies | Named, fixed strategy sets |
ServiceLoader | Plug-in strategies without Spring |
6Practical Examples
6.1 Composing predicates as strategies
Predicate<Order> open = o -> o.status() == Status.OPEN;
Predicate<Order> highValue = o -> o.total().isAtLeast(Money.eur("1000"));
Predicate<Order> domestic = o -> o.country() == Country.IN;
Predicate<Order> reviewQueue = open.and(highValue.or(domestic.negate()));6.2 Choosing a strategy from configuration
@Bean
RoundingPolicy rounding(@Value("${billing.rounding:bankers}") String mode) {
return switch (mode) {
case "bankers" -> RoundingPolicy.bankers(2);
case "commercial" -> RoundingPolicy.commercial(2);
default -> throw new IllegalStateException("unknown rounding " + mode);
};
}6.3 Plug-in strategies with ServiceLoader
ServiceLoader.load(ExportFormat.class).stream()
.map(ServiceLoader.Provider::get)
.forEach(f -> registry.put(f.id(), f));Third-party JARs add formats by declaring provides ExportFormat with ... in their module-info.java.
7Common Problems & Mistakes
- Long lambdas (20+ lines) that should be named classes.
- Generic BiFunction types in public APIs, obscuring meaning.
- Capturing mutable state in lambdas used concurrently.
- Checked exceptions forcing awkward wrapping inside lambdas — design the interface for it.
- Registry built from reflection/strings without startup validation.
- Anonymous lambdas in logs — hard to diagnose which strategy ran.
8Performance & Production Considerations
| Aspect | Guidance |
|---|---|
| Lambda creation | Create strategies once; avoid building capturing lambdas in hot loops |
| Inlining | Few implementations per call site inline well |
| Naming | Give strategies toString() or a name() for logs and metrics |
| Hot reload | Rebuild strategy registries atomically on configuration change |
| GraalVM native | Lambdas and method references are supported; reflection-based registries need hints |
9Architecture & Design Considerations
- Prefer domain-named functional interfaces for business strategies; use JDK functional types for technical plumbing.
- Keep strategies pure (inputs → output) where possible; inject dependencies explicitly otherwise.
- Build one registry per strategy family, validated at startup, observable at runtime.
- Treat strategy names as part of configuration contracts — rename with migration.
10Interview Questions & Answers
What is a functional interface?
A functional interface is an interface with exactly one abstract method, so a lambda or method reference can implement it. Default and static methods do not count, nor do abstract redeclarations of public Object methods such as equals. Comparator, Runnable, Callable and everything in java.util.function, like Predicate and Function, are functional interfaces. The @FunctionalInterface annotation is optional, but it documents intent and makes the compiler reject a second abstract method that would break every lambda implementing the interface. For strategies this matters: a single-method RoundingPolicy can be supplied as v -> v.setScale(2, RoundingMode.HALF_EVEN), while default methods such as andThen add composition without breaking the functional shape.
How does a lambda relate to the Strategy pattern?
A lambda is a concise implementation of a strategy. The Strategy pattern defines a family of interchangeable algorithms behind a common interface and lets the client choose one at run time. If that interface is functional, each concrete strategy can be a lambda or method reference instead of a separate class, so behaviour is passed around as a value. list.sort(Comparator.comparing(Order::placedAt)) is the Strategy pattern in one line: List.sort is the context, Comparator is the strategy interface and the method reference is the concrete strategy. Lambdas can also capture effectively final variables to build parameterised strategies, such as a flat shipping policy that returns a captured amount.
When would you use an enum instead of lambdas for strategies?
Use an enum when the set of variants is fixed, known at compile time and needs stable names. Enum constants can implement a strategy interface with their own bodies, as VatMode.INVOICE_TOTAL and PER_LINE do, and can be selected by name from configuration or stored in a database column using valueOf and name(). You also get values() for listing options, EnumMap and exhaustive switch support, readable names in logs and singletons for free. Lambdas suit open-ended, local or parameterised strategies created in code. The trade-off is that renaming an enum constant becomes a data migration, and adding a variant means recompiling, so enums do not fit plug-in or tenant-defined strategies.
Name four java.util.function interfaces and a strategy use for each.
Predicate<T> takes a value and returns a boolean, so it is a filtering strategy, for example o -> o.status() == Status.OPEN, composable with and, or and negate. Function<T, R> transforms one value into another, so it suits mapping or formatting strategies such as o -> o.id() + " " + o.total(), and composes with andThen and compose. Supplier<T> takes nothing and returns a value, making it a strategy for generating or lazily creating values, such as ID generators or default factories. Consumer<T> accepts a value and returns nothing, so it fits sinks like sending notifications or writing audit entries. UnaryOperator, BinaryOperator and BiFunction cover other shapes, and primitive variants avoid boxing.
When should you declare your own functional interface rather than use a JDK one?
Declare your own when the strategy is part of the domain or a public API. A ShippingPolicy with cost(Parcel, Destination) documents intent far better than BiFunction<Parcel, Destination, Money>, whose meaning lives only in a variable name. A named interface can carry Javadoc, default methods for composition, static factories for standard strategies such as RoundingPolicy.bankers(2), and checked exceptions or domain result types if needed. It also appears meaningfully in stack traces, IDE searches and Spring bean types, and two unrelated BiFunctions can no longer be swapped by mistake. Use JDK types for local, technical plumbing inside a method or class, where a new interface would add ceremony without meaning.
How do you register strategies in Spring?
Make each strategy a Spring bean implementing a common interface, typically with @Component, and let the container collect them. Injecting List<FraudCheck> gives every implementation, ordered by @Order, while Map<String, FraudCheck> gives them keyed by bean name. The service then builds an immutable registry, often keyed by a name() method rather than bean names so the key is an explicit contract, and selects strategies per request, tenant or configuration. @Qualifier or @Primary pick a single strategy at injection time, and @ConditionalOnProperty enables one through configuration. Validate the registry at startup: if configuration names a strategy with no matching bean, fail the application context instead of the first request.
What are the downsides of lambdas as strategies?
Lambdas are anonymous, which hurts operations: stack traces show names like lambda$select$3, and logs cannot say which strategy ran unless you add a name or toString. They are harder to test in isolation, because you usually test them through the factory method or class that creates them. They fit poorly when logic grows beyond a few lines, needs several dependencies, or must be configured and referenced by name. Capturing mutable state can create concurrency bugs, and checked exceptions force awkward wrapping. A lambda's identity and equality are also unspecified, so never use lambdas as map keys or compare them with ==. Promote such strategies to named classes or enums.
How would you expose extension points in a shared platform library?
I would define small, documented functional interfaces for each policy the platform lets consumers change, such as RetryPolicy, TenantResolver or AuditSink, using domain types in the signatures and keeping one abstract method so consumers can plug in a lambda. Each interface ships sensible defaults through static factories, like RetryPolicy.exponential(3), plus default methods for composition. The library accepts implementations through constructors or builders, Spring auto-configuration with @ConditionalOnMissingBean so a consumer's bean overrides the default, and ServiceLoader for non-Spring users. I treat these interfaces as public APIs: semantic versioning, compatibility tests, deprecation before removal, and never adding an abstract method in a minor release, because that breaks every lambda.