Fitness Function Catalog
Every diagnostic protean check can report, grouped by category. Each entry
lists the rule's category and severity level, why it fires, and how to fix it.
For a task-oriented walkthrough — running the checks, suppressing findings, and
wiring them into CI — see the
Architecture Fitness Functions guide.
For the JSON/SARIF shape of a finding and the CLI flags, see the
protean check reference.
Severity levels
| Level | Meaning |
|---|---|
warning |
A likely design problem worth addressing. Gates CI at the default [lint].level = "warn" floor. |
info |
An advisory observation. Never gates CI unless [lint].level = "info". |
Validator errors (malformed domains that cannot build an IR) are a separate,
always-fatal class and are not listed here — they always exit 1.
Aggregate Design
Rules that keep aggregates within their consistency boundary and value objects immutable.
CROSS_AGGREGATE_REFERENCE
| Category | aggregate_design |
| Level | warning |
Why. Aggregates coordinate other aggregates by identity, not by object
reference (Vernon's Rule 3). A Reference to another aggregate's root couples
the two into one object graph and invites a single transaction to span both
clusters.
Fix. Hold the other aggregate by its identifier instead of a Reference.
Replace Reference(<Other>) with an Identifier field (for example
<other>_id: Identifier()) and load the other aggregate through its own
repository when needed.
ES_AGGREGATE_NO_EVENTS
| Category | aggregate_design |
| Level | warning |
Why. An event-sourced aggregate reconstitutes its state by replaying its events. With no events registered it can record no state changes and cannot be rebuilt from its stream.
Fix. Declare at least one domain event with part_of=<Aggregate> and raise
it from the aggregate's behaviour, or drop event_sourced=True if the aggregate
is not meant to be event-sourced.
VALUE_OBJECT_MUTABLE_FIELD
| Category | aggregate_design |
| Level | warning |
Why. Value objects are compared by value and must be immutable. A List or
Dict field gives the value object mutable internal state, so two instances that
should be equal can diverge and value equality no longer holds.
Fix. Replace the mutable collection with an immutable representation, or move the collection onto the containing entity or aggregate. If the values form a concept with its own identity, model them as an entity referenced by the aggregate instead.
AGGREGATE_TOO_LARGE
| Category | aggregate_design |
| Level | info |
Why. A large aggregate is a consistency boundary and contention hotspot; oversized clusters are hard to keep transactionally consistent.
Fix. Split the aggregate into smaller aggregates, or raise
[lint] aggregate_size_limit (default 5 entities) if the size is intentional.
HANDLER_TOO_BROAD
| Category | aggregate_design |
| Level | info |
Why. A handler that handles many message types accretes unrelated responsibilities and becomes hard to reason about.
Fix. Split the handler into focused handlers, or raise
[lint] handler_breadth_limit (default 5 message types) if the breadth is
intentional.
EVENT_WITHOUT_DATA
| Category | aggregate_design |
| Level | info |
Why. An event with no fields carries no information beyond its name, so consumers cannot react to what actually changed.
Fix. Add fields capturing the state change, or confirm the event is intentionally a bare signal.
AGGREGATE_NO_INVARIANTS
| Category | aggregate_design |
| Level | info |
Why. An aggregate is a consistency boundary. With no pre- or post-invariants it enforces no business rules and is usually an anemic data holder rather than a true aggregate.
Fix. Add one or more @invariant.pre or @invariant.post methods expressing
the business rules the aggregate must always satisfy, or reconsider whether this
concept is an aggregate at all.
Bounded Context
Rules that preserve independent decomposition and the ports-and-adapters boundary.
CIRCULAR_CLUSTER_DEPENDENCY
| Category | bounded_context |
| Level | warning |
Why. Circular identity references between aggregate clusters prevent independent decomposition, deployment, and event sourcing of the aggregates.
Fix. Break the cycle by replacing one direction of the reference with a domain event or a process manager that coordinates the two aggregates asynchronously.
INFRA_IMPORT_IN_DOMAIN
| Category | bounded_context |
| Level | warning (opt-in) |
Off by default. Enable with [lint].check_infra_imports = true; the rule then
AST-parses each resolvable domain element's source module.
Why. Domain elements must not depend on concrete infrastructure adapters;
importing from protean.adapters couples the domain layer to a specific adapter
and breaks the ports-and-adapters boundary.
Fix. Remove the protean.adapters import from the domain module. Depend on
domain-layer abstractions and let the adapter be wired through the domain's
provider configuration instead.
Handler Completeness
Rules that flag missing or misplaced write, read, and reaction paths.
UNHANDLED_EVENT
| Category | handler_completeness |
| Level | warning |
Why. An event with no registered handler is published but never consumed, so a state change goes unobserved.
Fix. Register an event handler, projector, or process manager for this event,
or mark it published=True if it is intentionally external.
UNUSED_COMMAND
| Category | handler_completeness |
| Level | warning |
Why. A command with no handler cannot be processed, so the intent it represents can never be fulfilled.
Fix. Add a command handler method for this command, or remove the command if it is unused.
ES_EVENT_MISSING_APPLY
| Category | handler_completeness |
| Level | warning |
Why. An event-sourced aggregate rebuilds its state by applying events; an
event without an @apply handler is never folded into state.
Fix. Add an @apply method on the aggregate for this event.
PUBLISHED_NO_EXTERNAL_BROKER
| Category | handler_completeness |
| Level | warning |
Why. Events marked published are meant to leave the bounded context, but
with no external broker configured they are only dispatched internally.
Fix. Configure outbox.external_brokers, or remove published=True if the
events are internal.
AGGREGATE_WITHOUT_COMMAND_HANDLER
| Category | handler_completeness |
| Level | warning |
Why. An aggregate with no command handler has no write path — nothing can change its state.
Fix. Add a command handler for the aggregate, or model it as a read-only projection if no writes are expected.
PROJECTION_WITHOUT_PROJECTOR
| Category | handler_completeness |
| Level | warning |
Why. A projection with no projector is never populated, so queries against it will always return empty.
Fix. Add a projector for the projection, or set externally_populated=True
if it is filled by a subscriber.
QUERY_HANDLER_WITHOUT_QUERY
| Category | handler_completeness |
| Level | warning |
Why. A projection with a query handler but no query has a read path that nothing can invoke — no query is registered for the handler to serve.
Fix. Register a Query(part_of=<projection>) for the handler to serve, or
remove the query handler if the projection needs no read path.
PROJECTOR_HANDLES_ORPHANED_EVENT
| Category | handler_completeness |
| Level | warning |
Why. A projector handling an event the domain does not register is wired to a type that can never be dispatched — usually a stale reference after a rename or removal.
Fix. Register the event, or remove the handler for the orphaned type from the projector.
COMMAND_HANDLER_CROSS_CLUSTER
| Category | handler_completeness |
| Level | warning |
Why. A command handler that processes another cluster's command puts that aggregate's write path outside its consistency boundary.
Fix. Move the command handler into the owning cluster, or model the interaction as an event reaction across the boundary.
EVENT_HANDLER_FOREIGN_EVENT
| Category | handler_completeness |
| Level | warning |
Why. An event handler should react to events of its own aggregate cluster. Handling another cluster's event couples two aggregates through the handler and is often better expressed as a Process Manager coordinating the two.
Fix. Move the handler into the owning cluster, or introduce a
ProcessManager that reacts to the source event and issues a command into this
cluster.
SUBSCRIBER_NO_STREAMS
| Category | handler_completeness |
| Level | info |
Why. A subscriber with no stream has nothing to consume, so it is registered but can never be invoked.
Fix. Set the subscriber's stream, or remove the subscriber if it is unused.
PROCESS_MANAGER_UNCLOSED
| Category | handler_completeness |
| Level | info |
Why. A process manager with no end=True handler never signals completion,
so its instances accumulate without being retired.
Fix. Mark the terminating handler with end=True so the process manager
closes its instances.
Naming Conventions
Advisory rules that keep element names aligned with their role in the ubiquitous
language. All are info-level.
EVENT_NOT_PAST_TENSE
| Category | naming_conventions |
| Level | info |
Why. A domain event records a fact that has already happened, so a past-tense
name (OrderPlaced) reads truthfully; a gerund (OrderPlacing) describes an
in-flight action and reads like a command.
Fix. Rename the event to the past tense (e.g. OrderPlaced).
COMMAND_NOT_IMPERATIVE
| Category | naming_conventions |
| Level | info |
Why. A command expresses an intent to act, so a verb-first imperative name
(PlaceOrder) reads truthfully; a noun-like name (OrderCreation) obscures the
intent.
Fix. Rename the command to a verb-first imperative phrase (e.g. PlaceOrder).
AGGREGATE_NOT_NOUN
| Category | naming_conventions |
| Level | info |
Why. An aggregate models a thing in the domain, so a noun name (Order)
reads truthfully; a gerund, verb, or adjective (OrderProcessing) reads like a
process or capability rather than an entity.
Fix. Rename the aggregate to the domain-concept noun it represents (e.g.
Order rather than OrderProcessing).
Persistence
UNBOUNDED_INDEXED_STRING
| Category | persistence |
| Level | warning |
Why. An index over an unbounded string field is unportable: the DDL fails on SQL Server, needs a prefix length on MySQL, and is inefficient on PostgreSQL.
Fix. Give the field a bounded length (String(max_length=N)) sized to its
domain, or remove it from the index if it does not need to be indexed.
See the UNBOUNDED_INDEXED_STRING deep dive for the exact scope, limits, and per-engine behaviour.
Versioning
UPCASTER_GAP
| Category | versioning |
| Level | warning |
Why. Stored payloads at older versions with no upcaster path to the current version fail to deserialize at read time.
Fix. Add upcasters covering the missing source versions. See Evolving Events Over Time.
Deprecation
Rules that surface elements, fields, and options scheduled for removal. See the v0.17 migration guide for the deprecation timeline.
DEPRECATED_ELEMENT
| Category | deprecation |
| Level | info |
Why. A deprecated element is scheduled for removal; code depending on it will break at the removal version.
Fix. Migrate to the replacement element before the scheduled removal version.
DEPRECATED_FIELD
| Category | deprecation |
| Level | info |
Why. A deprecated field is scheduled for removal; code depending on it will break at the removal version.
Fix. Migrate to the replacement field before the scheduled removal version.
DEPRECATED_OPTION
| Category | deprecation |
| Level | warning or info |
Emitted at warning level for a deprecated option alias (for example the
event_sourced alias), and at info level for a deprecated element option.
Why. The option is a deprecated alias scheduled for removal.
Fix. Use the current option name instead of the deprecated alias.
DEPRECATED_EMAIL
| Category | deprecation |
| Level | info |
Why. The email subsystem is deprecated and scheduled for removal in v1.0.0.
Fix. Notify from an event handler or subscriber that calls an application-level notification service instead.
Custom rules
Rules loaded through [lint].rules emit findings under the custom category by
default. See
Writing custom rules.