Signals
Signals provide event-driven pub/sub for inter-feature communication. A producer feature emits a signal when something happens; consumer features react without importing the producer’s internals.
Table of contents
- The problem: tight coupling between features
- How it works
- Defining a signal (producer)
- Connecting to a signal (consumer)
- The signals.py convention
- Contract integration
- Introspecting signals
- Signals vs template hooks
- Signal registry internals
- See also
The problem: tight coupling between features
When a user registers, the profile feature needs to create a UserProfile. Without signals, the auth feature has to know about profile and call it directly:
Without signals (the wrong way)
# splent_feature_auth/services.py
from splent_io.splent_feature_profile.models import UserProfile # direct import!
from splent_framework.db import db
class AuthenticationService:
def create_user(self, email, password, name, surname):
user = User(email=email)
user.set_password(password)
db.session.add(user)
db.session.commit()
# Auth knows about profile — tight coupling
profile = UserProfile(user_id=user.id, name=name, surname=surname)
db.session.add(profile)
db.session.commit()
return user
Problems:
- Auth depends on profile. If you remove
splent_feature_profilefrom the product, auth crashes on import. In an SPL where profile is optional, this is unacceptable. - Every new listener means editing auth. Want to create a billing account on registration? Edit auth again. Want to send a welcome email? Edit auth again. Auth becomes a dumping ground.
- You can’t see who reacts to what. The coupling is hidden inside service methods. No way to inspect it.
With signals (the right way)
# splent_feature_auth/signals.py — auth defines the signal
from splent_framework.signals.signal_utils import define_signal
user_registered = define_signal("user-registered", "splent_feature_auth")
# splent_feature_auth/services.py — auth emits it
from splent_io.splent_feature_auth.signals import user_registered
class AuthenticationService:
def create_user(self, email, password, **kwargs):
user = User(email=email)
user.set_password(password)
db.session.add(user)
db.session.commit()
# Auth knows nothing about profile, billing, email...
user_registered.send(self, user=user, **kwargs)
return user
# splent_feature_profile/signals.py — profile reacts
from splent_framework.signals.signal_utils import connect_signal
@connect_signal("user-registered", "splent_feature_profile")
def on_user_registered(sender, user, **kwargs):
profile = UserProfile(user_id=user.id, name=kwargs.get("name", ""))
db.session.add(profile)
db.session.commit()
What this fixes:
- Auth has zero knowledge of profile. Remove profile from the product and auth keeps working. The signal fires, nobody listens, nothing happens.
- Adding a new listener never touches auth. Billing just adds its own
@connect_signal("user-registered", ...)handler. Auth is never modified. - Full introspection. Run
splent product:signalsand see exactly who emits what and who listens:
SIGNAL EMITTED BY LISTENERS
──────────────────────────────────────────────────────────
user-registered splent_feature_auth splent_feature_profile, splent_feature_billing
How it works
SPLENT signals are built on blinker with a shared namespace so that all feature signals are discoverable and introspectable.
auth emits "user-registered" --> profile listens --> creates UserProfile
billing listens --> creates BillingAccount
(auth knows nothing about profile or billing)
Defining a signal (producer)
A producer feature defines a signal using define_signal() from splent_framework.signals.signal_utils. This creates a blinker signal and registers it in the framework’s signal registry.
File: splent_feature_auth/signals.py
from splent_framework.signals.signal_utils import define_signal
user_registered = define_signal("user-registered", "splent_feature_auth")
define_signal(name, provider) returns a blinker NamedSignal. The producer emits the signal by calling .send() with keyword arguments:
from splent_io.splent_feature_auth.signals import user_registered
class AuthenticationService:
def register(self, email, password, name, surname):
user = User(email=email)
# ... save user ...
user_registered.send(self, user=user, name=name, surname=surname)
Connecting to a signal (consumer)
A consumer feature listens to a signal using the @connect_signal() decorator. If the producer feature is not installed in the product, the handler is silently skipped – no crash.
File: splent_feature_profile/signals.py
from splent_framework.signals.signal_utils import connect_signal
from splent_io.splent_feature_profile.models import UserProfile
from splent_framework.db import db
@connect_signal("user-registered", "splent_feature_profile")
def on_user_registered(sender, user, **kwargs):
"""Create a UserProfile when a new user registers."""
name = kwargs.get("name", "")
surname = kwargs.get("surname", "")
if name and surname:
profile = UserProfile(user_id=user.id, name=name, surname=surname)
db.session.add(profile)
db.session.commit()
connect_signal(signal_name, listener_feature) takes the signal name and the listener’s feature package name. The listener feature is recorded in the signal registry for introspection.
The signals.py convention
Every feature has a signals.py module at the root of its package. The framework auto-imports this module during feature loading, so signal definitions and connections are registered at startup without any manual wiring.
splent_feature_auth/
src/splent_io/splent_feature_auth/
__init__.py
signals.py <-- defines "user-registered"
services.py
...
splent_feature_profile/
src/splent_io/splent_feature_profile/
__init__.py
signals.py <-- connects to "user-registered"
models.py
...
When scaffolding a new feature with feature:create, the CLI generates a signals.py from a template with commented examples for both define_signal and connect_signal.
Contract integration
Signals are declared in a feature’s pyproject.toml contract so that the CLI can validate signal dependencies statically, before the application boots.
Provider declares provides.signals
[tool.splent.contract.provides]
signals = ["user-registered"]
Consumer declares requires.signals
[tool.splent.contract.requires]
signals = ["user-registered"]
The feature:contract command reads these declarations and reports mismatches. If a product includes a feature that requires a signal but no installed feature provides it, the contract check flags the missing dependency.
Introspecting signals
Use product:signals to see all signals, their providers, and their listeners in the active product:
splent product:signals
sample_splent_app -- 1 signal(s)
SIGNAL EMITTED BY LISTENERS
──────────────────────────────────────────────────────────────
user-registered splent_feature_auth splent_feature_profile
See product:signals for full usage.
Signals vs template hooks
Both signals and template hooks enable inter-feature communication, but they serve different purposes:
| Signals | Template hooks | |
|---|---|---|
| Purpose | Business logic events | UI extension points |
| Direction | Producer emits, consumers react | Layout defines slots, features fill them |
| Runs during | Request processing / service calls | Template rendering |
| Example | Auth emits “user-registered”, profile creates a record | Auth registers a nav item in “layout.navbar.authenticated” |
| API | define_signal / connect_signal |
register_template_hook / get_template_hooks |
Use signals when one feature needs to trigger side effects in other features. Use template hooks when a feature needs to contribute HTML to a shared layout.
Signal registry internals
File: splent_framework/signals/registry.py
The registry is a module-level dict that tracks every defined signal, its provider, and its listeners:
| Function | Description |
|---|---|
register_signal(name, signal, provider) |
Record a new signal definition |
register_listener(signal_name, listener) |
Record that a feature listens to a signal |
get_signal(name) |
Look up a registered signal by name (returns None if not found) |
get_registry() |
Return the full registry dict for introspection |
clear_registry() |
Reset all registrations (used in test teardown) |
The registry is populated at import time during feature loading. Signal definitions must run before signal connections, which is guaranteed by the topological feature load order (producers load before consumers when UVL constraints are set correctly).
See also
- Template system – template hooks for UI extension
- Feature loading – how
signals.pyis auto-imported product:signals– list all signals in a productfeature:contract– validate signal contracts