Configure @Failover¶
@Failover supports several configuration styles — from a single hardcoded expiry to fully expression-driven, environment-specific values. This guide covers all options with examples.
0. Placement — where @Failover actually takes effect¶
@Failover is applied by a Spring AOP (CGLIB) proxy, so it only works when the proxy can intercept the call. Put it on a public, non-static, non-final method of a concrete @Component/@Service class (a non-final class) — and call that method from another bean.
It is silently not applied when:
| Placement | Why it fails |
|---|---|
| On an interface, but the bean is a concrete class that implements it (annotation only on the interface) | The bean is proxied by CGLIB, which advises the implementation; annotate the concrete method. |
On a private, static, or final method | The proxy cannot intercept/override it. |
On a method of a final class | CGLIB cannot subclass the class. |
| Self-invocation — the bean calls its own annotated method | The call doesn't go through the proxy. Call via the injected bean reference, or move the method to a separate bean. |
Interface beans (Feign, Spring Data, @HttpExchange) are the exception
When the bean itself is an interface — a @FeignClient, a Spring Data repository, an @HttpExchange client — Spring proxies it with a JDK dynamic proxy at the interface level. There the interface method is the right place for @Failover, and it works. The rule above is only about concrete-class beans (CGLIB), where the annotation must be on the implementation method.
Startup check
The framework logs a WARN at startup for every @Failover on a concrete-class bean that cannot be advised (annotation only on a supertype/interface, non-public/static/final method, or final class) — e.g. "Failover 'x' on Foo#bar will NOT be applied …". Interface beans (Feign / Spring Data / @HttpExchange) and JDK proxy classes are intentionally not warned, since interface-level placement is correct for them. Self-invocation is a runtime call-graph property and cannot be detected statically, so it is not warned — avoid it by construction. The failover.registered.total gauge and the failover health indicator also report how many failovers were discovered.
1. Fixed Expiry (Same Across All Environments)¶
Use expiryDuration and expiryUnit when the expiry value is the same in every environment:
@Failover(
name = "country-by-code",
expiryDuration = 24,
expiryUnit = ChronoUnit.HOURS
)
Country findByCode(String code);
expiryUnit accepts any java.time.temporal.ChronoUnit constant: MINUTES, HOURS, DAYS, WEEKS, MONTHS, YEARS.
2. Expression-Based Expiry (Environment-Specific Values)¶
Use expiryDurationExpression and expiryUnitExpression to resolve expiry from application properties, environment variables, or any Spring EL expression. When either expression is set, it takes priority over the corresponding fixed attribute.
Property / YAML file¶
@Failover(
name = "country-by-code",
expiryDurationExpression = "${failover.country.expiry-duration:24}",
expiryUnitExpression = "${failover.country.expiry-unit:HOURS}"
)
Country findByCode(String code);
Each profile overrides the expiry independently. The default values (24 / HOURS) apply when the property is absent.
Environment variable¶
@Failover(
name = "country-by-code",
expiryDurationExpression = "${FAILOVER_COUNTRY_EXPIRY_DURATION:24}",
expiryUnitExpression = "${FAILOVER_COUNTRY_EXPIRY_UNIT:HOURS}"
)
Country findByCode(String code);
Set environment variables for production containers:
Spring EL (computed value)¶
@Failover(
name = "country-by-code",
expiryDurationExpression = "#{@failoverProperties.countryExpiryDuration}",
expiryUnitExpression = "#{@failoverProperties.countryExpiryUnit}"
)
Country findByCode(String code);
Any valid Spring EL expression is accepted: bean property access, arithmetic, conditionals.
3. Custom Key Generator¶
By default, the key is derived from the method arguments (joined and hashed to a UUID). Override it with a named bean:
@Component("countryKeyGen")
public class CountryKeyGenerator implements KeyGenerator {
@Override
public String key(Failover failover, List<Object> args) {
// normalise to uppercase for case-insensitive lookup
return ((String) args.get(0)).toUpperCase(Locale.ROOT);
}
}
@Failover(
name = "country-by-code",
keyGenerator = "countryKeyGen",
expiryDuration = 24,
expiryUnit = ChronoUnit.HOURS
)
Country findByCode(String code);
4. Custom Expiry Policy¶
Override how expiry is calculated for a specific failover:
@Component("countryExpiryPolicy")
public class CountryExpiryPolicy implements ExpiryPolicy<Country> {
@Override
public Instant computeExpiry(Failover failover) {
// expires at midnight UTC
return LocalDate.now(ZoneOffset.UTC).plusDays(1)
.atStartOfDay(ZoneOffset.UTC).toInstant();
}
@Override
public boolean isExpired(Failover failover, ReferentialPayload<Country> payload) {
return payload.getExpireOn().isBefore(Instant.now());
}
}
@Failover(
name = "country-by-code",
expiryPolicy = "countryExpiryPolicy",
expiryDuration = 24,
expiryUnit = ChronoUnit.HOURS
)
Country findByCode(String code);
When expiryPolicy is set, computeExpiry on the custom policy is called — expiryDuration / expiryUnit are only used by isExpired if the custom policy delegates to the default expiry check.
5. Domain Grouping¶
Share store entries between a single-entity endpoint and a scatter/gather list endpoint:
// Single-entity: stores under domain "country", key derived from "FR"
@Failover(
name = "country-by-code",
domain = "country",
expiryDuration = 24,
expiryUnit = ChronoUnit.HOURS
)
Country findByCode(String code);
// Batch: scatter-stores each entity individually under domain "country"
@Failover(
name = "countries-by-codes",
domain = "country",
payloadSplitter = "countrySplitter",
expiryDuration = 24,
expiryUnit = ChronoUnit.HOURS
)
List<Country> findByCodes(String csvCodes);
A successful findByCodes("FR,DE") stores FR and DE individually. On failure, findByCode("FR") recovers FR from the same store partition without a separate findByCodes call ever having succeeded.
Warning
All @Failover annotations in the same domain must use the same expiry configuration. Mismatched expiry causes the last writer to overwrite the stored expiry timestamp. A startup WARN is logged when mismatched expiry is detected.
6. PayloadSplitter + recoverAll Combinations¶
6a. Batch by IDs (standard scatter/gather)¶
@Failover(
name = "countries-by-ids",
domain = "country",
payloadSplitter = "countrySplitter",
expiryDuration = 24,
expiryUnit = ChronoUnit.HOURS
)
List<Country> findByIds(String csvIds);
countrySplitter.splitOnRecover reads args.get(0) (the CSV), splits on ,, returns one context per ID. Each context drives one delegateR.recover() call. No recoverAll needed.
6b. findAll() — No Args¶
@Failover(
name = "all-countries",
domain = "country",
payloadSplitter = "countryAllSplitter",
expiryDuration = 24,
expiryUnit = ChronoUnit.HOURS
)
List<Country> findAll();
Empty args automatically trigger the recover-all path. countryAllSplitter.splitOnRecover returns one placeholder context. delegateR.recoverAll fetches all slices under domain = "country".
6c. Filter Args — Non-ID Arguments¶
@Failover(
name = "countries-by-region",
domain = "country",
payloadSplitter = "countryAllSplitter",
expiryDuration = 24,
expiryUnit = ChronoUnit.HOURS,
recoverAll = true
)
List<Country> findByRegion(String region);
region is a filter, not an entity ID. Without recoverAll = true, scatter would try to split "EU" into entity keys (wrong). With recoverAll = true, the recover-all path is forced regardless of non-empty args. countryAllSplitter.splitOnRecover ignores args and returns one placeholder.
Summary¶
| Pattern | Args type | recoverAll | Splitter splitOnRecover |
|---|---|---|---|
| Batch by IDs | Entity IDs (CSV) | false (default) | Split args into per-entity contexts |
| findAll() | None | Not needed | Return 1 placeholder context |
| Filter-only args | Non-ID filters | true | Return 1 placeholder context (ignore args) |
Full Example — All Options Together¶
@Failover(
name = "countries-by-codes",
domain = "country",
expiryDurationExpression = "${failover.country.expiry-duration:24}",
expiryUnitExpression = "${failover.country.expiry-unit:HOURS}",
keyGenerator = "countryKeyGen",
expiryPolicy = "countryExpiryPolicy",
payloadSplitter = "countrySplitter"
)
List<Country> findByCodes(String csvCodes);
Resolution order:
expiryDurationExpression(non-blank) → overridesexpiryDurationexpiryUnitExpression(non-blank) → overridesexpiryUnitexpiryPolicy(non-blank) → overrides default expiry policy (butexpiryDuration/expiryUnitstill supply defaults when the policy delegates to them)keyGenerator(non-blank) → overrides default key generatorpayloadSplitter(non-blank) → enables scatter/gather mode
Next Steps¶
- Annotation Reference — complete attribute table and per-attribute detail
- Scatter / Gather Concepts — how
payloadSplitter+recoverAllwork at runtime - Payload Splitter How-to — step-by-step splitter implementation
- Domain Grouping — sharing entries across failovers