Skip to content

ADR-0000: Guiding Principles

Status: Accepted

Date: March 2026

This is not a decision record. It is a meta-document that captures the principles governing all Protean architectural decisions. When evaluating a new proposal or resolving a design tension, these principles provide the framework for reasoning.


1. Topology, not logic

The framework captures what exists and how things connect — never what happens inside. Structural metadata (field names, handler targets, aggregate clusters) is the framework's concern. Implementation logic (handler bodies, invariant checks, factory methods) belongs to the developer.

Example: When domain.init() runs, the framework discovers that OrderCommandHandler handles PlaceOrder and that Order raises OrderPlaced. It does not inspect what the handler does with the command data or what conditions cause the event to be raised. Invariant names and stages (pre/post) are registered as structural metadata, but the invariant logic itself is opaque to the framework.

2. Declare, don't detect

When the framework needs information that isn't naturally introspectable from Python's type system or class structure, the answer is explicit developer declaration via decorator options or Meta attributes — never static analysis of source code, never inference from naming conventions.

Example: Whether an event crosses bounded context boundaries is not something the framework can infer. Rather than scanning handler bodies for external API calls, we added the published option to @domain.event(published=True). The developer declares intent; the framework acts on it.

3. Explicit over automatic

Protean favors explicit registration and wiring over magic auto-discovery. Every domain element is registered through a decorator (@domain.aggregate, @domain.command_handler) that makes the element's role and relationships visible at the declaration site. Implicit behavior is acceptable only when the default is obvious and the override is straightforward.

Example: Every command handler, event handler, and projector declares part_of=SomeAggregate explicitly. The framework does not guess aggregate associations from module structure or naming. Default repositories are provided automatically for every aggregate, but custom repositories override them with an explicit @domain.repository(part_of=Order) declaration.

4. Compute freely, materialize deliberately, validate automatically

In-memory computation is cheap and should be unconstrained. Persisting state (materializing) is a deliberate act with consequences — it crosses a boundary. Validation happens automatically at every state transition, not at manual checkpoints.

Example: Aggregate invariants (@invariant.pre, @invariant.post) run on every mutation automatically. The developer never calls validate() — the framework guarantees that if an aggregate instance exists in memory, it satisfies all its invariants. But persisting that aggregate requires an explicit repository call within a Unit of Work, making the materialization boundary visible.

5. DSL expressiveness must survive infrastructure migration

Protean's field DSL (String(max_length=100), HasMany("Comment"), Status(transitions={...})) must work identically regardless of the underlying infrastructure. Switching from PostgreSQL to Elasticsearch, or from Redis Streams to an inline broker, should not require changing domain model declarations.

Example: The FieldSpec architecture translates String(max_length=100) into Pydantic-compatible annotations during class creation. The FieldSpec carries enough metadata for any adapter to map the field to its native storage type — Text maps to a TEXT column in SQL and a text field in Elasticsearch — but the developer never writes adapter-specific declarations. The domain model is infrastructure-agnostic by construction.

6. Aggregate-centric, flow-aware

The primary organizational unit is the aggregate cluster — an aggregate and everything that belongs to it (entities, value objects, commands, events, handlers, repositories). Cross-cutting concerns (domain services, process managers, subscribers) are not forced into clusters but get explicit treatment where their multi-aggregate nature is first-class.

Example: During domain.init(), ElementResolver.assign_aggregate_clusters() groups entities, commands, and events under their root aggregate by traversing part_of chains. But process managers and domain services stand apart — they coordinate across aggregate boundaries and belong to no single cluster. This mirrors DDD's distinction between aggregate internals and cross-aggregate coordination.

7. Screaming architecture

The folder structure, module names, and class names should communicate business concepts, not technical categories. A developer opening the codebase should immediately see the domain, not the framework plumbing.

Example: Protean applications organize code by domain concept (ordering/, inventory/, shipping/), not by technical layer (models/, handlers/, repositories/). Within each domain module, the aggregate, its commands, events, and handlers live together. The test directory mirrors the source structure — tests/aggregate/, tests/event/, tests/server/ — so navigation between source and test is immediate.