diff --git a/CHANGELOG.md b/CHANGELOG.md
index a944f918..dba4b8c2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
---
+## v26.06.95 (2026-06-10)
+
+### Fixed
+
+- **Union-safe bean-name derivation.** Registering a bean whose implementation type is
+ a PEP 604 union (`X | Y`, a `types.UnionType`) crashed application startup with
+ `AttributeError: 'types.UnionType' object has no attribute '__name__'`, surfaced as a
+ `BeanCreationException` from `ApplicationContext.start()`. Default bean-name derivation
+ now goes through a new union-safe `Registration.display_name`, which falls back to the
+ union member names (then `str()`) when `impl_type` has no `__name__`. The same property
+ is reused for scope-resolution error messages so they never raise either. Added
+ regression tests in `tests/container/test_registration_display_name.py`.
+
+---
+
## v26.06.94 (2026-06-10)
### Docs (parity initiative — final cross-cutting sweep)
diff --git a/README.md b/README.md
index cec5b4b1..0c4c9dee 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
-
+
diff --git a/pyproject.toml b/pyproject.toml
index 6bb3de04..17b6ee55 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.94"
+version = "26.6.95"
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/container/container.py b/src/pyfly/container/container.py
index cc8ff747..7b5e5667 100644
--- a/src/pyfly/container/container.py
+++ b/src/pyfly/container/container.py
@@ -398,7 +398,7 @@ def _resolve_request_scoped(self, reg: Registration) -> Any:
if ctx is None:
raise RuntimeError(
f"No active request context for REQUEST-scoped bean "
- f"{reg.impl_type.__name__}. Ensure a RequestContextFilter is active."
+ f"{reg.display_name}. Ensure a RequestContextFilter is active."
)
# Store request-scoped instances in the context's attributes
@@ -424,12 +424,12 @@ def _resolve_session_scoped(self, reg: Registration) -> Any:
if ctx is None:
raise RuntimeError(
f"No active request context for SESSION-scoped bean "
- f"{reg.impl_type.__name__}. Ensure a RequestContextFilter is active."
+ f"{reg.display_name}. Ensure a RequestContextFilter is active."
)
session = ctx.get(HTTP_SESSION_KEY)
if session is None:
raise RuntimeError(
- f"No HTTP session for SESSION-scoped bean {reg.impl_type.__name__}. "
+ f"No HTTP session for SESSION-scoped bean {reg.display_name}. "
f"Ensure the session module (SessionFilter) is enabled."
)
@@ -448,7 +448,7 @@ def _resolve_custom_scoped(self, reg: Registration) -> Any:
if handler is None:
raise RuntimeError(
f"Custom scope {reg.scope!r} is not registered for bean "
- f"{reg.impl_type.__name__}. Available: {sorted(self._custom_scopes)}. "
+ f"{reg.display_name}. Available: {sorted(self._custom_scopes)}. "
f"Call container.register_scope({reg.scope!r}, handler) first."
)
cache_key = f"__pyfly_bean_{reg.impl_type.__qualname__}"
diff --git a/src/pyfly/container/registry.py b/src/pyfly/container/registry.py
index a06edc97..c9c52ecc 100644
--- a/src/pyfly/container/registry.py
+++ b/src/pyfly/container/registry.py
@@ -17,7 +17,7 @@
from collections.abc import Callable
from dataclasses import dataclass, field
-from typing import Any
+from typing import Any, get_args
from pyfly.container.types import Scope, ScopeSpec
@@ -44,3 +44,24 @@ class Registration:
# ``init_plan_built`` distinguishes "not built yet" from "built, trivial (None)".
init_plan: list[tuple[str, Any, bool]] | None = field(default=None, repr=False, compare=False)
init_plan_built: bool = field(default=False, repr=False, compare=False)
+
+ @property
+ def display_name(self) -> str:
+ """Readable bean name: the explicit ``name`` if set, otherwise a
+ union-safe rendering of ``impl_type``.
+
+ PEP 604 unions (``X | Y``) and other typing constructs have no
+ ``__name__``, so deriving a default name via ``impl_type.__name__``
+ raises ``AttributeError``. Fall back to the union member names, then
+ ``str()``, so bean-name derivation never crashes on a union impl type.
+ """
+ if self.name:
+ return self.name
+ impl = self.impl_type
+ direct = getattr(impl, "__name__", None)
+ if direct is not None:
+ return direct
+ args = get_args(impl)
+ if args:
+ return " | ".join(getattr(a, "__name__", None) or str(a) for a in args)
+ return str(impl)
diff --git a/src/pyfly/context/application_context.py b/src/pyfly/context/application_context.py
index dc8b8bd6..63d75784 100644
--- a/src/pyfly/context/application_context.py
+++ b/src/pyfly/context/application_context.py
@@ -246,7 +246,7 @@ async def _do_start(self) -> None:
# Pass 1: before_init for every bean (collects all @aspect beans, etc.)
for group in instance_groups:
rep = group[0]
- bean_name = rep.name or rep.impl_type.__name__
+ bean_name = rep.display_name
inst = rep.instance
for pp in sorted_pps:
inst = pp.before_init(inst, bean_name)
@@ -256,7 +256,7 @@ async def _do_start(self) -> None:
# Pass 2: @post_construct then after_init (weaving now sees every aspect)
for group in instance_groups:
rep = group[0]
- bean_name = rep.name or rep.impl_type.__name__
+ bean_name = rep.display_name
await self._call_post_construct(rep.instance)
inst = rep.instance
for pp in sorted_pps:
@@ -977,7 +977,7 @@ def _post_init_lazy_bean(self, instance: Any, reg: Registration) -> Any:
batched startup passes for a single bean. Aspects are already collected
during startup, so a single bean weaves correctly here.
"""
- bean_name = reg.name or reg.impl_type.__name__
+ bean_name = reg.display_name
sorted_pps = sorted(self._post_processors, key=lambda pp: get_order(type(pp)))
for pp in sorted_pps:
instance = pp.before_init(instance, bean_name)
diff --git a/tests/container/test_registration_display_name.py b/tests/container/test_registration_display_name.py
new file mode 100644
index 00000000..ab85754a
--- /dev/null
+++ b/tests/container/test_registration_display_name.py
@@ -0,0 +1,40 @@
+"""Regression: Registration.display_name must be union-safe.
+
+PEP 604 unions (``X | Y``) are ``types.UnionType`` and have no ``__name__``, so
+deriving a default bean name via ``impl_type.__name__`` raised ``AttributeError``
+during startup (``BeanCreationException``). ``display_name`` must never crash.
+"""
+
+from pyfly.container.registry import Registration
+
+
+class _A:
+ pass
+
+
+class _B:
+ pass
+
+
+def test_display_name_uses_explicit_name() -> None:
+ assert Registration(impl_type=_A, name="myBean").display_name == "myBean"
+
+
+def test_display_name_falls_back_to_type_name() -> None:
+ assert Registration(impl_type=_A).display_name == "_A"
+
+
+def test_display_name_is_union_safe() -> None:
+ # The case that crashed bean-name derivation at startup.
+ reg = Registration(impl_type=(_A | _B))
+ assert reg.display_name == "_A | _B" # no AttributeError
+
+
+def test_display_name_handles_arbitrary_typing_construct() -> None:
+ from typing import Optional
+
+ # Optional[_A] (== Union[_A, None]) and similar typing constructs must never
+ # raise; the exact rendering varies by Python version, but it is always a
+ # usable, non-empty name.
+ name = Registration(impl_type=Optional[_A]).display_name
+ assert isinstance(name, str) and name
diff --git a/uv.lock b/uv.lock
index 57409b82..90fbc760 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2160,7 +2160,7 @@ wheels = [
[[package]]
name = "pyfly"
-version = "26.6.94"
+version = "26.6.95"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },