Skip to content

refactor(domain): enrich the backend domain toward aggregates with value objects #98

Description

@danilopgon

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.

  1. 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.
  2. 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.
  3. Migrate the repository boundary from dict to domain entities, keeping dict↔entity mapping inside infrastructure/. Start with campaigns (the most mature module).
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions