diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2d659e6..6472e1b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
---
+## v26.06.42 (2026-06-07)
+
+### Added (cache — @cacheable condition / unless)
+
+`@cacheable` / `cache` now accept Spring-style predicates (Pythonic callables):
+
+- **`condition`** — a predicate over the call arguments; when it returns ``False`` caching
+ is bypassed entirely (the function runs, nothing is read from or written to the cache).
+- **`unless`** — a predicate over the *result*; when it returns ``True`` the result is
+ returned but not stored (e.g. skip caching empty/None results).
+
## v26.06.41 (2026-06-07)
### Added (context — injectable ApplicationEventPublisher + arbitrary domain events)
diff --git a/README.md b/README.md
index 5be5d28..3f5c22d 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
-
+
diff --git a/pyproject.toml b/pyproject.toml
index 2be9360..d4bf699 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ name = "pyfly"
# CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4);
# git tag, GitHub release and human-readable display use leading-zero form
# (v26.05.04) to match the Java/.NET/Go siblings.
-version = "26.6.41"
+version = "26.6.42"
description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more."
readme = "README.md"
license = "Apache-2.0"
diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py
index 2029e2c..dd7d9fa 100644
--- a/src/pyfly/__init__.py
+++ b/src/pyfly/__init__.py
@@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""
-__version__ = "26.06.41"
+__version__ = "26.06.42"
diff --git a/src/pyfly/cache/decorators.py b/src/pyfly/cache/decorators.py
index 07f1d54..db3d48b 100644
--- a/src/pyfly/cache/decorators.py
+++ b/src/pyfly/cache/decorators.py
@@ -63,6 +63,9 @@ def cache(
backend: CacheAdapter,
key: str,
ttl: timedelta | None = None,
+ *,
+ condition: Callable[..., bool] | None = None,
+ unless: Callable[[Any], bool] | None = None,
) -> Callable[[F], F]:
"""Cache the return value of an async function.
@@ -74,6 +77,11 @@ def cache(
backend: Cache adapter to use.
key: Key template with {param} placeholders.
ttl: Optional time-to-live for cached entries.
+ condition: Predicate over the call arguments (same signature as *func*); when it
+ returns ``False`` caching is bypassed entirely (the function runs, nothing is
+ read from or written to the cache). Spring's ``@Cacheable(condition=...)``.
+ unless: Predicate over the *result*; when it returns ``True`` the result is returned
+ but NOT stored. Spring's ``@Cacheable(unless=...)``.
"""
def decorator(func: F) -> F:
@@ -81,6 +89,10 @@ def decorator(func: F) -> F:
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
+ # condition=False -> bypass the cache entirely.
+ if condition is not None and not condition(*args, **kwargs):
+ return await func(*args, **kwargs)
+
resolved_key = _resolve_key(func, key, args, kwargs)
# Check cache. A present-but-None entry is a hit (null caching /
@@ -91,9 +103,10 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any:
if await backend.exists(resolved_key):
return None
- # Execute and cache
+ # Execute and (unless excluded) cache.
result = await func(*args, **kwargs)
- await backend.put(resolved_key, result, ttl=ttl)
+ if unless is None or not unless(result):
+ await backend.put(resolved_key, result, ttl=ttl)
return result
return wrapper # type: ignore[return-value]
@@ -105,17 +118,23 @@ def cacheable(
backend: CacheAdapter,
key: str,
ttl: timedelta | None = None,
+ *,
+ condition: Callable[..., bool] | None = None,
+ unless: Callable[[Any], bool] | None = None,
) -> Callable[[F], F]:
"""Cache the return value, skip execution on cache hit.
- Equivalent to :func:`cache`.
+ Equivalent to :func:`cache`, with Spring-style ``condition`` (bypass caching) and
+ ``unless`` (don't store certain results) predicates.
Args:
backend: Cache adapter to use.
key: Key template with {param} placeholders.
ttl: Optional time-to-live for cached entries.
+ condition: Predicate over the call arguments; ``False`` bypasses the cache.
+ unless: Predicate over the result; ``True`` returns it without storing.
"""
- return cache(backend=backend, key=key, ttl=ttl)
+ return cache(backend=backend, key=key, ttl=ttl, condition=condition, unless=unless)
def cache_evict(
diff --git a/tests/cache/test_cacheable_condition_unless.py b/tests/cache/test_cacheable_condition_unless.py
new file mode 100644
index 0000000..2574d68
--- /dev/null
+++ b/tests/cache/test_cacheable_condition_unless.py
@@ -0,0 +1,73 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""@cacheable condition / unless predicates (v26.06.42)."""
+
+from __future__ import annotations
+
+import pytest
+
+from pyfly.cache.adapters import InMemoryCache
+from pyfly.cache.decorators import cacheable
+
+
+@pytest.mark.asyncio
+async def test_condition_false_bypasses_cache() -> None:
+ backend = InMemoryCache()
+ calls = {"n": 0}
+
+ @cacheable(backend=backend, key="x:{n}", condition=lambda n: n > 0)
+ async def compute(n: int) -> int:
+ calls["n"] += 1
+ return n * 10
+
+ # n=0 -> condition False -> bypass: executes every time, nothing cached
+ assert await compute(0) == 0
+ assert await compute(0) == 0
+ assert calls["n"] == 2
+ assert await backend.exists("x:0") is False
+
+
+@pytest.mark.asyncio
+async def test_condition_true_caches() -> None:
+ backend = InMemoryCache()
+ calls = {"n": 0}
+
+ @cacheable(backend=backend, key="x:{n}", condition=lambda n: n > 0)
+ async def compute(n: int) -> int:
+ calls["n"] += 1
+ return n * 10
+
+ assert await compute(5) == 50
+ assert await compute(5) == 50 # cache hit
+ assert calls["n"] == 1
+
+
+@pytest.mark.asyncio
+async def test_unless_excludes_result_from_cache() -> None:
+ backend = InMemoryCache()
+ calls = {"n": 0}
+
+ # Don't cache "empty" results (None / 0).
+ @cacheable(backend=backend, key="x:{n}", unless=lambda result: not result)
+ async def compute(n: int) -> int:
+ calls["n"] += 1
+ return n
+
+ assert await compute(0) == 0
+ assert await compute(0) == 0 # unless(0)->True -> not stored -> recomputed
+ assert calls["n"] == 2
+
+ assert await compute(7) == 7
+ assert await compute(7) == 7 # unless(7)->False -> stored -> cache hit
+ assert calls["n"] == 3
diff --git a/uv.lock b/uv.lock
index 21cf538..da64b5a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1981,7 +1981,7 @@ wheels = [
[[package]]
name = "pyfly"
-version = "26.6.41"
+version = "26.6.42"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },