Summary
Evolve the backend domain layer from its current anemic shape toward a richer DDD-style model: entities that own their invariants, explicit value objects, and a repository boundary that speaks in domain entities instead of raw dicts. This is a post-MVP architectural improvement, to be done incrementally — no rewrite.
Context / Root Cause
The backend already has the right skeleton: a modular monolith with nested Clean/Hexagonal layers per module (ADR-05), strict inward dependency (api → application → domain, infrastructure → domain). That is exactly the foundation an aggregate + value-object model needs. Today, however, the weight sits in the wrong place:
- Domain entities are anemic.
Campaign, NPC, Faction, Arc, NewArc are frozen BaseModels with data only, no behavior (services/api/app/modules/campaigns/domain/).
- Invariants are scattered across the application layer. The clearest offender is provenance (
content_source). The rule "a DM edit flips provenance to edited; a manual create is manual; LLM output is llm" is duplicated across 6+ files:
create_npc.py, create_faction.py, create_arc.py → force "manual"
update_npc.py, update_faction.py, update_arc.py → restamp "edited"
contracts.py → normalizes to llm
- The repository boundary is
dict-based. update_arc(arc_id, changes: dict) -> dict | None, create_npc(data: dict) -> dict, and read models built with ArcResponse(**row). Use cases pass and receive raw dicts; there is no dict↔entity mapping isolated in infrastructure. This is the largest single piece of the refactor.
- We already have de-facto value objects. The
StrEnums (ContentSource, Priority, ArcStatus, Importance) are half the battle.
- The "aggregate root" claim is aspirational.
docs/03-domain-model.md calls Campaign an aggregate root "with many NPCs/factions/arcs", but the code treats each child as an independent entity: flat routers (/npcs, /factions, /arcs), one-at-a-time CRUD, and per-row RLS.
Proposal
Move business rules into the domain and make the persistence boundary type-safe, incrementally and module by module.
- Provenance value object first (highest value, lowest risk). Introduce a
Provenance/mark_edited() concept on the entities that centralizes the manual/edited/llm rule and collapses the 6+ duplications. No DB or repository change required.
- Behavior on frozen entities. Since entities are
frozen, methods return a new instance via model_copy(update=...) (functional style) — a natural fit for Pydantic frozen models.
- Migrate the repository boundary from
dict to domain entities, keeping dict↔entity mapping inside infrastructure/. Start with campaigns (the most mature module).
- Additional value objects only where a real invariant exists (e.g.
SessionNumber ordering, bounded text). Do not promote to a VO for aesthetics.
Aggregate boundary decision (deliberate)
Do not force a monolithic Campaign aggregate that must be loaded/saved whole to touch one child. The real access patterns (edit one NPC at a time) plus per-row RLS mean NPC / Faction / Arc are more honest as their own small aggregate roots, with Campaign protecting consistency only where it already does — the ordered creation flow with its compensating delete (create_campaign.py).
Acceptance Criteria
Given a DM edits an NPC, faction, or arc
When the update is applied
Then the provenance transition to "edited" is enforced by a single domain concept
And no application command restamps content_source inline
Given a manual create of an NPC, faction, or arc
When the entity is constructed in the domain
Then its provenance is "manual" by construction, not set in the application layer
Given the campaigns repository port (for the migrated module)
When a use case reads or writes an entity
Then the method accepts and returns domain entities, not raw dicts
And dict↔entity mapping lives only in infrastructure
Given the existing backend test suite
When the domain enrichment lands per module
Then behavior is preserved and covered by tests (strict TDD)
Scope
In scope
- A domain-owned provenance value object / method, replacing the scattered
content_source logic.
- Behavior methods on
Campaign, NPC, Faction, Arc where invariants exist.
- Migrating the
campaigns repository port and implementation to a domain-entity boundary (dict mapping isolated in infrastructure/).
- Treating NPC/Faction/Arc as small aggregate roots; keeping
Campaign's consistency role to the ordered-create flow.
- Additional VOs only where a genuine invariant justifies one.
- TDD coverage for every step.
Non-goals
- No monolithic
Campaign aggregate loaded/saved whole.
- No change to the per-row RLS model or the flat
/npcs, /factions, /arcs routes.
- No RAG, embeddings, billing, or multi-user collaboration.
- No big-bang rewrite — this is incremental, module by module.
- No new external dependency; stay on Pydantic + the existing hexagonal layering.
Affected Files / Areas
services/api/app/modules/campaigns/domain/ (campaign.py, npc.py, faction.py, arc.py, enums.py)
services/api/app/modules/campaigns/application/commands/{create,update}_{npc,faction,arc}.py, create_campaign.py, contracts.py
services/api/app/modules/campaigns/domain/ports.py (repository port signatures)
services/api/app/modules/campaigns/infrastructure/repository.py (dict↔entity mapping)
services/api/app/modules/campaigns/application/read_models/ (relationship to enriched entities)
docs/03-domain-model.md, docs/04-architecture.md (reflect the aggregate-boundary decision)
Testing Notes
- Backend (pytest, strict TDD): domain-level tests for provenance transitions and any new VO invariants; repository tests asserting the entity boundary and dict mapping; ownership/RLS behavior preserved.
- Each module migration must keep the existing suite green before moving to the next.
Notes
Future / post-MVP architectural work. Can be sequenced as several small PRs: (1) provenance VO, (2) entity behavior, (3) repository boundary migration for campaigns, (4) roll the pattern to sessions/generation as they mature. Consider capturing the aggregate-boundary decision as an ADR.
Summary
Evolve the backend domain layer from its current anemic shape toward a richer DDD-style model: entities that own their invariants, explicit value objects, and a repository boundary that speaks in domain entities instead of raw
dicts. This is a post-MVP architectural improvement, to be done incrementally — no rewrite.Context / Root Cause
The backend already has the right skeleton: a modular monolith with nested Clean/Hexagonal layers per module (ADR-05), strict inward dependency (
api → application → domain,infrastructure → domain). That is exactly the foundation an aggregate + value-object model needs. Today, however, the weight sits in the wrong place:Campaign,NPC,Faction,Arc,NewArcare frozenBaseModels with data only, no behavior (services/api/app/modules/campaigns/domain/).content_source). The rule "a DM edit flips provenance toedited; a manual create ismanual; LLM output isllm" is duplicated across 6+ files:create_npc.py,create_faction.py,create_arc.py→ force"manual"update_npc.py,update_faction.py,update_arc.py→ restamp"edited"contracts.py→ normalizes tollmdict-based.update_arc(arc_id, changes: dict) -> dict | None,create_npc(data: dict) -> dict, and read models built withArcResponse(**row). Use cases pass and receive raw dicts; there is no dict↔entity mapping isolated in infrastructure. This is the largest single piece of the refactor.StrEnums (ContentSource,Priority,ArcStatus,Importance) are half the battle.docs/03-domain-model.mdcallsCampaignan aggregate root "with many NPCs/factions/arcs", but the code treats each child as an independent entity: flat routers (/npcs,/factions,/arcs), one-at-a-time CRUD, and per-row RLS.Proposal
Move business rules into the domain and make the persistence boundary type-safe, incrementally and module by module.
Provenance/mark_edited()concept on the entities that centralizes themanual/edited/llmrule and collapses the 6+ duplications. No DB or repository change required.frozen, methods return a new instance viamodel_copy(update=...)(functional style) — a natural fit for Pydantic frozen models.dictto domain entities, keeping dict↔entity mapping insideinfrastructure/. Start withcampaigns(the most mature module).SessionNumberordering, bounded text). Do not promote to a VO for aesthetics.Aggregate boundary decision (deliberate)
Do not force a monolithic
Campaignaggregate that must be loaded/saved whole to touch one child. The real access patterns (edit one NPC at a time) plus per-row RLS mean NPC / Faction / Arc are more honest as their own small aggregate roots, withCampaignprotecting consistency only where it already does — the ordered creation flow with its compensating delete (create_campaign.py).Acceptance Criteria
Scope
In scope
content_sourcelogic.Campaign,NPC,Faction,Arcwhere invariants exist.campaignsrepository port and implementation to a domain-entity boundary (dict mapping isolated ininfrastructure/).Campaign's consistency role to the ordered-create flow.Non-goals
Campaignaggregate loaded/saved whole./npcs,/factions,/arcsroutes.Affected Files / Areas
services/api/app/modules/campaigns/domain/(campaign.py,npc.py,faction.py,arc.py,enums.py)services/api/app/modules/campaigns/application/commands/{create,update}_{npc,faction,arc}.py,create_campaign.py,contracts.pyservices/api/app/modules/campaigns/domain/ports.py(repository port signatures)services/api/app/modules/campaigns/infrastructure/repository.py(dict↔entity mapping)services/api/app/modules/campaigns/application/read_models/(relationship to enriched entities)docs/03-domain-model.md,docs/04-architecture.md(reflect the aggregate-boundary decision)Testing Notes
Notes
Future / post-MVP architectural work. Can be sequenced as several small PRs: (1) provenance VO, (2) entity behavior, (3) repository boundary migration for
campaigns, (4) roll the pattern tosessions/generationas they mature. Consider capturing the aggregate-boundary decision as an ADR.