Architecture fitness functions
Architecture fitness functions are automated, objective, repeatable checks that
enforce your architectural decisions on every commit. Protean runs them with
protean check: it builds your domain's
Intermediate Representation (IR) — the
complete structural snapshot of the domain — and reports every place the model
drifts from sound Domain-Driven Design.
This guide covers running the checks, tuning severity, suppressing findings
during gradual adoption, wiring them into CI, and writing your own rules. For
the full list of rules see the Fitness Function Catalog;
for the CLI flags and output schema see the
protean check reference.
What are architecture fitness functions?
DDD makes structural promises: aggregates are consistency boundaries that reference each other by identity, events are past-tense facts, every command has a handler, bounded contexts don't import each other's infrastructure. These are easy to state and easy to erode as a codebase grows.
Because Protean holds the whole domain as an IR, many of these promises are
decidable — they can be checked mechanically at build time, with no runtime and
no database. protean check walks the IR and emits a diagnostic for each
violation, each carrying a rule code, a category, a severity level, the
offending element, the reason it fired (rule.rationale), and how to fix it
(rule.fix).
The checks are deterministic: the same domain always produces the same findings in the same order. That makes them safe to gate CI on.
Running protean check
# Check the domain discovered from the current directory
protean check
# Check an explicit domain module
protean check --domain=my_app.domain
# Show only warnings and errors (hide info-level advice)
protean check --level=warning
# One-line summary for scripts
protean check --quiet
protean check prints a human-readable report by default. The --level flag
filters what is displayed — it never changes the exit code. See
Strictness and CI gating below for how the exit code
is actually decided.
Built-in rules
protean check ships the following rules, grouped by category. Each links to its
full entry — rationale, fix, and configuration — in the
catalog.
Aggregate design
| Code | Level | Flags |
|---|---|---|
CROSS_AGGREGATE_REFERENCE |
warning | A Reference to another aggregate's root (violates Vernon's Rule 3). |
ES_AGGREGATE_NO_EVENTS |
warning | Event-sourced aggregate with no events registered. |
VALUE_OBJECT_MUTABLE_FIELD |
warning | Value object with a List/Dict field (breaks immutability). |
AGGREGATE_TOO_LARGE |
info | Aggregate exceeding [lint].aggregate_size_limit entities. |
HANDLER_TOO_BROAD |
info | Handler exceeding [lint].handler_breadth_limit message types. |
EVENT_WITHOUT_DATA |
info | Event with no fields. |
AGGREGATE_NO_INVARIANTS |
info | Aggregate with no pre/post invariants. |
Bounded context
| Code | Level | Flags |
|---|---|---|
CIRCULAR_CLUSTER_DEPENDENCY |
warning | Circular identity references between aggregate clusters. |
INFRA_IMPORT_IN_DOMAIN |
warning (opt-in) | Domain element importing from protean.adapters. |
Handler completeness
| Code | Level | Flags |
|---|---|---|
UNHANDLED_EVENT |
warning | Event with no registered consumer. |
UNUSED_COMMAND |
warning | Command with no handler. |
ES_EVENT_MISSING_APPLY |
warning | Event-sourced event with no @apply handler. |
PUBLISHED_NO_EXTERNAL_BROKER |
warning | published=True event but no external broker configured. |
AGGREGATE_WITHOUT_COMMAND_HANDLER |
warning | Aggregate with no write path. |
PROJECTION_WITHOUT_PROJECTOR |
warning | Projection that nothing populates. |
QUERY_HANDLER_WITHOUT_QUERY |
warning | Query handler with no query registered. |
PROJECTOR_HANDLES_ORPHANED_EVENT |
warning | Projector handling an unregistered event. |
COMMAND_HANDLER_CROSS_CLUSTER |
warning | Command handler for another cluster's command. |
EVENT_HANDLER_FOREIGN_EVENT |
warning | Event handler reacting to another cluster's event. |
SUBSCRIBER_NO_STREAMS |
info | Subscriber with no stream configured. |
PROCESS_MANAGER_UNCLOSED |
info | Process manager with no end=True handler. |
Naming conventions
| Code | Level | Flags |
|---|---|---|
EVENT_NOT_PAST_TENSE |
info | Event name is not past tense. |
COMMAND_NOT_IMPERATIVE |
info | Command name is not verb-first imperative. |
AGGREGATE_NOT_NOUN |
info | Aggregate name reads as a process, not a noun. |
Persistence, versioning, and deprecation
| Code | Level | Flags |
|---|---|---|
UNBOUNDED_INDEXED_STRING |
warning | An index over an unbounded string field. |
UPCASTER_GAP |
warning | Stored event version with no upcaster path. |
DEPRECATED_ELEMENT |
info | Element scheduled for removal. |
DEPRECATED_FIELD |
info | Field scheduled for removal. |
DEPRECATED_OPTION |
warning or info | Deprecated option or alias. |
DEPRECATED_EMAIL |
info | Deprecated email subsystem. |
The suppression system
Real domains adopt these checks gradually. Protean gives you two suppression layers, applied in this order.
Element-level: suppress_checks
Silence specific codes for a single element with the suppress_checks option on
its decorator. This is the right tool when a violation is a deliberate,
documented exception for that element:
@domain.aggregate(
indexes=[Index("body")],
suppress_checks=["UNBOUNDED_INDEXED_STRING"],
)
class Note:
body = Text()
suppress_checks accepts a list of rule codes (a bare string is treated as a
single code). It is available on every domain element decorator — aggregates,
entities, value objects, events, commands, handlers, projections, and so on.
Config-level: [lint].suppressions
For gradual remediation across the whole domain, the [lint].suppressions
allow-list grandfathers the first N findings of a code — you adopt a rule
without failing CI on pre-existing violations, while any new violation beyond
N still surfaces:
[lint.suppressions]
UNHANDLED_EVENT = 3 # grandfather the first 3 findings; a 4th fails CI
Findings are sorted into a deterministic (code, element, field, message) order
before the first N are dropped, so the same domain always suppresses the same
findings.
Which layer to use
| You want to… | Use |
|---|---|
| Permanently exempt one element for a documented reason | suppress_checks on that element |
| Adopt a rule on a legacy domain without fixing everything at once | [lint].suppressions allow-list, then drive the count to zero |
Per-element suppress_checks takes precedence over the config allow-list.
There is no global disable
Protean has no disabled_rules config key and no --save-baseline flag.
The two layers above are the supported way to silence findings; the
allow-list is the gradual-adoption path a baseline would otherwise serve.
Two info rules are threshold-tunable
([lint].aggregate_size_limit, [lint].handler_breadth_limit) and
INFRA_IMPORT_IN_DOMAIN is opt-in
([lint].check_infra_imports).
Strictness and CI gating
Severity has two independent knobs — do not confuse them:
--level(CLI, defaultinfo) filters what is displayed. It never changes the exit code and never filters the machine formats.[lint].level(config, defaultwarn) is the severity floor that decides the exit code — this is what gates CI.
[lint]
level = "warn" # errors + warnings gate CI (default)
[lint].level |
Exit 0 unless… |
|---|---|
error |
there is a validator error. |
warn (default) |
there is an error or a warning. |
info |
there is an error, warning, or info finding. |
Exit codes: 1 on any validator error (always), 2 on a gating finding at or
above the floor, 0 otherwise. Set level = "error" while you burn down
warnings, then tighten to warn once the domain is clean.
CI integration
protean check speaks two machine formats. Both emit the unfiltered set of
findings regardless of --level.
SARIF → GitHub Code Scanning
--format=sarif emits SARIF 2.1.0, which
GitHub Code Scanning renders as inline PR annotations and a security dashboard:
# .github/workflows/fitness.yml
name: Architecture fitness
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install protean
- run: protean check --domain=my_app.domain --format=sarif > protean.sarif
continue-on-error: true # upload even when findings gate the build
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: protean.sarif
Each SARIF result carries the rule's rationale and fix, and a helpUri back to
this documentation.
GitHub Actions annotations
For a simpler pipeline without Code Scanning, --format=github-annotations
emits ::error/::warning/::notice workflow commands that appear inline on
the run:
- run: protean check --domain=my_app.domain --format=github-annotations
Writing custom rules
Register additional rules with the [lint].rules config key — a list of dotted
import paths to callables with the signature (ir: dict) -> list[dict]:
[lint]
rules = ["my_app.lint.check_naming"]
# my_app/lint.py
def check_naming(ir: dict) -> list[dict]:
"""Flag aggregates whose short name is not PascalCase."""
findings = []
# ir["elements"] is a flat index of element type -> list of FQNs.
for fqn in ir.get("elements", {}).get("AGGREGATE", []):
name = fqn.rsplit(".", 1)[-1]
if not (name[:1].isupper() and name.isalnum()):
findings.append({
"code": "AGGREGATE_NOT_PASCAL_CASE",
"element": fqn,
"level": "info",
"message": f"{name} should be PascalCase",
})
return findings
The ir argument is the full IR document — see the
IR specification for its structure.
A custom finding requires code, element, level (warning or info), and
message; category defaults to custom, and rule/suggestion are optional.
A rule that raises, returns a non-list, or returns a finding missing a required
key is logged and skipped — it never crashes protean check. Custom findings
pass through the same suppression and gating machinery as built-in rules.
Pre-commit integration
Run protean check locally on every commit alongside the
IR compatibility hooks.
The pre-commit hook wiring is documented in the
compatibility-checking guide; point the hook at
protean check to fail commits that introduce new violations.
Related
- Fitness Function Catalog — every rule in detail.
protean checkreference — flags, formats, exit codes, JSON schema.[lint]configuration — config keys.- Compatibility checking — the complementary IR-diff breaking-change checks.