diff --git a/skills/ddd-best-practices/SKILL.md b/skills/ddd-best-practices/SKILL.md new file mode 100644 index 0000000..a8d6733 --- /dev/null +++ b/skills/ddd-best-practices/SKILL.md @@ -0,0 +1,134 @@ +--- +name: ddd-best-practices +description: Domain-Driven Design guidance for modeling complex domains. Use when designing Bounded Contexts or Subdomains, discovering Aggregate boundaries from invariants and transactions, defining Aggregate Roots, designing Domain Events, typed domain failures, or repository contracts, choosing exception vs Result/Either/Option semantics, choosing Repository vs DAO/query service/gateway, splitting oversized Aggregates, coordinating cross-Aggregate consistency, applying Context Mapping patterns, implementing CQRS or Event Sourcing, building a Ubiquitous Language, applying Hexagonal Architecture, or reviewing layer ownership. +license: MIT +metadata: + author: luckys + version: "1.0.0" +--- + +# DDD Best Practices + +Use this skill when the main question is how to model a domain, where to draw boundaries, or how to structure collaboration between subdomains. + +## Working Style + +1. Start from the domain problem, not from technical constructs. +2. Build a Ubiquitous Language before writing any code. +3. Keep Aggregates small — consistency boundary, not convenience grouping. +4. Prefer Value Objects when a domain concept has value semantics, not as a blanket wrapper for every primitive. +5. Let Domain Events tell the story of what happened, not what to do next. +6. Never let infrastructure concerns leak into the domain model. + +## Design Workflow + +1. **Discover the domain.** + - Who are the domain experts? What language do they use? + - What are the core subdomains (Core, Supporting, Generic)? + - Where does the most complex business logic live? + +2. **Draw Bounded Contexts.** + - One model per context — resist the urge to share models across contexts. + - Name each context after a team or capability, not a technical layer. + - Define context relationships explicitly (Partnership, Customer-Supplier, Anti-Corruption Layer, etc.). + +3. **Design Aggregates.** + - Start from one business command and state the invariants it must preserve. + - Include only state that must commit or reject atomically. + - Each Aggregate has one root Entity that controls access. + - Reference other Aggregates by identity and make eventual consistency explicit. + - Check lifecycle, cardinality, contention, and concurrency; table count does not define the boundary. + +4. **Model with tactical patterns.** + - Entity: has identity that persists over time. + - Value Object: defined by its attributes; value equality; immutable by default; no identity. + - Domain Service: stateless logic that doesn't belong to any single Entity. + - Domain Event: records that something significant happened. + - Repository: collection-like access to Aggregates. + - Factory: encapsulates complex construction. + +5. **Integrate contexts.** + - Translate at boundaries — never let one context's model pollute another. + - Use Anti-Corruption Layers when consuming upstream models you don't control. + - Translate internal Domain Events into stable Integration Events before crossing a context boundary. + +## Heuristics + +### Bounded Context +Draw the boundary where the Ubiquitous Language changes — the same word meaning different things is a signal for a boundary. + +### Aggregate +If a recognized business invariant requires two objects to commit or reject together, they likely belong in the same Aggregate. Do not confuse a convenient synchronous workflow with an invariant. If temporary inconsistency has an acceptable recovery path, prefer separate Aggregates. + +### Entity vs Value Object +If you track it over time or need to distinguish between two instances with the same data, it's an Entity. If all that matters is its value, it's a Value Object. + +### Domain Service +If a significant domain operation doesn't naturally belong to any Entity or Value Object, it belongs in a Domain Service — but use sparingly. + +### Domain Event +Name events in the past tense: `OrderPlaced`, `PaymentFailed`, `UserRegistered`. They record facts — they don't issue commands. +Prefer a specific semantic fact such as `UserArchived` over generic `UserUpdated` or `StatusChanged`; let the Aggregate record it and the application deliver it. + +### Repository +One Repository per Aggregate Root. Never expose a Repository for child entities within an Aggregate. +Return complete Aggregates for invariant-bearing work; use dedicated query models for reports and partial projections, and gateways for external capabilities. + +## Warning Signs + +- Anemic Domain Model: Entities have only getters/setters; all logic is in services. +- God Aggregate: one Aggregate contains everything related to a concept. +- Shared database between Bounded Contexts — contexts are coupled through the schema. +- Ubiquitous Language drift: code uses different terms from the domain experts. +- Application Service doing domain logic instead of orchestrating domain objects. +- Repository returning arbitrary queries instead of meaningful collection operations. +- Repository exposing ORM/query-builder types, interpolated SQL, or transaction control. +- Generic CRUD events carry full Aggregate snapshots without a consumer or compatibility reason. +- A use case invents Domain Events after the transition, or an Aggregate publishes through an Event Bus. +- Raw internal Domain Events cross a Bounded Context instead of being translated to versioned Integration Events. +- One transaction routinely modifies multiple Aggregate Roots without a documented invariant. +- Public setters or mutable child collections let callers bypass the Aggregate Root. +- Repository access exists for a child Entity inside an Aggregate. +- Aggregate boundaries mirror ORM relationships, UI screens, or table layouts. +- A global uniqueness rule is checked only in memory, without a concurrent persistence constraint. + +## References + +- Read `references/aggregates.md` for Aggregate discovery, boundary signals, rule ownership, creation vs. reconstitution, concurrency, cross-Aggregate coordination, persistence, and review checklists. +- Read `references/repositories.md` for repository semantics, contract ownership, Repository vs DAO/query service/gateway, absence, mapping, transactions, caching, pagination, testing, and legacy migration. +- Read `references/domain-events.md` for event semantics, granularity, payload/envelope design, aggregate recording, creation vs reconstitution, Integration Event translation, subscribers, and delivery boundaries. +- Read `references/tactical-patterns.md` for Entities, Value Objects, Aggregates, Domain Services, Domain Events, Repositories, Factories. +- Read `references/strategic-design.md` for Subdomains (Core/Supporting/Generic), Bounded Contexts, and Ubiquitous Language. +- Read `references/context-mapping.md` for Context Map patterns: Partnership, Shared Kernel, Customer-Supplier, Conformist, ACL, Open Host Service, Published Language. +- Read `references/cqrs-and-events.md` for CQRS, Event Sourcing, Event Storming, and process managers. +- Read `references/hexagonal-architecture.md` for Ports & Adapters: primary/driven ports, adapters, dependency direction, test strategy, Walking Skeleton implementation, and comparison with Clean/Onion Architecture. +- Read `references/ddd-in-practice.md` for practical DDD application: discovery process (Impact Mapping, Model Exploration Whirlpool), team topology, DDD adoption anti-patterns, and PHP implementation examples (Value Objects, Entities, Aggregate Root, Application Service, Specification). +- Read `references/domain-errors.md` for failure taxonomy and ownership, Option vs Result/Either vs exception decisions, stable codes, exhaustive composition, state atomicity, and safe boundary translation. +- Read `references/read-models.md` for aggregate-derived DTOs vs dedicated read models, CQRS query shapes, projection handlers, atomic idempotency, missing event data, incremental updates, and shadow rebuilds. +- Read `references/typescript-ddd-examples.md` for the TypeScript DDD skeleton: folder structure by Bounded Context, explicit Value Objects, AggregateRoot/DomainEvent examples, creation vs reconstitution strategies, use case structure, CommandBus/QueryBus, Object Mother, and Criteria pattern. +- Read `references/go-ddd-examples.md` for DDD in Go: folder structure by Bounded Context, Value Objects, Aggregate Root with unexported fields, Domain Events, Repository interface (port) vs implementation (adapter), Use Case, ACL, CQRS query side, Domain Service. + +## Related Skills + +- Use `oop-best-practices` for detailed Value Object construction, equality, immutability, optionality, persistence, and everyday object design within a Bounded Context. +- Use `design-patterns-best-practices` for GoF and enterprise patterns inside the domain. +- Use `refactoring-best-practices` when evolving an existing domain model safely. +- Use `tdd-best-practices` for invariant-first Aggregate tests, deterministic fixtures, and concurrency integration tests. +- Use `infrastructure-design` for transaction boundaries, optimistic locking, and reliable Outbox implementation. +- Use `rest-api-best-practices` when exposing the domain through an HTTP API and translating domain errors to status codes. + +## Source Influences + +This skill is synthesized from: + +- *Domain-Driven Design* by Eric Evans (the blue book) +- *Implementing Domain-Driven Design* by Vaughn Vernon +- *Domain-Driven Design Distilled* by Vaughn Vernon +- *Learning Domain-Driven Design* by Vladik Khononov +- *Patterns, Principles, and Practices of Domain-Driven Design* by Scott Millett & Nick Tune +- *Hexagonal Architecture Explained* by Alistair Cockburn & Juan Manuel Garrido de Paz +- *DDD in PHP* (community resource) +- [CodelyTV Aggregates course](https://github.com/CodelyTV/aggregates-course) +- [CodelyTV Repository Pattern course](https://github.com/CodelyTV/repository_pattern-course) (including production counterexamples) +- [CodelyTV Domain Events course](https://github.com/CodelyTV/domain_modeling-domain_events-course) (including modeling and delivery counterexamples) +- [CodelyTV Domain Modeling Errors course](https://github.com/CodelyTV/domain_modeling-errors-course) (including Optional, Result, Effect, exhaustivity, and boundary counterexamples) diff --git a/skills/ddd-best-practices/references/aggregates.md b/skills/ddd-best-practices/references/aggregates.md new file mode 100644 index 0000000..3a3bfa4 --- /dev/null +++ b/skills/ddd-best-practices/references/aggregates.md @@ -0,0 +1,191 @@ +# Aggregates and Aggregate Roots + +Source: [CodelyTV/aggregates-course](https://github.com/CodelyTV/aggregates-course), synthesized with the aggregate rules in Evans and Vernon. + +Use this reference to discover, review, split, or implement aggregate boundaries. Treat the course as design evidence, not as production-ready code: retain the principles below and avoid copying its unsafe concurrency and testing details. + +## Aggregate Is a Consistency Boundary + +An aggregate is the smallest cluster of entities and value objects that must remain atomically consistent after a command. It is not: + +- every object associated with a domain noun +- an ORM object graph or cascade configuration +- a database table, module, bounded context, or service +- the shape required by a screen or report + +Keep a rule inside one aggregate only when the business requires it to hold immediately. Coordinate rules that may settle later across aggregates using events, policies, or process managers. + +Small aggregates are usually easier to load, lock, and evolve, but size is a consequence of the invariants. Never use a property-count or table-count threshold. + +## Discover the Boundary from a Command + +Analyze one business command at a time: + +1. Name the command in Ubiquitous Language: `ApproveOrder`, `ReserveSeat`, `AddReview`. +2. List the state the decision reads and changes. +3. State each invariant as a sentence the business recognizes. +4. Ask which state must commit or reject as one unit. +5. Put only that state behind one root. +6. Treat remaining coordination as cross-aggregate and make its consistency expectation explicit. +7. Recheck lifecycle, cardinality, contention, and failure recovery before finalizing the boundary. + +Example: adding a product review does not normally require loading and saving the product with every historical review. A review has independent identity and lifecycle, and the collection can grow without bound. Model `ProductReview` as its own aggregate and retain `ProductId` as an identity reference unless a genuine atomic product-review invariant proves otherwise. + +## Boundary Signals + +| Signal | Prefer the same aggregate | Prefer separate aggregates | +|---|---|---| +| Consistency | Rule must hold at commit | Temporary inconsistency is acceptable | +| Lifecycle | Child exists only with owner | Object is created, archived, or deleted independently | +| Cardinality | Small and naturally bounded | Collection is large or unbounded | +| Concurrency | Changes must serialize | Parts change independently or are highly contended | +| Access | Only meaningful through owner | Needs direct lookup or its own use cases | +| Ownership | Root exclusively owns the part | Object is shared by several owners | +| Failure | Partial success is invalid | Retry, compensation, or reconciliation is meaningful | + +Do not split a real invariant merely to improve performance. First understand the business consequence, then choose reservation, optimistic concurrency, a different invariant, or a larger boundary deliberately. + +## Protect the Root + +Give every aggregate exactly one root and route all commands through it. + +- Keep mutable state and child collections private. +- Expose named operations, not public setters. +- Return immutable snapshots or copies; never expose a mutable collection that bypasses the root. +- Give child entities local identity when the root must distinguish them. +- Create repositories for roots, not for internal children. +- Reference another aggregate by its identity, not by a live object reference. + +An identity reference prevents accidental cross-aggregate mutation, but it does not guarantee that the referenced aggregate still exists. Enforce strong referential requirements with the appropriate database constraint, lifecycle policy, reservation, or reconciliation process. + +## Put Each Rule in the Narrowest Owner + +Use this order when deciding where a rule belongs: + +| Rule | Owner | +|---|---| +| Intrinsic validity of one value, independent of context | Value Object | +| Stateful invariant over members of one aggregate | Aggregate Root behavior | +| Stateless domain policy that belongs to no entity or value | Narrow Domain Service | +| Loading, existence checks, transactions, I/O, and workflow | Application Service | +| Database uniqueness or serialization under concurrency | Persistence constraint plus application handling | + +Examples: + +- Rating from 0 to 5: `ReviewRating` Value Object. +- Order total cannot exceed its approved limit: `Order` Aggregate Root. +- Exchange rate policy involving two currencies and a supplied rate source: a named Domain Service or port. +- Load a product before creating a review: application orchestration. + +Avoid generic `Ensurer`, `Manager`, or `Validator` services that accept raw primitives and collect unrelated rules. A Domain Service must use domain language, remain stateless, and never import an application use case. + +Context-sensitive rules do not automatically belong in a Value Object. If a comment limit varies by tenant, role, product, or date, pass the policy explicitly or enforce it at the aggregate boundary rather than hiding ambient context in the value. + +Use `oop-best-practices` for the detailed Value Object contract. An Aggregate may safely expose immutable Value Objects or snapshots when doing so cannot bypass root-controlled mutation. + +## Creation, Reconstitution, and Transitions + +Separate three semantic paths: + +- `create(...)`: establish a new identity and record creation facts. +- `fromPrimitives(...)`, `rehydrate(...)`, or a mapper: restore persisted state without recording new facts. +- named commands such as `rename(...)` or `addCategory(...)`: enforce transitions and record resulting facts. + +Restrict raw constructors where the language permits it. Never reconstitute through `create(...)`; loading an aggregate must not emit `Created` again. + +Technical Aggregate IDs may be generated by the caller and supplied to `create(...)`. This makes create commands deterministic across retries and supports idempotency. Do not confuse an opaque technical ID with a business sequence such as an invoice number; business numbering may require a concurrency-safe allocator and its own domain policy. + +Reconstitution still has to produce a valid usable object, but rule evolution needs care. A stricter constructor can reject historical data that was valid under an earlier rule. Handle that with migration, version-aware mapping, or an explicit legacy state rather than silently treating stored data as newly created. + +`toPrimitives()` and `fromPrimitives()` are one mapping style, not a DDD requirement. A dedicated mapper is equally valid when it keeps persistence concerns out of the model more effectively. + +## Transactions and Concurrency + +Use one aggregate root per transaction as the default, not an inviolable law. Multiple tables may persist one aggregate atomically. Updating multiple roots in one local transaction can be justified, but treat it as a boundary warning and document why eventual consistency is unacceptable. + +Protect concurrent updates when lost updates matter. Carry an expected Aggregate version across load and save, reject stale decisions, and choose retry versus user-visible conflict according to the command semantics. Do not silently use last-write-wins for business decisions. Use `infrastructure-design` for optimistic-lock implementation and integration testing. + +Watch for hot aggregates: many users appending to one root will serialize on one version even if they change unrelated children. Unbounded collections, repeated write conflicts, large payloads, and long lock times are evidence to revisit the boundary. + +## Cross-Aggregate Rules + +An in-memory check cannot guarantee a global rule under concurrency. Examples include unique email, sequential invoice number, stock shared across orders, and existence of another aggregate. + +Choose enforcement by semantics: + +- Use a unique database constraint for uniqueness and translate collisions into a domain/application error. +- Use an atomic database sequence, locked counter, serializable transaction with retry, or explicit allocator for business numbering. +- Use reservations when scarce capacity must be held across a workflow. +- Use events and idempotent handlers when temporary inconsistency is acceptable. +- Use a process manager for stateful, multi-step coordination and compensation. + +Never use `MAX(number) + 1` as a safe concurrent allocator. A unique constraint can detect the collision, but it does not make allocation correct by itself. + +If two changes truly must commit together, reconsider whether they belong in one aggregate before introducing a saga. A saga cannot retroactively create atomic consistency; it manages eventual consistency and partial failure. + +## Persistence, Events, and Reads + +Model one repository per aggregate root. Persist the aggregate as a unit even when its state spans several tables. Keep transaction control outside individual repository methods so the application boundary can commit state and outbox records together. + +Let the aggregate decide which domain facts occurred and record them internally. Let the application or Unit of Work persist those facts. Do not inject an Event Bus into the aggregate. + +The educational sequence `save -> pull events -> publish` has a dual-write failure window. For events that must survive a crash or cross a process boundary, store aggregate changes and outbox messages in the same database transaction, then publish asynchronously with idempotent consumers. + +Do not distort aggregates for queries. Use a dedicated read model or projection when a query joins aggregates, computes totals, or serves a consumer-specific shape. A synchronous database view is one projection option; asynchronous projections additionally require replay, ordering, idempotency, monitoring, and a stated staleness expectation. + +## Testing Expectations + +Test the root through public commands, with real value objects and child entities. At minimum verify: + +- valid transitions change the observable state +- each invariant rejects its boundary cases +- rejected commands leave state and pending events unchanged +- creation records the expected event +- reconstitution records no event +- named transitions record the right event payload +- child uniqueness and collection rules use value equality +- persistence round trips preserve the model +- optimistic conflicts and global constraints work against real infrastructure + +Use `tdd-best-practices` for the complete Aggregate testing workflow. + +## Common Failure Modes + +- Copying the ORM relationship graph into one aggregate. +- Measuring aggregate size by properties, classes, or tables. +- Loading an unbounded child collection to append one item. +- Exposing public setters or mutable child arrays. +- Providing a repository for an internal child entity. +- Updating several roots in every command without reviewing the boundary. +- Treating a read/report model as the write aggregate. +- Checking global uniqueness only in memory. +- Emitting creation events during reconstitution. +- Clearing events before durable handoff to an outbox. +- Moving all rules into a procedural Domain Service. + +## Review Checklist + +- Can every invariant be stated in domain language? +- Does each invariant require immediate consistency? +- Is the boundary the smallest one that can enforce those invariants? +- Can any collection grow without a business-defined bound? +- Do independently changing objects have independent identities and lifecycles? +- Can callers mutate state without a root command? +- Does one repository represent one root rather than one table? +- Is cross-aggregate consistency explicit and failure-aware? +- Are global constraints enforced under real concurrency? +- Are creation and reconstitution separate? +- Are durable events written atomically with state? +- Are query shapes kept out of the write model? + +## Course-Specific Caveats + +Do not generalize these details from the course: + +- Aggregate inheritance, public `toPrimitives()`, or a wrapper for every primitive are not mandatory. +- A synchronous existence check cannot guarantee the referenced aggregate remains present. +- SQL views are not the default projection mechanism. +- `MAX + 1` is unsafe for concurrent numbering. +- JavaScript `includes(new ValueObject(...))` compares object identity, not domain value. +- The demonstrated self-asserting repository/event-bus mocks can pass without proving the System Under Test made the call. +- The repository history illustrates design evolution but does not demonstrate a strict Red-Green-Refactor process. diff --git a/skills/ddd-best-practices/references/context-mapping.md b/skills/ddd-best-practices/references/context-mapping.md new file mode 100644 index 0000000..e611324 --- /dev/null +++ b/skills/ddd-best-practices/references/context-mapping.md @@ -0,0 +1,258 @@ +# Context Mapping + +A Context Map documents the relationships between Bounded Contexts. Each relationship type has different implications for autonomy, coupling, and translation effort. The map is not just a technical diagram — it captures organizational dynamics, power structures, and integration strategies between teams. + +--- + +## Partnership + +**Relationship type:** Peer-to-peer, mutual dependency + +**Intent:** Two teams with a mutual dependency cooperate closely so that neither can succeed without the other. + +**How it works:** Both teams agree on an interface and coordinate their schedules so neither blocks the other. Failures in one context propagate to the other, so the relationship demands active ongoing collaboration. Teams hold joint planning sessions, share release cycles, and jointly own the integration points. + +**When to use:** +- Two teams are building closely related contexts that must go live together +- Both teams have equal organizational standing and willingness to coordinate +- Tight coupling is genuinely necessary and unavoidable for the business goal + +**When NOT to use:** +- One team has clear authority or control over the interface (use Customer-Supplier instead) +- Teams are geographically or organizationally distant with poor communication channels +- Long-term stability is required — Partnership is fragile if one team's priorities shift + +**Key trade-off:** Highest integration fidelity, but shared fate. One team's dysfunction or delays immediately affect the other. + +**Practical heuristic:** If you find yourself describing the relationship as "we rise or fall together," it is a Partnership. Document that explicitly — otherwise it defaults to an unacknowledged and fragile dependency. + +--- + +## Shared Kernel + +**Relationship type:** Peer-to-peer, shared code ownership + +**Intent:** Two teams agree to share a small, explicitly defined subset of the domain model, jointly owning it and treating any change as requiring mutual consent. + +**How it works:** A designated portion of code (entities, value objects, events, or a shared library) is jointly maintained. Neither team may change the shared subset without consulting the other. Both teams run their respective test suites against the shared kernel to detect breakage. This forms an intimate relationship that requires ongoing consultation. + +**When to use:** +- Two contexts share a small, stable core that would be expensive to duplicate or translate +- Both teams have strong discipline and communication to enforce joint ownership +- The shared piece is truly domain-meaningful, not just a utility library + +**When NOT to use:** +- The shared subset keeps growing — this is a signal that boundaries are wrong, not that sharing should expand +- Teams lack the discipline to enforce mutual consent on changes +- The contexts are owned by teams with different release cadences or priorities + +**Key trade-off:** Reduces translation overhead for the shared part, but creates a tight coupling point that resists independent evolution. + +**Practical heuristic:** Keep the Shared Kernel as small as possible. If you cannot put it on an index card, it is too large. Treat every addition to it as an architectural decision requiring both teams' approval. + +--- + +## Customer-Supplier Development Teams + +**Relationship type:** Upstream (Supplier) / downstream (Customer), negotiated + +**Intent:** The downstream (Customer) team can influence the upstream (Supplier) team's planning, negotiating what features and interface changes are prioritized. + +**How it works:** The upstream team acts as a supplier of services or APIs. The downstream team is the customer and has legitimate standing to raise requirements and place them in the upstream team's backlog. Planning happens collaboratively — the upstream team commits to meeting downstream needs within agreed timelines. Acceptance tests written by the downstream team validate that the upstream delivers what was promised. + +**When to use:** +- The upstream team is inside the same organization and responsive to business pressure +- The downstream team needs specific functionality or interface changes from upstream +- There is a governance or product management structure that can enforce upstream accountability + +**When NOT to use:** +- The upstream is a third-party vendor or external system that cannot be influenced (use Conformist or ACL instead) +- The upstream team has no incentive to prioritize downstream needs +- Negotiation overhead would exceed the cost of building a local solution (use Separate Ways) + +**Key trade-off:** Gives downstream teams a voice, but requires trust, organizational support, and ongoing negotiation investment. + +**Practical heuristic:** Write acceptance tests as the first artifact of every Customer-Supplier negotiation. If the upstream team will not run your tests, the relationship has silently become Conformist. + +--- + +## Conformist + +**Relationship type:** Upstream / downstream, upstream-controlled + +**Intent:** The downstream team accepts and conforms to the upstream model entirely, with no translation layer and no ability to influence the upstream design. + +**How it works:** The downstream team adopts the upstream's model as-is, using its types, structures, and language directly in their own code. There is no negotiation — the upstream publishes whatever it publishes, and the downstream adapts. This is common when integrating with external systems, SaaS platforms, or powerful internal platforms that dictate their own model. + +**When to use:** +- The upstream team cannot or will not accommodate downstream needs +- The upstream model is close enough to the downstream's needs that translation would add more complexity than it removes +- Speed of integration matters more than model purity +- The upstream is a third-party system or a dominant platform within the organization + +**When NOT to use:** +- The upstream model is a Big Ball of Mud that would corrupt the downstream's core domain +- The downstream is a Core Domain where model integrity is a competitive differentiator +- Concepts between contexts are semantically different despite sharing names + +**Key trade-off:** Lowest integration effort, but the downstream model is permanently coupled to whatever the upstream publishes, including its deficiencies. + +**Practical heuristic:** If you are conforming to an upstream you do not control and the upstream model is poor quality, switch to an Anticorruption Layer. Conformist is only appropriate when the upstream model is acceptable to live with long-term. + +--- + +## Anticorruption Layer (ACL) + +**Relationship type:** Upstream / downstream, downstream-protected + +**Intent:** The downstream team builds an explicit translation layer that insulates its own model from the upstream's model, converting between representations at the boundary. + +**How it works:** The ACL is a set of adapters, translators, and facades that sit between the downstream context and the upstream system. The downstream model never sees the upstream's types directly — all data flows through the ACL, which converts it into terms and structures that fit the downstream's Ubiquitous Language. The ACL is typically implemented as Domain Services, Anti-Corruption Services, or repository adapters that call upstream APIs and return local domain objects. + +**When to use:** +- Integrating with a legacy system, Big Ball of Mud, or third-party API whose model does not align with your domain +- The upstream model would corrupt the semantics or integrity of the downstream Core Domain +- You want to be able to swap out the upstream system later without touching the rest of the downstream model +- The downstream context is a Core Domain whose model integrity must be preserved + +**When NOT to use:** +- The upstream model is clean and the concepts genuinely align — an ACL adds complexity for no benefit +- The integration is trivial and temporary +- The downstream team is a Conformist context where model purity is not critical + +**Key trade-off:** Maximum protection of the downstream model's integrity, at the cost of translation code that must be maintained as both models evolve. + +**Practical heuristic:** Every integration with a legacy system or external API should default to an ACL until you have a concrete reason not to build one. The cost of building it is always less than the cost of retroactively removing corruption from your Core Domain. + +--- + +## Open Host Service + +**Relationship type:** Upstream / multiple downstream consumers, publisher-controlled + +**Intent:** The upstream context defines an explicit protocol or API that any downstream consumer can use, removing the need for bespoke integrations for each consumer. + +**How it works:** The upstream team designs a well-structured, versioned service interface — typically a REST API, gRPC contract, or messaging interface — and publishes it as a stable access point. Each downstream consumer integrates against this standard interface. The upstream team manages the protocol as a product, versioning it and maintaining backward compatibility. The interface may be described using a Published Language. + +**When to use:** +- Multiple contexts or external consumers need to integrate with one upstream context +- The upstream team wants to support a variety of consumers without negotiating bespoke integrations for each +- A standardized, stable API surface reduces integration friction across the organization + +**When NOT to use:** +- There is only one downstream consumer — bespoke integration is simpler +- The upstream model changes too rapidly to maintain a stable public protocol +- Consumers have radically different needs that a single protocol cannot serve well + +**Key trade-off:** One well-maintained interface scales to many consumers, but the upstream team takes on the overhead of designing and versioning a public protocol. + +**Practical heuristic:** An Open Host Service should be treated like a product with real API design discipline — versioning, documentation, deprecation policy. If those practices are absent, it will degrade into a poorly understood point-to-point integration. + +--- + +## Published Language + +**Relationship type:** Upstream / downstream, documentation-mediated + +**Intent:** A well-documented, shared language (schema, format, or protocol) is defined and used as the medium of exchange between contexts, so that both sides can understand and validate the data independently. + +**How it works:** Rather than relying on informal knowledge of each other's internal models, two or more contexts agree on a Published Language — a formally defined, versioned format such as JSON Schema, XML Schema, Avro schema, OpenAPI spec, or a domain event schema registry. Each context translates to and from this language at its boundary, rather than depending on the other context's internal representation directly. Published Language is often paired with Open Host Service. + +**When to use:** +- The integration crosses organizational or system boundaries where informal knowledge sharing is unreliable +- Multiple consumers need to independently implement against the same data contract +- Long-term stability and clear versioning of the data exchange format are required +- The downstream context needs to validate incoming data against a known schema + +**When NOT to use:** +- The integration is entirely internal, well-understood, and short-lived +- Both contexts are owned by the same team and can evolve the interface together informally +- The overhead of schema management outweighs the coordination benefit + +**Key trade-off:** Explicit contracts enable independent evolution and validation, but require governance of the schema over time. + +**Practical heuristic:** Publish the language in a schema registry or shared documentation repository. If the language only exists in team members' heads or in one team's code, it is not truly Published. + +--- + +## Separate Ways + +**Relationship type:** No integration, full independence + +**Intent:** Two contexts have no meaningful relationship and should be kept entirely separate, with each team solving its needs independently. + +**How it works:** The decision is made that integrating two contexts would cost more than duplicating the functionality or solving problems separately. Each context implements what it needs without coupling to the other. This is not an oversight or a temporary state — it is a deliberate architectural decision to keep two areas of the system fully decoupled. + +**When to use:** +- The integration cost (translation, coordination, versioning) exceeds the benefit of sharing +- The two contexts address genuinely different problems with no meaningful overlap +- The functional duplication is small and cheap to maintain independently +- Teams are in entirely different business units with separate priorities and budgets + +**When NOT to use:** +- There is actual shared domain data that must remain consistent across both contexts +- The duplication leads to divergence that will confuse users or create data integrity issues +- The choice is driven by convenience or avoidance of coordination rather than a genuine analysis + +**Key trade-off:** Maximum autonomy and zero coupling, but potential duplication of effort and loss of consistency if the "separate" assumption later proves wrong. + +**Practical heuristic:** Document the Separate Ways decision explicitly on the Context Map with a brief rationale. Without documentation, future teams will not understand why integration was not built and may add it inappropriately — or waste time investigating a gap that was intentional. + +--- + +## Big Ball of Mud + +**Relationship type:** No clear boundaries, legacy/unstructured + +**Intent:** Acknowledges the existence of a large, legacy, unstructured system with no clear internal model boundaries, so that new systems can plan accordingly. + +**How it works:** The Big Ball of Mud is not a pattern to build — it is a pattern to recognize and document. It describes a legacy system or module where internal models are tangled, terminology is inconsistent, and boundaries between concerns have eroded over time. When a new Bounded Context must integrate with a Big Ball of Mud, it draws a hard boundary around the mud and uses an Anticorruption Layer to prevent the disorder from leaking in. The Big Ball of Mud itself is acknowledged on the Context Map as a zone of known disorder. + +**When to use:** +- Documenting the reality of a legacy system that cannot be immediately refactored +- Planning an integration with an existing unstructured system so the new context can protect itself +- Communicating to stakeholders the scope and risk of legacy integration work + +**When NOT to use:** +- As a justification for building new systems without boundaries ("it will just grow") +- As a permanent state — the goal is always to strangle or refactor the mud over time + +**Key trade-off:** Acknowledging the Big Ball of Mud prevents teams from pretending it is a clean context. The risk is that recognizing it makes it easy to leave it in place indefinitely. + +**Practical heuristic:** Draw a Big Ball of Mud on the Context Map whenever a legacy system is involved. Pair it with an ACL on the downstream side. Plan incremental strangling to reduce its footprint over time. + +--- + +## How to Draw a Context Map + +Drawing a Context Map is a collaborative, discovery-driven activity — not a documentation task done in isolation after the fact. + +**Steps:** + +1. **Identify all Bounded Contexts.** List every system, subsystem, module, or application that has its own Ubiquitous Language and team ownership. Include third-party systems, legacy platforms, SaaS products, and external APIs. + +2. **Identify all integration points.** For each pair of contexts that exchange data or call each other, draw a line. Do not assume integration exists — verify it by talking to the teams and reading the code. + +3. **Determine upstream and downstream direction.** Ask: whose model influences the other? The upstream team publishes; the downstream team consumes and must adapt. Upstream is typically the one with less need to change. Mark direction with arrows (U → D). + +4. **Name the relationship pattern.** For each integration line, assign one of the nine patterns based on organizational dynamics and technical reality. Discuss with both teams — they often have different perceptions of the relationship type. + +5. **Note translation mechanisms.** For each integration, mark whether an ACL, Published Language, or Open Host Service is in place — or whether the downstream is conforming directly to the upstream model. + +6. **Mark organizational dynamics.** Note which contexts are Core Domains, Supporting Subdomains, and Generic Subdomains. The relationship pattern should generally protect Core Domains from upstream corruption. + +7. **Post it visibly.** A Context Map should be a physical or wiki artifact visible to all teams. It is a living document — update it when teams change, systems are integrated, or relationship patterns shift. + +--- + +## Reading a Context Map + +**Upstream / downstream arrows** indicate the direction of model influence, not necessarily data flow. The upstream team's model shapes what the downstream must adapt to. An upstream team with no accountability to downstream consumers is a signal of either Conformist or ACL, not Customer-Supplier. + +**Translation responsibility** always falls on the downstream side. If a downstream context integrates with an upstream without an ACL, it is either Conformist (intentional) or an undetected corruption risk (a problem). The presence of an ACL on the map signals the downstream team is actively protecting its model. + +**Coupling signals.** Partnership and Shared Kernel indicate the highest coupling — both teams are mutually affected by changes. Customer-Supplier is asymmetric but negotiated. Conformist and ACL are asymmetric with no upstream accountability. Open Host Service and Published Language decouple via contracts. Separate Ways means no coupling. Big Ball of Mud means unknown or unmapped coupling. + +**Interpreting risk.** A Core Domain downstream of a Big Ball of Mud with no ACL is a high-risk integration — corruption will spread. A Core Domain using Separate Ways for non-essential functionality is healthy autonomy. Multiple Conformist relationships in a Core Domain are a warning that the team has lost control of its model. + +**Map evolution.** Context Map relationships change as organizations evolve. A Partnership that becomes a Customer-Supplier after a reorg is not a failure — it is normal. The map must be updated to reflect reality, or it becomes misleading. Treat relationship pattern changes as architectural decisions worth recording. diff --git a/skills/ddd-best-practices/references/cqrs-and-events.md b/skills/ddd-best-practices/references/cqrs-and-events.md new file mode 100644 index 0000000..e0cc522 --- /dev/null +++ b/skills/ddd-best-practices/references/cqrs-and-events.md @@ -0,0 +1,318 @@ +# CQRS and Event-Driven Patterns + +Patterns for separating reads from writes, communicating through events, and modeling time-based business processes. These complement tactical DDD patterns and enable scalability without sacrificing domain model integrity. + +--- + +## CQRS (Command Query Responsibility Segregation) + +**Intent:** Separate the model used to mutate state (write side) from the model used to answer queries (read side). + +**How it works:** Commands go through the domain model, enforcing all business rules and invariants via aggregates. Queries bypass the domain model entirely and hit a purpose-built read model — typically a flat, denormalized projection optimized for the UI or reporting layer. The read model is updated asynchronously by subscribing to domain events emitted by the write side. This makes the two sides eventually consistent. + +**When to use:** +- The system has fundamentally different read and write workloads (heavy reporting, dashboards, search views alongside a rich command model). +- The domain model's structure is too complex to serve query requirements efficiently (e.g., aggregates that hide data needed for displays). +- You are already using event sourcing — CQRS is a natural complement because projections must be built from events anyway. +- Bounded contexts that need multiple independent read representations of the same data. + +**When NOT to use:** +- Simple CRUD systems where read and write shapes are nearly identical. +- Small teams where the operational overhead of maintaining two models outweighs the benefit. +- Early-stage products where the query requirements are still unknown and premature optimization is costly. +- Supporting or generic subdomains with simple business logic — transaction script or active record suffices. + +**Key trade-off:** Read models are eventually consistent with the write model. Callers must tolerate a window of staleness after a command completes. Absolute consistency across both sides requires either synchronous projection (negates the scalability benefit) or special UI handling. + +**Related patterns:** Event Sourcing (events are the natural feed for projections), Domain Events (carry the data needed to update read models), Outbox Pattern (ensures events reach the projection engine reliably). + +**Practical heuristic:** If a query requires joining more than 3 aggregates or crosses bounded context boundaries, that query does not belong in the domain model — build a dedicated read model projection for it. + +--- + +## Event Sourcing + +**Intent:** Persist an aggregate's state as an immutable, append-only sequence of domain events rather than storing the current state snapshot. + +**How it works:** Every state transition in an aggregate produces a domain event that is appended to the event store. To restore an aggregate, the system replays all of its events in order, applying each one to rebuild current state. The event store becomes the single source of truth. Projections (read models) are built by consuming the event stream and computing derived state. Because the full history is preserved, past states can be reconstructed and the log provides a built-in audit trail. + +**When to use:** +- The domain requires a deep, reliable audit log (financial transactions, compliance, legal records). +- Business stakeholders need to analyze behavior over time or reconstruct historical state. +- The domain model is complex enough to warrant it (i.e., already applying the domain model pattern). +- The subdomain tracks monetary transactions or is legally obligated to record every change. +- You need to support temporal queries: "What was the state of X at time T?" + +**When NOT to use:** +- Simple subdomains (transaction script, active record) — the overhead is unjustified. +- Teams without experience managing event schema evolution — versioning events is non-trivial. +- Systems where query performance is critical and you cannot afford projection rebuilds. +- When the business has no interest in history — forcing event sourcing for its own sake adds accidental complexity. + +**Key trade-off:** Event sourcing solves the audit log and temporal query problems elegantly, but introduces significant complexity: event schema versioning, projection rebuild time, eventual consistency between the event store and read models, and a steep learning curve for developers unfamiliar with the pattern. + +**Related patterns:** CQRS (projections are the read side), Domain Events (the events stored are domain events), Outbox Pattern (used to reliably publish stored events to external consumers). + +**Practical heuristic:** Ask three questions before adopting event sourcing: Does this subdomain track money or require an audit log? Does the business want time-travel queries? Is the business logic complex enough to already need a domain model? If all three are yes, event sourcing is appropriate. If only one or two, a domain model with conventional persistence is likely sufficient. + +--- + +## Domain Events vs. Integration Events + +**Intent:** Distinguish between events that communicate state changes within a bounded context (domain events) and events that communicate across bounded context boundaries (integration events). + +**How it works:** A domain event is part of the ubiquitous language of a bounded context. It is named from the domain's perspective ("CampaignActivated", "OrderPlaced") and carries the information needed for other aggregates or sagas within the same context to react. An integration event is a message published to a message bus for consumption by other bounded contexts or external systems. Integration events are intentionally decoupled from the domain model's internal representation — they use a stable public contract to avoid coupling consumers to internal implementation details. In practice, a domain event handler translates the domain event into an integration event before publishing externally. + +**When to use:** +- Use domain events for intra-context reactions (triggering sagas, updating projections within the same bounded context). +- Use integration events for inter-context communication (notifying other bounded contexts, triggering processes in external systems). + +**When NOT to use:** +- Do not publish raw domain events directly across bounded context boundaries — this couples consumers to your internal model. +- Do not use integration events for enforcing business invariants within a single aggregate — that belongs inside the aggregate boundary. + +**Key trade-off:** The separation requires an explicit translation step (domain event → integration event), which adds a layer of indirection but protects the public API from internal model changes. Skipping this translation creates tight coupling that makes future refactoring expensive. + +**Related patterns:** Outbox Pattern (ensures integration events are published reliably), CQRS (domain events feed projections on the read side), Saga (domain events trigger saga steps). + +**Practical heuristic:** If an event needs to cross a bounded context boundary, define a separate integration event type for it — do not reuse the internal domain event class. + +--- + +## Event Storming + +**Intent:** A collaborative workshop technique that uses domain events to rapidly explore, map, and align understanding of a business domain across technical and non-technical stakeholders. + +**How it works:** Participants gather in front of a large modeling surface (physical or virtual) using color-coded sticky notes. Domain events (things that happened, in past tense — orange) are placed first in rough chronological order. The group then identifies commands (blue — actions that cause events), policies (lilac — automated rules triggered by events), read models (green — information needed to decide which command to issue), external systems (pink), and finally aggregates (yellow — the clusters of domain logic that own the events). The process reveals gaps, conflicts, and complexities in domain knowledge that written specifications miss. EventStorming runs in three variants: Big Picture (entire domain, all stakeholders, hours), Process Level (one workflow in depth), and Design Level (aggregate and bounded context boundaries, for developers). + +**When to use:** +- Kickstarting a new project or feature with diverse stakeholders who share implicit knowledge. +- Identifying bounded contexts and aggregate boundaries during strategic design. +- Discovering pain points, bottlenecks, and unclear business rules in an existing system. +- Onboarding a new team to a complex legacy domain. + +**When NOT to use:** +- Small, well-understood domains where the overhead of the workshop exceeds its value. +- Teams working alone on a subdomain they already deeply understand. +- When key domain experts are unavailable — EventStorming without the right participants produces incomplete models. + +**Key trade-off:** EventStorming produces a shared mental model and surfaces complexity quickly, but the output (sticky notes) must be translated into formal artifacts (aggregates, bounded contexts, ubiquitous language) to be useful in implementation. The session is only as good as the domain experts in the room. + +**Related patterns:** Bounded Contexts (a primary output of Big Picture EventStorming), Aggregates (identified in Design Level EventStorming), Domain Events (the core language of the workshop), Saga (process-level flows often map directly to sagas). + +**Practical heuristic:** Run a Big Picture EventStorming before committing to any bounded context boundaries. The events and pain points that emerge will reveal where the natural seams in the domain lie better than any upfront analysis. + +--- + +## Saga / Process Manager + +**Intent:** Coordinate a multi-step business process that spans multiple aggregates or bounded contexts, maintaining eventual consistency across all participants. + +**How it works:** A saga reacts to domain events by issuing commands to other aggregates or services. It is stateless in the simplest case — it is instantiated by a triggering event and executes a linear sequence of event-to-command mappings (e.g., CampaignActivated → PublishAdvertisement, PublishingConfirmed → TrackConfirmation). A process manager is the stateful variant: it has an explicit identity, persists its execution state, and implements business logic to determine which step to take next based on that state. The process manager is implemented as an aggregate (often event-sourced) that subscribes to events, transitions its internal state, and publishes CommandIssuedEvent entries that an outbox relay executes. All participants remain only eventually consistent — no two commands in a saga are atomic. + +**When to use:** +- Business processes that span multiple aggregates and cannot be placed within a single aggregate boundary (Saga for linear flows). +- Complex, branching business workflows with conditional logic based on intermediate results (Process Manager). +- Recovering from partial failures across distributed components through compensating transactions. + +**When NOT to use:** +- When the operations actually belong inside a single aggregate — use proper aggregate boundaries first. Do not use sagas to compensate for incorrectly split aggregates. +- When strong consistency is required — sagas are eventually consistent by design. +- For simple intra-aggregate coordination — the aggregate handles this internally. + +**Key trade-off:** Sagas enable multi-component coordination without distributed transactions, but the eventual consistency model means the system can be in inconsistent intermediate states. Compensating transactions are complex to design and test, and failures at any step require careful recovery logic. + +**Related patterns:** Domain Events (the signals sagas listen to), Outbox Pattern (ensures saga-issued commands are executed reliably), Process Manager (stateful extension of the saga pattern), Aggregates (sagas coordinate between aggregate boundaries). + +**Practical heuristic:** If you find yourself needing strongly consistent data across two aggregates managed by a saga, review the boundary with `aggregates.md` before reaching for coordination. A saga can manage recovery and eventual consistency, but cannot make separate commits atomic. + +--- + +## Outbox Pattern + +**Intent:** Guarantee that domain or integration events are published to external systems reliably, even if the process fails after committing a database transaction but before sending the message. + +**How it works:** Instead of publishing directly to a message bus after the domain state change, the application or Unit of Work persists Aggregate state and outgoing messages in the same database transaction, typically using an `outbox` table. A separate relay polls the outbox, publishes unpublished messages, and marks them as sent. The shared database transaction makes state and durable handoff atomic. Relay retries can publish duplicates, so delivery is normally at least once and consumers must be idempotent or deduplicate by message ID. In event-sourced systems, the event store can serve as the durable source for the relay. + +**When to use:** +- Any time you need to publish events or commands to an external system as a result of a domain state change. +- Saga and process manager implementations where command execution must survive process restarts. +- Microservices that need at-least-once delivery guarantees for inter-service messages. + +**When NOT to use:** +- Intra-process, in-memory event dispatch (domain events within a single bounded context that do not leave the process boundary). +- When the message broker already provides transactional publish semantics with your database (rare, but some systems support this natively). + +**Key trade-off:** The outbox pattern solves the dual-write problem at the cost of added operational complexity: the outbox table must be polled or tailed, relay infrastructure must be maintained, and consumers must implement idempotency to handle duplicate deliveries. + +**Related patterns:** Domain Events and Integration Events (what the outbox publishes), Saga / Process Manager (outbox is the standard delivery mechanism for saga-issued commands), Event Sourcing (event store doubles as the outbox in event-sourced systems). + +**Practical heuristic:** Any time you write to a database and need to send a message to another service in the same operation, use the outbox pattern. Avoid the temptation to publish directly in the service layer — a process crash between commit and publish will silently lose the message. + +--- + +## Raising Domain Events from Aggregates + +For the canonical design rules on semantics, granularity, payloads, Aggregate recording, creation/reconstitution, and Integration Event translation, read `domain-events.md`. This section shows only the relationship to CQRS and event-driven coordination. + +**Intent:** Let the aggregate itself record domain events so the use case never needs to know which events to create. + +**How it works:** The `AggregateRoot` base class maintains a private `domainEvents` list. Aggregates call `this.record(event)` inside named constructors or mutating methods — never inside the regular constructor. The application coordinates persistence and event handoff. The simple drain-and-publish example below is only for explicitly best-effort in-process reactions; durable delivery requires non-destructive inspection plus a transactional Outbox. + +**Example:** +```typescript +// AggregateRoot base class +export abstract class AggregateRoot { + private domainEvents: DomainEvent[] = []; + + pullDomainEvents(): DomainEvent[] { + const events = this.domainEvents; + this.domainEvents = []; + return events; + } + + protected record(event: DomainEvent): void { + this.domainEvents.push(event); + } +} + +// Aggregate named constructor raises the event +export class User extends AggregateRoot { + static create(id: string, name: string, email: string, profilePicture: string): User { + const user = new User(new UserId(id), new UserName(name), new UserEmail(email), ...); + user.record(new UserRegisteredDomainEvent(id, name, email, profilePicture)); + return user; + } + + updateEmail(email: string): void { + this.email = new UserEmail(email); + this.record(new UserEmailUpdatedDomainEvent(this.id.value, email)); + } +} + +// Best-effort in-process example only; not a durable handoff. +export class UserRegistrar { + constructor( + private readonly repository: UserRepository, + private readonly eventBus: DomainEventBus, + ) {} + + async registrar(id: string, name: string, email: string, profilePicture: string): Promise { + const user = User.create(id, name, email, profilePicture); + await this.repository.save(user); + await this.eventBus.publish(user.pullDomainEvents()); + } +} +``` + +**Practical heuristic:** Record events inside named constructors and mutating methods — never in the plain constructor. For durable behavior, append translated messages to an Outbox in the same transaction as state and clear pending events only after commit. + +--- + +## EventBus Interface and DomainEventSubscriber + +**Intent:** Decouple the domain from event transport infrastructure and define a standard contract for reacting to domain events. + +**How it works:** An internal `DomainEventBus` port dispatches internal facts within the application. A separate `IntegrationMessageOutbox`/publisher port accepts translated, versioned public contracts for cross-process delivery. Infrastructure implements each adapter without exporting internal event classes. + +The `DomainEventSubscriber` interface defines how handlers declare which events they care about. Each subscriber implements `subscribedTo()` — returning the event class constructors it handles — and `on(event)` — the handler called on dispatch. The `InMemoryEventBus` builds a `Map` at startup by iterating all registered subscribers. + +**Example — complete wiring:** +```typescript +// Domain port +export interface DomainEventBus { + publish(events: DomainEvent[]): Promise; +} + +// Domain port — subscriber contract +export type DomainEventName = { + readonly eventName: string; + readonly prototype: T; +}; + +export interface DomainEventSubscriber { + on(domainEvent: T): Promise; + subscribedTo(): DomainEventName[]; // array of event class constructors +} + +// Concrete event +export class UserRegisteredDomainEvent extends DomainEvent { + static readonly eventName = "shop.user-registered"; + readonly eventName = UserRegisteredDomainEvent.eventName; + + constructor( + public readonly id: string, + public readonly name: string, + public readonly email: string, + public readonly profilePicture: string, + ) { + super(); + } +} + +// Infrastructure — InMemoryEventBus registers subscribers at construction time +export class InMemoryEventBus implements DomainEventBus { + private readonly subscriptions = new Map Promise>>(); + + constructor(subscribers: DomainEventSubscriber[]) { + this.registerSubscribers(subscribers); + } + + async publish(events: DomainEvent[]): Promise { + for (const event of events) { + const handlers = this.subscriptions.get(event.eventName) ?? []; + for (const handler of handlers) await handler(event); + } + } + + private registerSubscribers(subscribers: DomainEventSubscriber[]): void { + subscribers.forEach((subscriber) => { + subscriber.subscribedTo().forEach((event) => { + this.subscribe(event.eventName, subscriber); + }); + }); + } + + private subscribe(eventName: string, subscriber: DomainEventSubscriber): void { + const current = this.subscriptions.get(eventName); + const handler = subscriber.on.bind(subscriber) as (event: DomainEvent) => Promise; + if (current) { + current.push(handler); + } else { + this.subscriptions.set(eventName, [handler]); + } + } +} +``` + +**Illustrative design decisions in `InMemoryEventBus`:** +- Subscribers are registered at construction time — no runtime `addSubscriber()` calls. +- The `Map` key is `event.eventName` (a string constant on the event class), not the class reference — this survives serialization boundaries. +- Events and handlers are dispatched sequentially so no sibling handler outlives a rejected publication. A collect-all or concurrent policy must await every handler before reporting failures and must not share a transaction that can roll back while work continues. +- Internal dispatch and broker publication use different contracts so infrastructure never decides how to translate domain facts into public messages. + +**Practical heuristic:** Keep the required contracts in the application/domain core and the dispatcher in infrastructure. Do not share producer-owned concrete event classes between Bounded Contexts; translate them to stable Integration Events at the boundary. + +--- + +## Aggregate Reconstruction: fromPrimitives and toPrimitives + +**Intent:** Separate the path that creates new aggregates (triggering domain events) from the path that restores existing aggregates from storage (no events raised). + +**How it works:** Give creation and restoration different semantic entry points. A named factory such as `create()` may record creation facts; a reconstitution factory or dedicated persistence mapper restores existing state without recording new facts. Restrict raw construction where practical so callers cannot accidentally choose the wrong lifecycle path. + +`fromPrimitives()`/`toPrimitives()` is a useful implementation style, not a requirement. Prefer a dedicated mapper when public serialization methods would expose persistence concerns or weaken encapsulation. + +**Practical heuristic:** Reconstitution must never invoke creation behavior or emit `Created` again. Read `aggregates.md` for lifecycle, historical-data, and mapping trade-offs. + +--- + +## Where to Publish Domain Events: Use Case vs. Aggregate + +**Intent:** Decide which layer is responsible for handing internal events to local dispatch or translated messages to durable delivery. + +**How it works:** The Aggregate records events internally (`this.record(event)`). The application owns handoff: it may dispatch best-effort local reactions after save, or translate pending facts and append them to an Outbox in the same transaction as state. Publishing from inside the Aggregate requires a transport dependency and breaks the dependency rule. + +**When NOT to:** Do not inject EventBus into the aggregate. Do not publish from a repository save hook — a failed publish after a successful save creates a split-brain state that is hard to detect. + +**Practical heuristic:** Keep event selection in the Aggregate and delivery outside it. `save aggregate -> pull events -> publish` is acceptable only for best-effort in-process delivery: it has a crash window and pulling may clear events before publication succeeds. For durable delivery, inspect pending events non-destructively, persist state and translated Outbox messages in one transaction, clear only after commit, then relay with retries and idempotent consumers. diff --git a/skills/ddd-best-practices/references/ddd-in-practice.md b/skills/ddd-best-practices/references/ddd-in-practice.md new file mode 100644 index 0000000..2c87a02 --- /dev/null +++ b/skills/ddd-best-practices/references/ddd-in-practice.md @@ -0,0 +1,811 @@ +# DDD in Practice + +Practical guidance for applying Domain-Driven Design in real projects. Covers process and discovery techniques, organizational patterns, adoption advice, and concrete PHP implementation examples for the tactical building blocks. + +--- + +## Part I: Process and Discovery + +## Knowledge Crunching + +**Intent:** Build a shared understanding of the problem domain by actively collaborating with domain experts to extract, refine, and model knowledge. + +**How it works / How to apply:** Knowledge crunching is not a one-time phase — it is an ongoing process throughout the lifetime of a project. Deep insights and breakthroughs only emerge after many iterations of working with the domain. Focus sessions on the most important use cases; do not simply read requirements aloud and ask experts to comment. Ask powerful questions to understand the intent behind requirements, not just the requirements themselves. + +**Practical heuristic:** Work *with* domain experts, not *for* them — the developer's job is to enable, not to execute requirements blindly. + +--- + +## Impact Mapping + +**Intent:** Clarify the business impact a product is trying to make before defining features, so that technical decisions remain aligned with business outcomes. + +**How it works / How to apply:** Create a mind-map-like diagram with four levels: the business goal (impact), the actors who can affect it, the ways each actor can help, and the deliverables that enable those ways. This goes beyond requirements documents by surfacing assumptions about who, how, and why. It lets developers suggest superior technical alternatives that business stakeholders would never have thought of. + +**Practical heuristic:** Before building any feature, ask: "What business impact does this make, and who does it affect?" — if you cannot trace the feature to the top-level goal, question whether it should be built. + +--- + +## Business Model Canvas + +**Intent:** Give developers a fast, structured way to understand the business model so they can ask more meaningful questions during knowledge crunching. + +**How it works / How to apply:** Use Alexander Osterwalder's nine-block canvas (Customer Segments, Value Propositions, Channels, Customer Relationships, Revenue Streams, Key Resources, Key Activities, Key Partnerships, Cost Structure) to visualize how the business works. Understanding what the business values, who it serves, and how it makes money enables developers to ask domain-relevant questions and identify what is truly core. + +**Practical heuristic:** Spend 30 minutes on a Business Model Canvas before the first knowledge-crunching session — it will transform the quality of questions you ask domain experts. + +--- + +## Deliberate Discovery + +**Intent:** Identify and tackle the areas of the problem domain the team is most ignorant about, rather than defaulting to comfortable, well-understood areas. + +**How it works / How to apply:** Dan North's technique: at the start of a project, the team makes a concerted effort to identify what they do not know. Unknown unknowns are the single greatest impediment to throughput. Teams should use knowledge-crunching sessions specifically to surface and reduce these gaps, led by domain experts who can focus the team on areas of genuine importance. + +**Practical heuristic:** Open each project inception by asking: "What are we most ignorant about?" — then schedule knowledge-crunching sessions specifically around those gaps. + +--- + +## Model Exploration Whirlpool + +**Intent:** Provide a structured recovery process when modeling is going wrong — communication is breaking down, designs are overly complex, or domain knowledge is insufficient. + +**How it works / How to apply:** Eric Evans's method defines five activities to run in a cycle: Scenario Exploring (domain expert describes a concrete scenario the team is worried about), Modeling (team maps the scenario visually), Challenging the Model (test the model against further scenarios), Harvesting and Documenting (capture key scenarios as reference; do not document every decision), and Code Probing (prove the model can be implemented). Use it on demand, not as a fixed project phase. + +**Practical heuristic:** When communication with the business feels strained or the design complexity is rising unexpectedly, invoke the whirlpool rather than pushing forward — the friction is the signal. + +--- + +## Domain Vision Statement + +**Intent:** Create a short, explicit statement of what is core to the product so the entire team — including business stakeholders — shares the same understanding of why the software is being built. + +**How it works / How to apply:** At project inception, ask stakeholders: "What is the business goal? What value does this bring? How will we know it is a success? How is this different from what has been done before?" Capture the answers in a brief statement and make it visible (posted on the wall). Use it to guide descoping decisions when deadlines conflict with quality. Amazon's "working backwards" practice is a concrete example: write the internal press release first, then build the product. + +**Practical heuristic:** If a feature cannot be traced back to the domain vision statement, challenge its inclusion before beginning development. + +--- + +## Problem Domain Distillation (Core / Supporting / Generic) + +**Intent:** Break a large problem domain into subdomains to focus effort where it matters most and avoid spending quality on areas that do not need it. + +**How it works / How to apply:** Identify three types of subdomains: Core domains — unique competitive differentiators, the reason the software is being written, requiring the best developers and the most investment. Supporting domains — enable core domains but offer no competitive edge; buy off-the-shelf or assign to junior developers. Generic domains — common to many businesses (e-mail, reporting); buy off-the-shelf. What is core to one business may be generic to another. Core domains change over time as competitors catch up. + +**Practical heuristic:** Put your best developers on the core domain; for generic and supporting domains, "good is good enough" — perfection there is wasted effort. + +--- + +## Build Subdomains for Replacement, Not Reuse + +**Intent:** Keep non-core subdomains isolated and simple so they can be replaced cheaply when business needs change. + +**How it works / How to apply:** When building supporting or generic domains, resist the urge to over-engineer. Code them in isolation from other models and legacy code using clean boundaries. Design with replacement in mind: in the future, these subdomains can be swapped for off-the-shelf packages or rewritten as the business evolves. A working but messy supporting subdomain isolated behind a clean boundary is acceptable; a tightly coupled one is not. + +**Practical heuristic:** Never invest in reusability for a supporting or generic subdomain — invest in replacability instead. + +--- + +## Treat the Core Domain as a Product, Not a Project + +**Intent:** Shift the business and team mindset from delivering a project to investing in a long-lived product so that quality and iteration are sustained over time. + +**How it works / How to apply:** Software for core domains is never truly finished; it lives through cycles of feature investment. Technical debt accumulated in a rush to launch becomes a serious liability in complex domains. Maintain a long-term vision shared with business sponsors, and use it to descope features rather than sacrifice code quality. If the business is uncertain whether a product will be successful, build a good-enough first version — but plan to refactor aggressively once it proves its value. + +**Practical heuristic:** Ask business sponsors: "What is the three-year vision for this product?" — if they cannot answer, the core domain has not been identified correctly. + +--- + +## Model-Driven Design (Code IS the Model) + +**Intent:** Keep the code model and the analysis model in continuous sync by using the same language and concepts in both, so that the code is the authoritative expression of the domain. + +**How it works / How to apply:** Traditional processes separate the analysis model (produced by architects) from the code model (built by developers), causing inevitable drift. DDD eliminates this separation: the code IS the model. Changes to domain understanding are immediately reflected in code structure, names, and concepts — and vice versa. When the code model diverges from the business model, it is a signal to re-engage with domain experts. The analysis model should be concrete enough to implement; overly abstract analysis models are not useful. + +**Practical heuristic:** If you cannot explain a class or method name to a domain expert using the ubiquitous language, the code model has drifted from the analysis model. + +--- + +## Application Architecture: Dependency Inversion for Domain Isolation + +**Intent:** Ensure the domain and application layers remain independent of infrastructure, frameworks, and external systems by inverting all dependencies inward. + +**How it works / How to apply:** The domain layer sits at the center and depends on nothing. The application service layer depends only on the domain layer. Outer layers (infrastructure, persistence, UI) depend on the inner layers — never the reverse. The application layer defines interfaces (for persistence, messaging, etc.) that the infrastructure layer implements. This means the domain and application logic can be tested in isolation using mocks/stubs, without touching a database or framework. Clean Architecture, Hexagonal (Ports and Adapters), and Onion Architecture all express this same pattern. + +**Practical heuristic:** If a unit test of a domain object requires spinning up a database or framework, the dependency inversion has been violated. + +--- + +## Application Service Layer + +**Intent:** Expose the use cases of a bounded context through a coarse-grained, procedural facade that coordinates domain logic without containing any domain logic itself. + +**How it works / How to apply:** Application services are thin orchestrators: they retrieve domain objects from persistence, delegate decisions to domain objects, save updated state, and publish notifications. They contain *application logic* (security, transactions, logging, coordination) but zero *domain logic*. They are named after business use cases (not CRUD operations), and their signatures represent the capabilities the system exposes. Application services are stateless except for the state needed to track task progression. They are the concrete implementation of the bounded context boundary — the "anti-corruption layer" protecting the domain from client concerns. + +**Practical heuristic:** If an application service method contains an `if` statement that reflects a business rule, extract that rule into the domain layer. + +--- + +## Bounded Context Autonomy: One Team, One Database Schema + +**Intent:** Give each bounded context full ownership of its own data schema so that models remain isolated and changes in one context cannot invalidate invariants in another. + +**How it works / How to apply:** Integration databases — where multiple bounded contexts share a single schema — make it easy to bypass the application service layer and directly manipulate domain state, invalidating invariants. Each bounded context should own its schema; they may share the same physical database but must use separate schemas. When contexts need data from each other, they communicate through application service APIs, not database joins. Different bounded contexts can use different architectural styles (CRUD, layered DDD, CQRS) without consistency across contexts. + +**Practical heuristic:** If two teams can write to the same database table, they are sharing a model and the boundary is not real. + +--- + +## Composite UI + +**Intent:** Decompose the user interface so that each region of the screen is owned by the bounded context responsible for that business capability, rather than having a single shared presentation layer. + +**How it works / How to apply:** Each bounded context exposes application services with coarse-grained methods. A composite UI assembles views from multiple bounded contexts via multiple API calls (for example, Ajax). This protects model integrity because the UI adapts to the bounded context's API contract rather than the context exposing its internal model. Different bounded contexts can own different UI regions independently. + +**Practical heuristic:** If changing the domain model of a bounded context forces a change in a shared presentation layer, the UI is not properly decomposed. + +--- + +## Context Game + +**Intent:** Reveal when a single term or concept means different things to different parts of the business, signaling the need for separate bounded contexts. + +**How it works / How to apply:** Pioneered by Greg Young. During knowledge-crunching sessions, when you suspect a term is overloaded, split the group by business department or responsibility. Give each group 20 minutes to define the term from their perspective. Reassemble and compare definitions. Where the definitions diverge, draw a context boundary. This is a low-cost workshop exercise that surfaces model boundaries without requiring up-front architecture decisions. + +**Practical heuristic:** Any term that business experts from different departments define differently is a candidate context boundary. + +--- + +## Team Topology Aligned to Bounded Contexts + +**Intent:** Assign bounded context ownership to individual teams so that autonomy in the model is matched by autonomy in the team structure. + +**How it works / How to apply:** Following Amazon's "two-pizza team" rule, no development team should be so large that it cannot be fed by two pizzas. Each team owns one or a set of bounded contexts and is responsible for all layers (presentation, domain, persistence, database schema). This allows teams to move fast without coordinating with others. Teams should hold regular cross-team knowledge-sharing sessions and practice cross-team pair programming (moving a developer to another team for a few days) to maintain system-level understanding without tight coupling. + +**Practical heuristic:** If a feature request requires a meeting between three or more teams before any code can be written, the bounded context boundaries are drawn in the wrong place. + +--- + +## DDD Adoption Anti-Patterns + +**Intent:** Avoid the failure modes that cause teams to get the cost of DDD without the benefit. + +**How it works / How to apply:** Four primary anti-patterns: (1) *DDD Lite* — applying tactical patterns (Entity, Aggregate, Repository) without the strategic work (UL, bounded contexts, knowledge crunching). The patterns are a by-product of the collaboration, not its goal. (2) *Tactical pattern perfection* — spending energy ensuring every class conforms to a pattern rather than solving business problems. (3) *Applying DDD to simple domains* — full DDD is not appropriate for CRUD-heavy supporting domains; use Transaction Script or Active Record there. (4) *Seeking validation* — the DDD process is not a certification; blindly following a pattern language to comply with a methodology is the opposite of DDD's intent. + +**Practical heuristic:** Before applying any DDD pattern, ask: "Is this extra complexity helping me deliver business value, or is it satisfying a methodology?" + +--- + +## Conditions for DDD to Succeed + +**Intent:** Identify the minimum set of prerequisites without which applying DDD will overcomplicate rather than simplify development. + +**How it works / How to apply:** DDD requires four things to be in place simultaneously: (1) A complex, nontrivial problem domain important to the business. (2) Access to engaged domain experts who understand the intent of the project. (3) An iterative development methodology — models must evolve over many cycles. (4) A focused, motivated team with solid design skills and willingness to learn the domain. Without all four, simpler approaches (CRUD, Transaction Script) will outperform DDD. The right time to apply DDD is when you encounter complexity or ambiguity — start simple and apply practices as needed. + +**Practical heuristic:** If any of the four prerequisites is missing, apply the strategic patterns of DDD (UL, subdomains, context map) but do not invest in the tactical domain model pattern. + +--- + +## Nontechnical Refactoring + +**Intent:** Continuously update code structure, names, and namespaces to reflect deepening domain knowledge, not just technical quality improvements. + +**How it works / How to apply:** As knowledge-crunching sessions reveal new domain concepts and more insightful abstractions, the codebase must be updated to reflect those discoveries. Class names, method names, and namespaces should evolve to match the growing ubiquitous language. When a grouping of implicit code logic represents a domain concept without an explicit name, name it, inform the domain expert, and wrap it in a concept. This "nontechnical refactoring" is distinct from technical refactoring and is the primary mechanism by which a domain model stays relevant and expressive over time. + +**Practical heuristic:** After every knowledge-crunching session, schedule a refactoring session to rename and restructure code to reflect any new domain concepts that emerged. + +--- + +## Supple Design via Delayed Refactoring + +**Intent:** Avoid premature refactoring by letting the code live long enough to reveal which areas change most often — then refactor to address real friction, not imagined future needs. + +**How it works / How to apply:** Martin Fowler's principle (from *Analysis Patterns*): "Design a model so that the most frequent modification of the model causes changes to the least number of types." A supple design is one that accommodates change with minimal ripple effects. Premature refactoring, driven by aesthetic preferences rather than evidence of friction, wastes effort and can obscure real change patterns. Let code accumulate enough change requests to reveal natural seams, then refactor with confidence. TDD enables safe exploration because tests survive refactoring. + +**Practical heuristic:** Do not refactor for elegance until you have seen the same area of code change at least twice — let reality reveal the seams. + +--- + +## Making the Implicit Explicit + +**Intent:** Surface hidden domain concepts buried in code as ad-hoc conditionals and logic blocks, name them, and make them first-class citizens of the domain model. + +**How it works / How to apply:** Implicit domain logic disguised as generic programming constructs (long `if` chains, flag fields, complex conditional expressions) hides important details from the model. When you find such a grouping, ask the domain expert what it represents, name the concept in the UL, and wrap the logic in a named class or method. This is how the model grows richer over time. Every implicit concept made explicit is a breakthrough that enables further discoveries and deeper collaboration. + +**Practical heuristic:** When a domain expert cannot explain what a code block does without you first translating it into business terms, that block contains an implicit concept that needs to be named. + +--- + +## Modeling Around Concrete Scenarios, Not Abstract Reality + +**Intent:** Prevent overengineering by driving model design from specific, concrete business scenarios rather than from abstract representations of the entire domain. + +**How it works / How to apply:** Select a behavior the product needs to implement, define two to four concrete scenarios for that behavior (using BDD-style Given/When/Then), and model only enough to satisfy those scenarios. This prevents developers from producing a one-model-to-rule-them-all view that reflects reality but is not useful as an abstraction. After the model satisfies the scenarios, challenge it with additional scenarios from the domain expert to verify its usefulness before committing to the application namespace. + +**Practical heuristic:** If a model element cannot be validated against at least one concrete business scenario, it is speculative — do not commit it to the codebase yet. + +--- + +## Process Manager + +**Intent:** Coordinate long-running business processes that span multiple bounded contexts without embedding cross-context orchestration logic inside any single context. + +**How it works / How to apply:** When a business process (for example, an order fulfillment workflow) touches multiple bounded contexts, a process manager (also known as a saga coordinator) tracks the state of the overall business task and delegates individual steps back to the relevant bounded contexts via messaging or web service calls. The process manager is stateless except for the state required to track task progression. It does not contain domain logic — only coordination logic. It is similar to an application service but operates at the inter-context level. + +**Practical heuristic:** If an application service in one bounded context is directly calling the domain layer of another bounded context, extract the coordination into a process manager. + +--- + +## DDD Is a Learning Process, Not a Destination + +**Intent:** Establish the correct philosophical frame for DDD adoption: it is a continuous journey of learning, refining, and experimenting — not a methodology to be implemented and certified. + +**How it works / How to apply:** A useful domain model is the product of hundreds of small refactorings, experiments, and conversations. The first model will be wrong. The second will be closer. For a complex core domain, a team should expect to produce at least three models before arriving at something genuinely useful. Wrong models have value: they reveal what does not work and sharpen understanding. Exploration and experimentation are the engine; the code artifact is just the current iteration. Challenge assumptions constantly; a model that was useful last sprint may be inadequate for the next set of features. + +**Practical heuristic:** If your team has not thrown away at least one model on a complex domain, you probably stopped exploring too early. + +--- + +## Part II: PHP Implementation Examples + +## Value Objects in PHP + +**What it looks like in PHP:** + +```php +class Money +{ + private int $amount; + private Currency $currency; + + public function __construct(int $amountInMinorUnits, Currency $aCurrency) + { + $this->amount = $amountInMinorUnits; + $this->currency = $aCurrency; + } + + public function add(Money $money): self + { + if (!$money->currency()->equals($this->currency())) { + throw new \InvalidArgumentException(); + } + return new self( + $money->amount() + $this->amount(), + $this->currency() + ); + } + + public function equals(Money $money): bool + { + return $money->currency()->equals($this->currency()) + && $money->amount() === $this->amount(); + } + + public function amount(): int { return $this->amount; } + public function currency(): Currency { return $this->currency; } +} +``` + +**Key implementation note:** Preserve observable value semantics by never mutating shared instances. Transformation methods normally return an equal or changed value instance; they may reuse an existing immutable instance when semantics are unchanged. + +Additional Value Object examples from the book: + +```php +class Currency +{ + private $isoCode; + + public function __construct($anIsoCode) + { + if (!preg_match('/^[A-Z]{3}$/', $anIsoCode)) { + throw new \InvalidArgumentException(); + } + $this->isoCode = $anIsoCode; + } + + public function equals(Currency $currency): bool + { + return $currency->isoCode() === $this->isoCode(); + } + + public function isoCode(): string { return $this->isoCode; } +} +``` + +Value equality uses `==` (same class + same attribute values) or an explicit `equals()` method. Avoid `===` for cross-instance comparison — it checks object identity, not value. + +Semantic constructors (named factory methods) substitute PHP's lack of constructor overloading: + +```php +class Money +{ + // ... + public static function fromMoney(Money $aMoney): self + { + return new self($aMoney->amount(), $aMoney->currency()); + } + + public static function ofCurrency(Currency $aCurrency): self + { + return new self(0, $aCurrency); + } +} +``` + +Use `self` instead of `static` in factory methods to avoid unexpected behavior when subclassed. + +--- + +## Entities in PHP + +**What it looks like in PHP:** + +```php +namespace Ddd\Billing\Domain\Model; + +class Order +{ + private $id; // OrderId value object + private $amount; + private $firstName; + private $lastName; + + public function __construct( + OrderId $anOrderId, + Amount $amount, + $aFirstName, + $aLastName + ) { + $this->id = $anOrderId; + $this->amount = $amount; + $this->firstName = $aFirstName; + $this->lastName = $aLastName; + } + + public function id(): OrderId { return $this->id; } +} +``` + +**Key implementation note:** Entity identity should be a Value Object (e.g., `OrderId`) rather than a plain primitive — this allows encapsulating equality logic and prevents accidental ID misuse. + +Identity generation options: +- **Persistence-generated** (AUTO_INCREMENT): simplest, but the Entity has no ID until persisted. +- **Application-generated** (UUID): preferred; use `ramsey/uuid` via Composer. +- **Client-provided**: natural keys such as ISBN for a Book. + +```php +class OrderId +{ + private $id; + + private function __construct(string $anId) + { + if (!Uuid::isValid($anId)) { + throw new \InvalidArgumentException('Invalid OrderId'); + } + $this->id = $anId; + } + + public static function create(?string $anId = null): self + { + return new self($anId ?? Uuid::uuid4()->toString()); + } + + public function equalsTo(OrderId $anOrderId): bool + { + return $anOrderId->id() === $this->id; + } + + public function id(): string { return $this->id; } +} +``` + +Use a **Surrogate Identity** in the persistence model when the ORM requires an integer primary key but the domain uses a UUID identity. Keep the mapping outside the domain Entity: + +```php +final class DoctrineOrderRecord +{ + private ?int $surrogateId = null; // generated by the database + private string $domainId; // UUID from OrderId +} +``` + +Active Record ORMs (Eloquent, Propel) force inheritance from a base class, coupling the Domain Model to persistence. Use Doctrine (Data Mapper) to keep Entities free of persistence details. + +--- + +## Aggregate Root in PHP + +**What it looks like in PHP:** + +```php +class Order // Aggregate Root +{ + private $id; + private $lines; // collection of OrderLine (child entities/VOs) + private $totalAmount; + + public function addLine(string $productName, Money $price): void + { + // All mutations go through the root — invariant enforced here + $line = new OrderLine($productName, $price); + $this->lines[] = $line; + $this->recalculateTotal(); + } + + private function recalculateTotal(): void + { + $this->totalAmount = array_reduce( + $this->lines, + fn($carry, $line) => $carry->add($line->amount()), + Money::ofCurrency($this->totalAmount->currency()) + ); + } +} +``` + +**Key implementation note:** External code must not retain mutable access to child Entities or collections. The root may expose immutable Value Objects or snapshots when doing so cannot bypass invariant-preserving commands. + +Aggregate design rules from the book: +1. **Design around business invariants**, not convenience. +2. **One repository per Aggregate Root** — child entities have no repository of their own. +3. **Persist the entire Aggregate atomically** — one transaction, one Aggregate. +4. Reference other Aggregates by identity only, not by object reference. + +```php +// Correct: add through root (Tell-Don't-Ask) +$order->addLine('DDD in PHP', new Money(2499, new Currency('USD'))); + +// Wrong: building child outside and setting it +$orderLine = new OrderLine('DDD in PHP', 24.99); +$order->addOrderLine($orderLine); // reveals internal structure +``` + +--- + +## Domain Events in PHP + +**What it looks like in PHP:** + +```php +// 1. Event definition +class UserRegistered +{ + private $userId; + private $occurredOn; + + public function __construct(UserId $userId) + { + $this->userId = $userId; + $this->occurredOn = new \DateTimeImmutable(); + } + + public function userId(): UserId { return $this->userId; } + public function occurredOn(): \DateTimeImmutable { return $this->occurredOn; } +} + +// 2. Aggregate Root collects events +class User +{ + private $events = []; + + public static function register(UserId $id, Email $email): self + { + $user = new self($id, $email); + $user->events[] = new UserRegistered($id); + return $user; + } + + public function releaseEvents(): array + { + $events = $this->events; + $this->events = []; + return $events; + } +} + +// 3. Application Service dispatches after persisting +$user = User::register($id, $email); +$this->userRepository->persist($user); +foreach ($user->releaseEvents() as $event) { + $this->eventBus->publish($event); +} +``` + +**Key implementation note:** Never fire Domain Events in the constructor if the Entity is reconstituted from the database (e.g., via Doctrine's `serialize/unserialize`) — that would re-publish events on every load. + +--- + +## Repository Interface + Implementation Separation in PHP + +**What it looks like in PHP:** + +```php +// Domain layer — interface only, no persistence details +namespace Domain\Model; + +interface PostRepository +{ + public function nextIdentity(): PostId; + public function add(Post $aPost): void; + public function remove(Post $aPost): void; + public function postOfId(PostId $anId): ?Post; + public function latestPosts(\DateTimeImmutable $sinceADate): array; +} + +// Infrastructure layer — Doctrine implementation +namespace Infrastructure\Persistence\Doctrine; + +use Doctrine\ORM\EntityRepository; +use Domain\Model\Post; +use Domain\Model\PostId; +use Domain\Model\PostRepository; + +class DoctrinePostRepository extends EntityRepository implements PostRepository +{ + public function nextIdentity(): PostId + { + return PostId::create(); + } + + public function add(Post $aPost): void + { + $this->getEntityManager()->persist($aPost); + } + + public function postOfId(PostId $anId): ?Post + { + return $this->find((string) $anId); + } +} + +// In-memory implementation for tests +namespace Infrastructure\Persistence\InMemory; + +class InMemoryPostRepository implements PostRepository +{ + private array $posts = []; + + public function add(Post $aPost): void + { + $this->posts[$aPost->id()->id()] = $aPost; + } + + public function postOfId(PostId $anId): ?Post + { + return $this->posts[$anId->id()] ?? null; + } +} +``` + +**Key implementation note:** The Repository interface belongs in the Domain layer; all concrete implementations belong in the Infrastructure layer — this keeps the domain free of framework and database dependencies. + +Key distinctions: +- Repositories are **not DAOs**: they model a collection, not a database gateway. Avoid table-centric CRUD methods. +- Generate opaque Aggregate IDs before persistence, either at the application boundary or through a domain-facing generator such as `repository.nextIdentity()`. Supplying the ID with the create command improves retry idempotency. Keep business sequences separate and allocate them with concurrency-safe infrastructure. +- Use **Collection-Oriented** style (no explicit `save()` call needed when the ORM tracks changes) or **Persistence-Oriented** style (explicit `persist()`/`save()`) depending on ORM capabilities. + +--- + +## Domain Service in PHP + +**What it looks like in PHP:** + +```php +// Domain Service: logic that doesn't naturally belong to one Entity +class TransferService +{ + public function transfer( + Money $amount, + Account $sourceAccount, + Account $targetAccount + ): void { + if ($sourceAccount->balance()->lessThan($amount)) { + throw new InsufficientFundsException(); + } + $sourceAccount->debit($amount); + $targetAccount->credit($amount); + } +} +``` + +**Key implementation note:** A Domain Service is stateless and operates solely on Domain objects — it must not depend on infrastructure (no repositories, no databases) directly; inject interfaces if persistence is needed. + +When to use a Domain Service vs. putting logic on an Entity: +- The operation involves **multiple Aggregates**. +- The operation doesn't conceptually "belong" to any single Entity. +- Putting it on an Entity would require injecting infrastructure or violating Tell-Don't-Ask. + +--- + +## Application Service in PHP + +**What it looks like in PHP:** + +```php +// Request DTO (input boundary) +class SignUpUserRequest +{ + public function __construct( + public readonly string $email, + public readonly string $password + ) {} +} + +// Application Service: orchestrates domain objects, no business logic +class SignUpUserService +{ + public function __construct( + private UserRepository $userRepository + ) {} + + public function execute(SignUpUserRequest $request): void + { + $email = $request->email; + + if (null !== $this->userRepository->userOfEmail($email)) { + throw new UserAlreadyExistsException(); + } + + $user = new User( + $this->userRepository->nextIdentity(), + $email, + $request->password + ); + + $this->userRepository->persist($user); + } +} +``` + +**Key implementation note:** Application Services receive primitive DTOs (not Domain objects) from the outside world, coordinate domain objects to fulfill a use case, and return DTOs or nothing — they must not contain business rules. + +Output patterns: +- Return a **response DTO** (plain data, no Domain objects exposed to callers). +- Use an **output port / Data Transformer** injected into the service for flexibility. +- Module structure: `Application/PlaceAnOrder/PlaceAnOrder.php`, `PlaceAnOrderRequest.php`, `PlaceAnOrderResponse.php`. + +--- + +## Specification Pattern in PHP + +**What it looks like in PHP:** + +```php +interface Specification +{ + public function isSatisfiedBy($candidate): bool; +} + +class PostPublishedAfterSpecification implements Specification +{ + public function __construct(private \DateTimeImmutable $date) {} + + public function isSatisfiedBy($candidate): bool + { + return $candidate->publishedAt() > $this->date; + } +} + +// Composite specifications +class AndSpecification implements Specification +{ + public function __construct( + private Specification $one, + private Specification $two + ) {} + + public function isSatisfiedBy($candidate): bool + { + return $this->one->isSatisfiedBy($candidate) + && $this->two->isSatisfiedBy($candidate); + } +} + +// Usage +$spec = new AndSpecification( + new PostPublishedAfterSpecification(new \DateTimeImmutable('-30 days')), + new PostByAuthorSpecification($authorId) +); + +$matchingPosts = array_filter($posts, fn($p) => $spec->isSatisfiedBy($p)); +``` + +**Key implementation note:** When used with Doctrine, create a parallel `DoctrineSpecification` that translates to DQL/QueryBuilder expressions rather than filtering in-memory — filtering large collections in PHP is a performance anti-pattern. + +Common uses: +- Validation (is this Entity in a valid state for an operation?). +- Selection / querying from repositories. +- Business rule encapsulation that needs to be reused across services. + +--- + +## PHP Anti-Patterns + +### Active Record ORM Leaking into the Domain + +**Problem:** + +```php +// Anti-pattern: Eloquent model IS the domain object +class User extends \Illuminate\Database\Eloquent\Model +{ + // Domain logic mixed with persistence concerns + // Enforces one-to-one table-to-class mapping + // Makes unit testing without a database nearly impossible +} +``` + +**Why it hurts DDD:** +- Active Record assumes a one-to-one mapping between Entity and table, coupling database schema to Domain design. +- Inheriting from the ORM base class pollutes Domain objects with infrastructure methods (`save()`, `delete()`, query scopes). +- Collections, inheritance, and complex invariants are hard to model. + +**Fix:** Use **Doctrine ORM** (Data Mapper pattern). Keep Entities as plain PHP objects; let Doctrine handle persistence through XML/YAML/attribute mappings. + +### Anemic Domain Model + +**Problem:** + +```php +// Anti-pattern: Entity is just a bag of getters/setters +class Order +{ + private $status; + public function getStatus() { return $this->status; } + public function setStatus($status) { $this->status = $status; } // no invariant +} + +// Business logic lives in a Service +$order->setStatus('shipped'); // any value, any time — invariant impossible +``` + +**Fix:** Put business behaviour on the Entity itself. Methods like `ship()`, `cancel()`, `approve()` encode the state transition and protect invariants: + +```php +class Order +{ + private $status; + + public function ship(): void + { + if ($this->status !== 'paid') { + throw new \DomainException('Only paid orders can be shipped.'); + } + $this->status = 'shipped'; + $this->events[] = new OrderShipped($this->id); + } +} +``` + +### Using PHP `serialize/unserialize` for Domain Objects + +**Problem:** Refactoring class names or namespaces silently breaks deserialized objects stored in Redis or sessions. + +**Fix:** Use JSON with explicit reconstruction logic, or rely on Doctrine's internal proxy/hydration which bypasses the constructor. + +### Mutating Value Objects + +**Problem:** + +```php +public function add(Money $money): void +{ + $this->amount += $money->amount(); // mutates — breaks immutability contract +} +``` + +**Fix:** Always return a new instance from any method that would change state (shown in the Value Objects section above). + +### Using `static` Instead of `self` in Value Object Factory Methods + +**Problem:** + +```php +public static function fromMoney(Money $aMoney): static +{ + return new static(...); // breaks when subclassed +} +``` + +**Fix:** Use `new self(...)` to avoid binding to subclass constructors unexpectedly. diff --git a/skills/ddd-best-practices/references/domain-errors.md b/skills/ddd-best-practices/references/domain-errors.md new file mode 100644 index 0000000..c6c2536 --- /dev/null +++ b/skills/ddd-best-practices/references/domain-errors.md @@ -0,0 +1,168 @@ +# Domain Failure Modeling + +Source: principles and counterexamples reviewed from [CodelyTV/domain_modeling-errors-course](https://github.com/CodelyTV/domain_modeling-errors-course), corrected and generalized for production use. + +Use this reference to define failure vocabulary, ownership, expected control flow, composition, and safe boundary translation. HTTP formatting and operational retry mechanics are separate concerns. + +## Failure Taxonomy + +Classify by meaning, owner, and recovery rather than by wording: + +| Category | Example | Owner | Typical channel | +|---|---|---|---| +| Intrinsic validation/invariant | `PostContentTooLong` | Value Object/Aggregate | typed error value or exception | +| Use-case outcome | `PostNotFound`, `EmailAlreadyRegistered` | Application/domain capability | `Result`/`Either` or typed exception | +| Port-level recoverable failure | `VersionConflict`, `DependencyUnavailable` | Application port contract | typed result when caller can recover | +| Vendor/technical failure | SQL syntax, socket reset | Infrastructure | exception/defect with preserved cause | +| Request/auth failure | malformed JSON, unauthenticated caller | Delivery | protocol-specific response | +| Programmer defect | impossible branch, broken invariant implementation | Code/runtime | fail fast, log, generic boundary response | + +Do not convert an infrastructure failure into absence. A database outage is not `UserNotFound`. Do not relabel a business rejection as a SQL or HTTP concept. + +## Ownership + +Place a failure next to the rule or capability that gives it meaning: + +- An invalid email belongs with `EmailAddress`. +- `OrderCannotBeCancelled` belongs with the cancellation rule. +- `PostNotFound` may belong to the application finder/use case because absence becomes failure only for that operation. +- A repository normally returns `null`/`Option` for ordinary absence; the caller decides whether absence is acceptable. +- Infrastructure translates vendor errors into stable port-level categories only when callers can act on them. Otherwise preserve the technical exception and cause for operational handling. + +A use case's public failure type includes expected failures from its collaborators. Composition must preserve that union rather than hiding it behind `Error`. + +## Stable Internal Codes + +Represent distinguishable failures with a stable bounded-context code independent of class names and messages: + +```typescript +abstract class DomainFailure extends Error { + abstract readonly code: string; + + protected constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} + +class UserNotFound extends DomainFailure { + readonly code = "users.user_not_found"; + + constructor(readonly userId: UserId) { + super("Required user was not found"); + } +} +``` + +The code is internal application/domain vocabulary. The HTTP API may map it to a different public Problem Details type. Neither `constructor.name` nor raw `message` is a stable external contract. + +Inheritance is optional. A language-native enum, sealed hierarchy, tagged union, checked exception, or error value is equally valid when it preserves identity and exhaustiveness. + +## Option, Result, or Exception + +| Situation | Prefer | +|---|---| +| Absence is normal and needs no reason | `Option` / nullable search result | +| Expected failure changes caller behavior | `Result` / `Either` | +| Trusted domain command rejects an invalid transition | typed exception or `Result`, consistently | +| Parsing untrusted input is normal control flow | `Result` | +| Technical defect/unavailable dependency is not recoverable here | exception/defect with cause | + +`Option` cannot explain why a value is absent. Do not force the delivery layer to invent a domain failure after information has been discarded. + +Exceptions and Results can coexist at different boundaries: expected domain outcomes in the typed channel, unexpected defects in the exception/defect channel. Avoid mixing both channels for the same expected failure within one capability. + +## Discriminated Result + +Prefer a proven language/library implementation when available. A minimal TypeScript shape illustrates the domain contract: + +```typescript +type Result = + | { readonly kind: "ok"; readonly value: T } + | { readonly kind: "error"; readonly error: E }; + +type LikePostFailure = PostNotFound | UserNotFound | AlreadyLiked; + +declare function likePost(command: LikePost): Promise>; +``` + +Eliminate Results through exhaustive `match`/`fold` or narrowing. Avoid partial extraction methods that throw on the wrong branch; they recreate the hidden failure channel the type was meant to remove. + +Do not constrain a generic `Result` implementation to `DomainFailure`: parsers, ports, and infrastructure adapters may need other typed error values. + +## Exhaustive Handling + +Use discriminated unions, sealed hierarchies, checked exceptions where appropriate, or library matchers so adding a failure forces callers to update: + +```typescript +function describeFailure(error: LikePostFailure): string { + if (error instanceof PostNotFound) return "post missing"; + if (error instanceof UserNotFound) return "user missing"; + if (error instanceof AlreadyLiked) return "already liked"; + return assertNever(error); +} +``` + +TypeScript does not track thrown exception sets. A type alias beside a throwing function is documentation, not enforcement. Never catch any base error and cast it to caller-selected `T`; require runtime guards or explicit constructor/code maps, and treat unmatched errors as unknown. + +`instanceof` is useful within one runtime but can fail across realms, package copies, or serialization. Use stable discriminants at process/module boundaries. + +## State Atomicity + +A rejected domain operation must leave state and pending events unchanged. Validate before mutation, or stage changes and commit them only after every rule passes. + +If a use case performs irreversible work before a later expected failure, returning `Result.error` does not undo that work. Order side effects deliberately and use transactions, Outbox, idempotency, or compensation according to the boundary. + +## Boundary Translation and Redaction + +Delivery maps known internal failures to protocol semantics. It must explicitly allow-list public code/message/details; never serialize every enumerable field or return `Error.message` by default. + +```typescript +type PublicProblem = { + type: string; + title: string; + status: number; + detail?: string; +}; + +function presentFailure(error: LikePostFailure): PublicProblem { + if (error instanceof PostNotFound) { + return { type: "https://api.example.com/problems/post-not-found", title: "Post not found", status: 404 }; + } + if (error instanceof UserNotFound) { + return { type: "https://api.example.com/problems/actor-not-found", title: "Actor not found", status: 404 }; + } + if (error instanceof AlreadyLiked) { + return { type: "https://api.example.com/problems/already-liked", title: "Post already liked", status: 409 }; + } + return assertNever(error); +} +``` + +Unknown failures are logged server-side with cause and correlation/trace ID, then returned as a generic 500. Ordinary formatting belongs in one framework-level error boundary; endpoint-local handling is for endpoint-specific recovery. + +Whether a missing resource returns 404, 403, or a deliberately indistinguishable response can depend on authorization and enumeration policy. HTTP status is not part of the Domain Failure. + +## Language Guidance + +- TypeScript: discriminated unions and Result libraries make expected sets enforceable; thrown sets are not checked. +- Java: checked exceptions expose failure propagation but can become noisy; sealed result types are another option. +- Scala/Rust/F#/Elm: native sum types and exhaustive pattern matching are the default fit. +- PHP: `@throws` documents but does not enforce; static analysis and explicit result variants improve feedback. +- Effect systems: execute effects with their runtime (`runPromise`, etc.); awaiting an Effect value does not run it. + +## Review Checklist + +- Is the failure classified by domain/application/infrastructure/delivery ownership? +- Is normal absence distinct from failure and from outage? +- Can callers distinguish every outcome they recover from without parsing messages? +- Is the internal code stable and independent from class names? +- Is the expected failure set enforceable and exhaustive where the language permits? +- Are Results composed without unchecked extraction? +- Does rejection leave state/events unchanged? +- Are public details allow-listed and safe for this caller? +- Are unknown failures logged with a correlation ID and returned generically? +- Are vendor causes preserved without leaking through the public response? + +## Course Caveats + +Use the course for its progression from generic exceptions through Optional, Either, Result, language types, fp-ts, Effect, and exhaustivity. Do not copy snapshots wholesale: reviewed examples include swallowed database failures, invalid Optional adapters, ignored Results, broken Effect composition/tests, unsound generic catch casts, reflective public serialization, SQL injection, stale acceptance tests, shared self-asserting mocks, and incomplete runtime input validation. diff --git a/skills/ddd-best-practices/references/domain-events.md b/skills/ddd-best-practices/references/domain-events.md new file mode 100644 index 0000000..4c4df83 --- /dev/null +++ b/skills/ddd-best-practices/references/domain-events.md @@ -0,0 +1,151 @@ +# Domain Event Design + +Source: principles and counterexamples reviewed from [CodelyTV/domain_modeling-domain_events-course](https://github.com/CodelyTV/domain_modeling-domain_events-course), corrected and generalized for production use. + +Use this reference to decide what a Domain Event means, where it originates, what it contains, and how it differs from an Integration Event. Delivery infrastructure is a separate concern. + +## Domain Facts, Not Commands + +A Domain Event is an immutable record of a meaningful fact that already happened inside one Bounded Context. Name it in past tense using the Ubiquitous Language: + +- `UserRegistered`, not `RegisterUserEvent`. +- `UserArchived`, not `UserStatusUpdated(status = archived)`. +- `OrderPaymentFailed`, not `HandlePaymentFailure`. + +Do not emit an event for every setter or database update. A direct method call inside one Aggregate is clearer when no independent reaction or historical fact exists. + +## Granularity and Semantics + +Prefer the narrowest event that preserves the business meaning: + +- `UserEmailUpdated(userId, email)` is clearer than `UserUpdated(fullUser)`. +- `UserArchived(userId)` communicates intent better than a generic status transition. +- Separate facts when consumers, lifecycle, authorization, or evolution differ. + +Avoid generic `EntityUpdated` events. They force consumers to inspect before/after data, couple them to the producer's full shape, and hide why the change matters. + +No-op command policy must be explicit. If setting the same email twice is not a new domain fact, do not record a second event. If repeated confirmation is meaningful, name and test that fact deliberately. + +## Aggregate Records, Application Delivers + +The Aggregate decides which facts occurred because it owns the transition and invariants. It records events in memory; it does not publish them. + +```typescript +abstract class AggregateRoot { + private pendingEvents: DomainEvent[] = []; + + protected record(event: DomainEvent): void { + this.pendingEvents.push(event); + } + + pullDomainEvents(): readonly DomainEvent[] { + const events = this.pendingEvents; + this.pendingEvents = []; + return events; + } + + pendingDomainEvents(): readonly DomainEvent[] { + return [...this.pendingEvents]; + } + + clearDomainEvents(handedOff: readonly DomainEvent[]): void { + const ids = new Set(handedOff.map((event) => event.eventId)); + this.pendingEvents = this.pendingEvents.filter((event) => !ids.has(event.eventId)); + } +} + +class User extends AggregateRoot { + static create(id: UserId, email: UserEmail): User { + const user = new User(id, email); + user.record(UserRegistered.now(id, email)); + return user; + } +} +``` + +Keep `record` protected. A public method lets external callers forge facts the Aggregate did not establish. Use destructive `pullDomainEvents()` only for best-effort local dispatch. Durable handoff takes a non-destructive snapshot and selectively clears those stable event IDs after commit, preserving facts recorded later. + +The application coordinates persistence and handoff. Never inject an Event Bus or Repository into an Entity, call a static bus from a constructor, or make construction asynchronous for publication. + +## Creation and Reconstitution + +Creation and loading are different lifecycle operations: + +- `create(...)` establishes new state and may record `Created`/`Registered`. +- `fromPrimitives(...)`, `rehydrate(...)`, or a mapper restores existing state and records nothing. +- named commands record only facts caused by successful transitions. + +Never load through the creation path. Reconstitution must not resend welcome emails, analytics, or other creation reactions. + +## Payload and Envelope + +Separate business payload from transport envelope. + +Business payload should contain the identity and values required to understand the fact. Prefer immutable primitives at serialization boundaries. Do not embed a live Aggregate or dump its complete state by default. + +A durable envelope commonly contains: + +- stable event/message ID; +- aggregate ID and optional aggregate version/source sequence; +- event type and schema version; +- occurrence time represented as an immutable instant/string; +- correlation and causation IDs; +- producer/Bounded Context and tenant when applicable; +- payload. + +Metadata belongs to the envelope unless it is itself domain meaning. An occurrence timestamp is not a reliable causal ordering mechanism across machines; use an aggregate version or source position when ordering matters. + +## Domain Events and Integration Events + +A Domain Event is an internal model fact. An Integration Event is a stable published contract for another Bounded Context or external consumer. + +Translate explicitly at the boundary: + +```text +internal UserEmailUpdated + -> publication policy/translator + -> shop.user-email-updated.v2 integration message +``` + +The integration message may need more data than the internal event to avoid synchronous callbacks, but enrichment is deliberate and versioned. Do not move a producer-owned event class into a generic shared-domain folder or add `isExternal()` to every internal event as a substitute for translation. + +Not every Domain Event must be public. Publication policy can filter, combine, redact, enrich, or suppress internal facts. + +## Subscribers + +Subscribers are application handlers in the consuming context. Name them as policies, such as `SendWelcomeEmailOnUserRegistered`, and keep them thin: translate the event into a focused use-case call. + +A subscriber may handle multiple events only when they represent the same reaction. Test each accepted event independently. Consumers of durable messages must be idempotent and define behavior for duplicates, retries, and out-of-order delivery. + +Do not make primary Aggregate persistence an ordinary subscriber-derived action. The command should not report success before its source-of-truth state is durable. Event-sourced systems are different because appending the event stream is the primary persistence operation. + +## Delivery Boundary + +`save -> pull -> publish` is acceptable only for explicitly best-effort, in-process reactions. It has a crash window, and destructive pulling can lose events when publication fails. + +For durable delivery: + +1. Persist Aggregate state and Outbox messages in one database transaction using the same transaction-scoped connection. +2. Commit both or neither. +3. Relay messages asynchronously. +4. Require idempotent consumers. For local database effects, commit the Inbox record and effect in the same transaction. For remote effects, use provider idempotency keys or a durable intent plus reconciliation. + +The Event Bus, Outbox, retries, ordering, dead letters, and CDC belong to infrastructure design, not the Aggregate. + +## Review Checklist + +- Is the event a completed business fact rather than a command or CRUD notification? +- Is its name part of the Ubiquitous Language? +- Does the Aggregate record it only after a successful transition? +- Does a rejected/no-op command avoid misleading events? +- Is creation separate from reconstitution? +- Is `record` inaccessible to arbitrary callers? +- Does the payload avoid unnecessary full-Aggregate coupling? +- Are durable identity, schema version, and source ordering available where needed? +- Are cross-context messages explicitly translated and versioned? +- Is primary persistence independent from ordinary subscribers? +- Is reliable delivery handled by one real transaction plus idempotent consumption? + +## Course Caveats + +Use the course for its design progression, not as production-ready code. Reviewed snapshots include static publication from constructors, infrastructure passed into Entities, non-atomic save/publish flows, a synchronous bus that swallows failures, unwired subscribers, generic shared external events, SQL injection, and tests whose self-asserting doubles can pass when collaborators are never called. diff --git a/skills/ddd-best-practices/references/go-ddd-examples.md b/skills/ddd-best-practices/references/go-ddd-examples.md new file mode 100644 index 0000000..947972e --- /dev/null +++ b/skills/ddd-best-practices/references/go-ddd-examples.md @@ -0,0 +1,436 @@ +# Go — DDD Examples + +DDD in Go uses structs + interfaces + unexported fields. No classes, no inheritance. The same tactical patterns apply: Entities, Value Objects, Aggregates, Domain Events, Repositories. + +--- + +## Folder Structure (Bounded Context) + +``` +internal/ + user/ # Bounded Context + domain/ + user.go # Aggregate Root + Entity + user_id.go # Value Object + email.go # Value Object + events.go # Domain Events + repository.go # Repository interface (port) + application/ + register_user.go # Use case + register_user_command.go + infrastructure/ + postgres_user_repository.go # Repository implementation (adapter) +``` + +--- + +## Value Object + +```go +// email.go + +package domain + +import ( + "errors" + "strings" +) + +type Email struct { + value string +} + +func NewEmail(raw string) (Email, error) { + normalized := strings.ToLower(strings.TrimSpace(raw)) + if !strings.Contains(normalized, "@") { + return Email{}, errors.New("invalid email address") + } + return Email{value: normalized}, nil +} + +func (e Email) String() string { return e.value } +func (e Email) Equals(o Email) bool { return e.value == o.value } +``` + +```go +// user_id.go + +package domain + +import "github.com/google/uuid" + +type UserID struct { + value string +} + +func NewUserID() UserID { + return UserID{value: uuid.New().String()} +} + +func UserIDFromString(raw string) (UserID, error) { + if _, err := uuid.Parse(raw); err != nil { + return UserID{}, fmt.Errorf("invalid user ID: %w", err) + } + return UserID{value: raw}, nil +} + +func (id UserID) String() string { return id.value } +func (id UserID) Equals(o UserID) bool { return id.value == o.value } +``` + +--- + +## Aggregate Root + +```go +// user.go + +package domain + +import "time" + +// User is the Aggregate Root +// All access to the aggregate goes through User +type User struct { + id UserID + email Email + name string + active bool + createdAt time.Time + events []DomainEvent +} + +// Named constructor — enforces invariants at creation time +func NewUser(id UserID, email Email, name string) (*User, error) { + if strings.TrimSpace(name) == "" { + return nil, errors.New("name cannot be blank") + } + u := &User{ + id: id, + email: email, + name: name, + active: true, + createdAt: time.Now(), + } + u.record(NewUserRegistered(id, email)) + return u, nil +} + +// Reconstitution from persistence — no domain event recorded +func ReconstitueUser(id UserID, email Email, name string, active bool, createdAt time.Time) *User { + return &User{id: id, email: email, name: name, active: active, createdAt: createdAt} +} + +// Behavior — validates and records events +func (u *User) ChangeEmail(newEmail Email) error { + if u.email.Equals(newEmail) { + return nil + } + oldEmail := u.email + u.email = newEmail + u.record(NewUserEmailChanged(u.id, oldEmail, newEmail)) + return nil +} + +func (u *User) Deactivate() { + if !u.active { return } + u.active = false + u.record(NewUserDeactivated(u.id)) +} + +// Getters — read-only access +func (u *User) ID() UserID { return u.id } +func (u *User) Email() Email { return u.email } +func (u *User) Name() string { return u.name } +func (u *User) IsActive() bool { return u.active } +func (u *User) CreatedAt() time.Time { return u.createdAt } + +// Domain event collection +func (u *User) Events() []DomainEvent { return u.events } +func (u *User) ClearEvents() { u.events = nil } + +func (u *User) record(e DomainEvent) { u.events = append(u.events, e) } +``` + +--- + +## Domain Events + +```go +// events.go + +package domain + +import "time" + +type DomainEvent interface { + EventName() string + OccurredAt() time.Time + AggregateID() string +} + +// UserRegistered event +type UserRegistered struct { + userID UserID + email Email + occurredAt time.Time +} + +func NewUserRegistered(id UserID, email Email) UserRegistered { + return UserRegistered{userID: id, email: email, occurredAt: time.Now()} +} + +func (e UserRegistered) EventName() string { return "user.registered" } +func (e UserRegistered) OccurredAt() time.Time { return e.occurredAt } +func (e UserRegistered) AggregateID() string { return e.userID.String() } +func (e UserRegistered) UserID() UserID { return e.userID } +func (e UserRegistered) Email() Email { return e.email } + +// UserEmailChanged event +type UserEmailChanged struct { + userID UserID + oldEmail Email + newEmail Email + occurredAt time.Time +} + +func NewUserEmailChanged(id UserID, old, new Email) UserEmailChanged { + return UserEmailChanged{userID: id, oldEmail: old, newEmail: new, occurredAt: time.Now()} +} + +func (e UserEmailChanged) EventName() string { return "user.email_changed" } +func (e UserEmailChanged) OccurredAt() time.Time { return e.occurredAt } +func (e UserEmailChanged) AggregateID() string { return e.userID.String() } +``` + +--- + +## Repository Interface (Port) + +```go +// repository.go + +package domain + +import "context" + +// UserRepository is defined in the domain — implemented in infrastructure +type UserRepository interface { + Save(ctx context.Context, user *User) error + FindByID(ctx context.Context, id UserID) (*User, error) + FindByEmail(ctx context.Context, email Email) (*User, error) + ExistsByEmail(ctx context.Context, email Email) (bool, error) +} +``` + +--- + +## Use Case (Application Service) + +```go +// register_user.go + +package application + +import ( + "context" + "myapp/internal/user/domain" +) + +type RegisterUserCommand struct { + Email string + Name string +} + +type RegisterUserUseCase struct { + users domain.UserRepository + eventBus EventBus +} + +func NewRegisterUserUseCase(users domain.UserRepository, bus EventBus) *RegisterUserUseCase { + return &RegisterUserUseCase{users: users, eventBus: bus} +} + +func (uc *RegisterUserUseCase) Execute(ctx context.Context, cmd RegisterUserCommand) error { + email, err := domain.NewEmail(cmd.Email) + if err != nil { return err } + + exists, err := uc.users.ExistsByEmail(ctx, email) + if err != nil { return err } + if exists { return ErrEmailAlreadyTaken } + + id := domain.NewUserID() + user, err := domain.NewUser(id, email, cmd.Name) + if err != nil { return err } + + if err := uc.users.Save(ctx, user); err != nil { return err } + + return uc.eventBus.Publish(ctx, user.Events()...) +} + +var ErrEmailAlreadyTaken = errors.New("email already taken") +``` + +--- + +## Repository Implementation (Adapter) + +```go +// postgres_user_repository.go + +package infrastructure + +import ( + "context" + "database/sql" + "myapp/internal/user/domain" +) + +type PostgresUserRepository struct { + db *sql.DB +} + +func NewPostgresUserRepository(db *sql.DB) *PostgresUserRepository { + return &PostgresUserRepository{db: db} +} + +func (r *PostgresUserRepository) Save(ctx context.Context, user *domain.User) error { + _, err := r.db.ExecContext(ctx, + `INSERT INTO users (id, email, name, active, created_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO UPDATE SET email = $2, name = $3, active = $4`, + user.ID().String(), + user.Email().String(), + user.Name(), + user.IsActive(), + user.CreatedAt(), + ) + return err +} + +func (r *PostgresUserRepository) FindByID(ctx context.Context, id domain.UserID) (*domain.User, error) { + row := r.db.QueryRowContext(ctx, + `SELECT id, email, name, active, created_at FROM users WHERE id = $1`, + id.String(), + ) + return r.scanUser(row) +} + +func (r *PostgresUserRepository) scanUser(row *sql.Row) (*domain.User, error) { + var rawID, rawEmail, name string + var active bool + var createdAt time.Time + + if err := row.Scan(&rawID, &rawEmail, &name, &active, &createdAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { return nil, nil } + return nil, err + } + + id, _ := domain.UserIDFromString(rawID) + email, _ := domain.NewEmail(rawEmail) + return domain.ReconstitueUser(id, email, name, active, createdAt), nil +} +``` + +--- + +## Anti-Corruption Layer (ACL) + +```go +// When consuming an external service, translate at the boundary + +// External payment service response (we don't control this) +type StripeCustomer struct { + StripeID string `json:"id"` + EmailAddr string `json:"email"` + PlanName string `json:"plan"` +} + +// Domain concept +type Subscriber struct { + id UserID + email Email + plan SubscriptionPlan +} + +// ACL translator — lives in infrastructure, translates into domain concepts +type StripeACL struct{} + +func (a StripeACL) ToSubscriber(sc StripeCustomer) (Subscriber, error) { + id, err := UserIDFromStripeID(sc.StripeID) + if err != nil { return Subscriber{}, err } + + email, err := domain.NewEmail(sc.EmailAddr) + if err != nil { return Subscriber{}, err } + + plan := planFromStripeName(sc.PlanName) // translates "stripe_premium" → domain.PremiumPlan + + return Subscriber{id: id, email: email, plan: plan}, nil +} +``` + +--- + +## CQRS — Query Side + +```go +// Queries bypass the domain model and read directly from the database +// No aggregates, no value objects — just data transfer structs + +type UserView struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Active bool `json:"active"` + CreatedAt string `json:"createdAt"` +} + +type UserQueryService struct { + db *sql.DB +} + +func (s *UserQueryService) FindActiveUsers(ctx context.Context) ([]UserView, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, email, name, active, created_at FROM users WHERE active = true ORDER BY created_at DESC`, + ) + if err != nil { return nil, err } + defer rows.Close() + + var users []UserView + for rows.Next() { + var u UserView + var createdAt time.Time + if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Active, &createdAt); err != nil { + return nil, err + } + u.CreatedAt = createdAt.Format(time.RFC3339) + users = append(users, u) + } + return users, rows.Err() +} +``` + +--- + +## Domain Service + +```go +// When an operation doesn't naturally belong to any single aggregate + +package domain + +type TransferService struct{} + +// Transferring money involves two accounts — neither owns the operation +func (s TransferService) Transfer(from, to *Account, amount Money) error { + if err := from.Debit(amount); err != nil { + return fmt.Errorf("debit failed: %w", err) + } + if err := to.Credit(amount); err != nil { + // compensate + from.Credit(amount) // best-effort rollback; use saga for reliability + return fmt.Errorf("credit failed: %w", err) + } + return nil +} +``` diff --git a/skills/ddd-best-practices/references/hexagonal-architecture.md b/skills/ddd-best-practices/references/hexagonal-architecture.md new file mode 100644 index 0000000..cea3876 --- /dev/null +++ b/skills/ddd-best-practices/references/hexagonal-architecture.md @@ -0,0 +1,583 @@ +# Hexagonal Architecture (Ports & Adapters) + +Architectural pattern by Alistair Cockburn that isolates the application core from all external actors (UI, databases, message queues, external APIs) through ports (interfaces) and adapters (implementations). The goal: the application can be driven equally by users, automated tests, batch scripts, or other applications — and can work with any technology on its outside. + +--- + +## The Core Problem: Coupling to Technology + +**Definition:** The fundamental problem Hexagonal Architecture solves is business logic that leaks into UI or database code, and technology details that leak into the application core, making the system impossible to test or change without touching everything. + +**How it works:** When business logic directly depends on a specific database, framework, or UI library, swapping that technology requires tearing apart the application. Tests must start the full infrastructure stack. Changing from one database to another can shut down a project for weeks. The application becomes non-interchangeable: it can only be driven one way and connected to one set of technologies. + +**Key rule:** If you cannot substitute the production database with an in-memory stub to run your business logic tests without recompiling, you have a coupling problem that Hexagonal Architecture is designed to fix. + +**Common mistake:** Treating the database as the "foundation" at the bottom of a layered stack — this is the root cause. The database is an external actor, not a foundation; it belongs outside the application boundary. + +--- + +## Hexagon / Application as the Center + +**Definition:** The hexagon (also called "the app," "the core," or "the system") is the software containing all business logic, with no reference to databases, networks, frameworks, or any external technology. + +**How it works:** The hexagon is technology-agnostic. It is written entirely in terms of the business domain. It declares what services it provides (provided interfaces) and what services it needs from the outside world (required interfaces). It does not know or care what technology implements those required interfaces — that decision happens at wiring time. The hexagon is like a hardware chip in a catalog: its input and output pins are fully defined, and it is your job to meet those specifications. + +**Key rule:** If any class inside the hexagon imports a framework, database driver, HTTP client, or any technology-specific library, that code does not belong inside the hexagon. + +**Common mistake:** Placing adapters or infrastructure code inside the hexagon "for convenience." The hexagon should have zero compile-time dependencies on any external actor or technology. + +--- + +## Ports (Primary/Driving Ports and Secondary/Driven Ports) + +**Definition:** A port is a provided or required interface defined by the app that captures the idea of a conversation between an external actor and the app for some specific intention. + +**How it works:** Ports define the true boundary of the hexagon. Every interaction between the app and the outside world happens at a port interface, using language the app itself defines — not the language of any external technology. Ports are named for their intention, not their implementation, using the convention "ForDoingSomething" (e.g., `ForCalculatingTaxes`, `ForGettingTaxRates`, `ForPlacingOrders`, `ForSendingNotifications`). + +- **Primary (driving) port:** A port with one or more provided interfaces, used by driving actors (UI, tests, batch scripts) to make requests of the app. The app implements this interface. +- **Secondary (driven) port:** A port with one or more required interfaces, used by the app to make requests of driven actors (databases, email services, external APIs). Driven actors implement this interface. + +**Naming convention:** Intention-oriented names beginning with "For" and a verb ending in "-ing" can make the conversation explicit. The app should have no idea what technology sits beyond the port. This convention is optional: established domain patterns such as `UserRepository` are valid when the port intentionally models a collection of Aggregate Roots. + +**Common mistake:** Naming a driven port after a technology (`PostgresUsers`, `SendGridClient`) instead of the capability the core requires. Use `UserRepository` for Aggregate collection semantics, `ForPersistingUsers` when the conversational naming style is clearer, and `EmailSenderGateway` for an external capability. + +``` +// Pseudocode — type-declared language style + +// Primary (driving) port — app provides this +interface ForCalculatingTaxes { + taxOn(amount): Money +} + +// Secondary (driven) port — app requires this +interface ForGettingTaxRates { + taxRate(amount): Percentage +} + +// App implements the driving port, declares dependency on the driven port +class TaxCalculator implements ForCalculatingTaxes { + taxRateRepository: ForGettingTaxRates // held by interface, not by class + + constructor(taxRateRepository: ForGettingTaxRates) + + taxOn(amount): Money { + return amount * taxRateRepository.taxRate(amount) + } +} +``` + +--- + +## Adapters (Primary/Driving Adapters and Secondary/Driven Adapters) + +**Definition:** An adapter is the code needed to fit the interfaces defined by the app with those of driving or driven actors — it translates between the technology-specific world and the technology-neutral port. + +**How it works:** Adapters exist outside the hexagon. When an external actor already speaks the port's language (e.g., a test case coded directly against the provided interface), no adapter is needed. When an actor does not match the port's interface — for example, a human interacting through a REST controller, or a SQL database being called through a repository — an adapter translates between the two. A driving adapter receives a technology-specific request (HTTP, CLI, GUI event) and converts it into a call on the app's provided interface. A driven adapter receives a call through the app's required interface and translates it into a technology-specific action (SQL query, HTTP call, file write). + +- **Primary (driving) adapter:** Connects a driving actor to a driving port (e.g., HTTP controller, CLI parser, test harness). +- **Secondary (driven) adapter:** Connects a driven port to a driven actor (e.g., SQL repository, SMTP email sender, file reader). + +**Key rule:** Adapters always depend on ports. Ports never depend on adapters. All compile-time arrows point inward toward the app. + +**Common mistake:** Letting the driven adapter import or reference domain objects or business logic. An adapter is pure translation code; it should not contain any business rules. + +``` +// Pseudocode — driven adapter example + +// Driven adapter: translates the port interface to a real technology +class PostgresTaxRateRepository implements ForGettingTaxRates { + taxRate(amount): Percentage { + // SQL query, ORM call, etc. + result = db.query("SELECT rate FROM tax_rates WHERE ...") + return result.rate + } +} + +// Test double (also a driven adapter, in-memory): +class FixedRateTaxRepository implements ForGettingTaxRates { + taxRate(amount): Percentage { + return 0.15 + } +} + +// Driving adapter: translates HTTP into port calls +class TaxController { + app: ForCalculatingTaxes + + constructor(app: ForCalculatingTaxes) + + handlePostRequest(request): Response { + amount = parseAmount(request.body) + tax = app.taxOn(amount) + return Response.ok(tax) + } +} +``` + +--- + +## Inside vs Outside the Hexagon + +**Definition:** The hexagon boundary is the definitive line separating the application (inside) from all technology and external actors (outside), enforced by the port interfaces. + +**How it works:** Inside the hexagon lives: business logic, domain model, use case implementations, and port interface declarations. The port interfaces themselves belong to the app — they are declared by the app and owned by it. Outside the hexagon lives: all adapters, all concrete technology implementations (databases, HTTP servers, message brokers), the configurator, and all driving actors (UI, tests, batch scripts). The pattern says nothing about how you organize the inside of the app or how you organize the outside — those are your choices. You can use DDD, Clean Architecture layers, or anything else inside. Outside is equally unconstrained. + +**Key rule:** External actors may only interact with the app through its defined ports. No external actor is allowed to access anything inside the hexagon directly. + +**Common mistake:** Thinking the pattern prescribes layers inside the hexagon. Hexagonal Architecture only mandates the inside/outside split at the ports — it is completely silent on how you structure the inside. You can use DDD, procedural code, or anything else. + +--- + +## Dependency Direction: Adapters Depend on Ports, Never the Reverse + +**Definition:** All compile-time dependencies point inward — toward the app. The app has zero source code dependencies on any primary or secondary actor. + +**How it works:** The driving actor (or its driving adapter) must know the app's provided interface to call it — so the driving adapter depends on the driving port. The app must know the required interface to call driven actors — so the app depends on the driven port interface. But the driven adapter (the concrete implementation) depends on the driven port interface, not the other way around. The app never imports anything from outside the hexagon. This is the inversion of control that makes the whole pattern work: the app declares what it needs (required interfaces), and the outside world provides it. + +**Key rule:** The app has no source code imports from any adapter, driver, database library, or external system. If you see the app importing a framework class, the dependency rule is violated. + +**Common mistake:** Having the app call a concrete driven adapter class directly instead of calling through the required interface. The app must always hold a reference typed as the port interface, not as the concrete adapter class. + +--- + +## Test Strategy with Hexagonal Architecture + +**Definition:** Tests act as driving actors (or configurators), and test doubles (stubs, mocks, fakes) act as driven actors — substituting for production infrastructure without any code changes to the app. + +**How it works:** Because the app depends only on port interfaces, you can wire the app to any implementation at test time. Test cases instantiate an in-memory driven interactor (implementing the driven port), instantiate the app passing that test double in the constructor, then call the app through its driving port. No database, no HTTP server, no external service is needed. The test case acts simultaneously as configurator and driving actor. System-level tests are pure and fast because there are no real connections. You can later write integration tests by connecting the same app to a real (or test) database, and end-to-end tests by connecting the production driver — all without changing the application code. + +**Key rule:** Always include a test driver or test double at each port. Without them, a port is just a line on a diagram, not a real boundary. + +**Common mistake:** Asserting that "we don't need to abstract the database because we can use an in-memory database in tests." Even if the technology allows fast in-memory variants, they still require the full driver stack, leak technology details into tests, and prevent swapping technologies later. + +``` +// Pseudocode — test wiring + +test "calculates 15% tax on 100" { + // Test double acts as driven actor + rateRepo = FixedRateTaxRepository(rate: 0.15) + + // App wired with test double (configurator role) + app = TaxCalculator(taxRateRepository: rateRepo) + + // Test case acts as driving actor + result = app.taxOn(100) + assert result == 15 +} + +test "calculates 30% tax for France" { + rateRepo = FixedRateTaxRepository(rate: 0.30) + app = TaxCalculator(taxRateRepository: rateRepo) + result = app.taxOn(100) + assert result == 30 +} +``` + +--- + +## Comparison with Layered Architecture (Why Layers Fail) + +**Definition:** Traditional layered architecture organizes code by concern level (Presentation → Business Logic → Data Access), where higher layers depend on lower layers — placing the database at the foundation. + +**How it works:** In a layered architecture, the business logic layer depends on the data access layer, which depends on the specific database technology. Swapping the database requires changing the data access layer, which can ripple up into business logic. Running business logic tests requires the database to be present. The UI layer at the top and the database at the bottom are both considered "layers of the same stack," rather than both being external actors. Hexagonal Architecture differs in two fundamental ways: it has exactly two layers (inside the app, and outside), and it requires that all external actors — including both the UI and the database — connect only through ports. The database is not at the bottom; it is outside, on equal footing with the UI. + +**Key rule:** In Hexagonal Architecture, the UI and the database are symmetric — both are external actors that connect through ports. Neither is "above" or "below" the other. + +**Common mistake:** Implementing only the "top" side (UI → app) with an interface, but leaving the "bottom" side (app → database) as a direct dependency. Patterns like MVC solve only the driving side; they leave the driven side tightly coupled. Hexagonal Architecture is symmetric — both sides need ports. + +--- + +## Relationship to Clean Architecture and Onion Architecture + +**Definition:** Clean Architecture (Robert C. Martin) and Onion Architecture (Jeffrey Palermo) are related approaches that share the same inversion of dependency direction as Hexagonal Architecture but prescribe specific internal layering of the application core. + +**How it works:** All three architectures agree on the key principle: external technologies (UI, database, frameworks) belong outside the domain/business logic, and all dependencies point inward. They look "upside down" compared to traditional layered architectures because the application core is at the center or bottom, not at the top. The difference is that Clean Architecture prescribes four internal rings (Entities, Use Cases, Interface Adapters, Frameworks & Drivers) and Onion Architecture prescribes internal rings (Domain Model, Domain Services, Application Services, UI/Infrastructure). Hexagonal Architecture says nothing about internal structure — it only mandates the inside/outside split at ports. You are free to apply Clean or Onion Architecture's internal layering inside your hexagon. + +**Key rule:** If you want Clean or Onion Architecture's internal structure, use it inside the hexagon. Hexagonal Architecture is not in conflict with either — it is the external boundary mechanism that enables both to function without infrastructure entanglement. + +**Common mistake:** Treating Clean Architecture and Hexagonal Architecture as competing alternatives. They operate at different levels: Hexagonal defines the external boundary; Clean and Onion define internal organization. They compose rather than compete. + +--- + +## How to Implement Step by Step + +**Definition:** The development sequence for Hexagonal Architecture starts with the smallest possible skeleton that exercises the full architecture, then grows incrementally. + +**How it works:** The recommended sequence follows a "Walking Skeleton" approach — establish the architecture with minimal behavior before adding real functionality: + +**Step 0 — Set up folder structure first:** +``` +app/ + business-logic/ + driving-ports/ # port interface declarations (type-declared languages) + driven-ports/ # port interface declarations +driving-adapters/ # one subfolder per adapter +driven-adapters/ # one subfolder per adapter +tests/ +``` + +**Step 1 — Driving side, app returning a constant:** +- Declare the first driving port interface: `ForAccomplishingSomething` +- Write the simplest app that implements the driving port and returns a hardcoded constant +- Write a test that calls the driving port and expects that constant +- Run and pass the test — the driving side architecture is established + +**Step 2 — Driven side, connect a test double:** +- Declare the first driven port interface: `ForAccomplishingXYZ` +- Write a test double (in-memory class implementing the driven port) and place it in driven-adapters +- Add an instance variable typed as the driven port interface to the app +- Add a constructor that accepts the driven port interface (not the concrete class) +- Change the app to call the driven actor for the result instead of returning a constant +- Update the test: instantiate the test double, pass it to the app constructor, verify the result +- The full Ports & Adapters architecture is now established + +**Step 3 — Driving side, add real driving actor:** +- Add a real UI, web controller, or CLI adapter to driving-adapters +- Connect it to the driving port; it still uses the test double on the driven side + +**Step 4 — Driven side, add real repository or receiver:** +- Choose the production technology (database, file, API) +- Write a driven adapter in driven-adapters that implements the driven port using real technology +- Wire production driver to production driven actor via the configurator (main, DI container, or composition root) + +**Key rule:** Always declare the app's dependency on a driven actor using the port interface type — never the concrete adapter class. The configurator is the only place that knows about concrete implementations. + +**Common mistake:** Building the full application before establishing the architecture. The architecture should be established in step 1-2 with minimal behavior, so that every subsequent feature addition inherits the correct structure automatically. + +--- + +## The Configurator (Fifth Element) + +**Definition:** The configurator is the piece of code — outside the pattern itself — that wires all players together: it instantiates driven adapters, instantiates the app (injecting driven adapters), and instantiates driving adapters (injecting the app). + +**How it works:** The configurator is the "know-it-all" element. It is the only place in the system that knows about concrete adapter classes. In production, this is typically `main`, a composition root, or a dependency injection framework like Spring. In tests, the test case itself acts as the configurator. The configurator always follows this order: (1) instantiate driven adapters, (2) instantiate the app with driven adapters injected, (3) instantiate driving adapters with the app injected. + +**Key rule:** The configurator must be the only place that references concrete adapter classes. Every other piece of code should reference only port interfaces. + +**Common mistake:** Letting the app look up its own driven actors (service locator antipattern without isolation). If the app calls a service locator that returns concrete types, the app now has a hidden dependency on concrete infrastructure, defeating the purpose of the driven port. + +--- + +## Hexagonal Architecture in the Frontend + +Source: https://github.com/CodelyTV/frontend-hexagonal_architecture-course + +The same Ports & Adapters principles that govern a backend service apply identically to a frontend application. The UI framework (React, Vue, Angular) is a driving adapter. The HTTP API, localStorage, and browser APIs are driven adapters. The application use cases and domain model live inside the hexagon, with zero framework imports. + +--- + +### The Frontend Hexagon + +**Intent:** Keep UI components thin by pushing all decision logic into framework-agnostic use case functions inside the hexagon. + +**How it works:** The frontend hexagon contains: domain model types (`Course`, `User`), repository port interfaces (`CourseRepository`), and use case functions (`getAllCourses`, `createCourse`). The UI component (React, Vue, etc.) acts as a driving adapter — it calls a use case and renders the result. No fetch calls, no localStorage reads, no API URLs appear inside the hexagon. + +**Folder structure:** +``` +src/ + modules/ + courses/ + domain/ + Course.ts # Domain type (interface or class) + CourseRepository.ts # Driven port (interface) + application/ + get-all/ + getAllCourses.ts # Use case function + create/ + createCourse.ts # Use case function + infrastructure/ + HttpCourseRepository.ts # Driven adapter (fetch API) + LocalStorageCourseRepository.ts # Driven adapter (localStorage) + InMemoryCourseRepository.ts # Test double + sections/ + courses/ + CoursesSection.tsx # Driving adapter (React component) +``` + +**Practical heuristic:** If your React/Vue component imports `fetch`, `axios`, or `localStorage` directly, the hexagon boundary has been broken. The component should only import use case functions. + +--- + +### Domain Model in the Frontend + +**Intent:** Define what the application cares about as a pure TypeScript type — no framework, no HTTP, no DOM. + +**How it works:** A frontend domain model is typically a plain TypeScript interface. Unlike backend DDD where the aggregate has rich behavior, the frontend domain model often represents a read model: the data shape the UI needs to display or manipulate. The key rule is that it must be definable without importing any framework. + +**Example:** +```typescript +// src/modules/courses/domain/Course.ts +export interface Course { + id: string; + title: string; + imageUrl: string; +} +``` + +**Practical heuristic:** If your domain type imports anything from `react`, `vue`, `axios`, or your HTTP client, it belongs in infrastructure, not domain. A domain type should be portable to a CLI, a test, or a different framework without changes. + +--- + +### Repository Port in the Frontend + +**Intent:** Declare what the application layer needs from data sources using a domain-language interface, with no technology details. + +**How it works:** The `CourseRepository` interface lives in `domain/` and describes the operations the use cases need. The interface uses domain types as parameters and return values. Multiple adapters can implement the same port: one fetches from an HTTP API, another reads from localStorage, a third uses in-memory data for tests. The use case never knows which adapter it has. + +**Example:** +```typescript +// src/modules/courses/domain/CourseRepository.ts +import { Course } from './Course'; + +export interface CourseRepository { + save(course: Course): void; + getAll(): Promise; +} +``` + +**Practical heuristic:** A repository port should read like a domain vocabulary list — `save`, `getAll`, `findById`. If you see `get('/api/courses')` or `localStorage.getItem()` in an interface, it belongs in the adapter, not the port. + +--- + +### Use Case Functions in the Frontend + +**Intent:** Express application behavior as pure functions that depend only on the repository port interface, making them testable without any infrastructure. + +**How it works:** A frontend use case accepts the repository as a parameter (dependency injection by argument) and returns the domain result. Two styles appear in the CodelyTV course: a simple function that directly accepts the repository and arguments, and a curried function that closes over the repository and returns an executable function. Both keep the use case 100% framework-free. + +**Example:** +```typescript +// Simple style — src/modules/courses/application/create/createCourse.ts +import { Course } from '../../domain/Course'; +import { CourseRepository } from '../../domain/CourseRepository'; + +export function createCourse( + courseRepository: CourseRepository, + course: Course +): void { + courseRepository.save(course); +} + +// Curried style — src/modules/courses/application/get-all/getAllCourses.ts +import { Course } from '../../domain/Course'; +import { CourseRepository } from '../../domain/CourseRepository'; + +export function getAllCourses(courseRepository: CourseRepository) { + return async function (): Promise { + return courseRepository.getAll(); + }; +} +``` + +**Practical heuristic:** If the use case function body contains `fetch`, `axios`, `useState`, or any framework API, it has leaked into infrastructure. The function should only call methods on the port interface it received. + +--- + +### Driven Adapters: Infrastructure Implementations + +**Intent:** Implement the repository port for a specific technology, keeping all technology-specific code — URLs, HTTP clients, storage keys — in one isolated class or factory function. + +**How it works:** Each adapter implements the `CourseRepository` interface using a concrete technology. The `LocalStorageCourseRepository` uses `localStorage`; an `HttpCourseRepository` would use `fetch`. Adapters created as factory functions (rather than classes) are idiomatic in functional TypeScript. The adapter is wired to the use case in the component or a composition root — never inside the use case itself. + +**Example:** +```typescript +// src/modules/courses/infrastructure/LocalStorageCourseRepository.ts +import { Course } from '../domain/Course'; +import { CourseRepository } from '../domain/CourseRepository'; + +export function createLocalStorageCourseRepository(): CourseRepository { + return { save }; +} + +function save(course: Course): void { + const courses = getAllFromLocalStorage(); + courses.set(course.id, course); + localStorage.setItem('courses', JSON.stringify(Array.from(courses.entries()))); +} + +function getAllFromLocalStorage(): Map { + const courses = localStorage.getItem('courses'); + if (courses === null) return new Map(); + return new Map(JSON.parse(courses) as Iterable<[string, Course]>); +} +``` + +**Practical heuristic:** Every line in an adapter that references a technology-specific API (`fetch`, `localStorage`, `indexedDB`, `axios`) is correct and expected there. Any such line found outside an adapter is a boundary violation. + +--- + +### Testing Frontend Hexagonal Code + +**Intent:** Test use cases in complete isolation from the browser, network, and UI framework by substituting real adapters with in-memory test doubles. + +**How it works:** The in-memory repository implements the same `CourseRepository` interface using a plain JavaScript `Map`. The test instantiates the in-memory repository, calls the use case passing that repository, then asserts against the repository's state. No browser APIs, no mocking frameworks, no HTTP servers are needed. The test is a fast, deterministic unit test. + +**Example:** +```typescript +// src/modules/courses/infrastructure/InMemoryCourseRepository.ts +import { Course } from '../domain/Course'; +import { CourseRepository } from '../domain/CourseRepository'; + +export function createInMemoryCourseRepository(): CourseRepository & { + getStoredCourses(): Map; +} { + const store = new Map(); + + return { + save(course: Course): void { + store.set(course.id, course); + }, + async getAll(): Promise { + return Array.from(store.values()); + }, + getStoredCourses() { + return store; + }, + }; +} + +// createCourse.test.ts +import { createCourse } from '../application/create/createCourse'; +import { createInMemoryCourseRepository } from '../infrastructure/InMemoryCourseRepository'; + +describe('createCourse use case', () => { + it('saves the course to the repository', () => { + const repository = createInMemoryCourseRepository(); + const course = { id: '1', title: 'DDD Course', imageUrl: '/img.png' }; + + createCourse(repository, course); + + expect(repository.getStoredCourses().get('1')).toEqual(course); + }); +}); +``` + +**Practical heuristic:** If your use case test imports anything from a browser API, a framework, or a network library, the hexagon boundary is broken and the test is testing more than one thing. Fix the boundary first, then the test becomes trivial. + +--- + +### React Wiring: App as Configurator + +**Intent:** Wire the concrete repository into the React component tree in `App.tsx`, passing it down through a Context provider so all child components receive it without knowing its implementation. + +**How it works:** `App.tsx` acts as the configurator. It creates the concrete adapter (e.g., `LocalStorageCourseRepository`) and passes it as a prop to the Context provider. The provider stores the repository reference and closes over it in the use case calls. Child components consume the context and call use case functions — they never import or know about the repository. + +**Three-file pattern:** + +```typescript +// --- src/App.tsx (the configurator) --- +import { createLocalStorageCourseRepository } from "./modules/courses/infrastructure/LocalStorageCourseRepository"; +import { CoursesContextProvider } from "./sections/courses/CoursesContext"; +import { CoursesList } from "./sections/courses/CoursesList"; +import { CreateCourseForm } from "./sections/courses/CreateCourseForm"; + +export function App() { + // Configurator: instantiate the concrete adapter here, nowhere else + const repository = createLocalStorageCourseRepository(); + + return ( + + + + + ); +} + +// --- src/sections/courses/CoursesContext.tsx (the driving adapter) --- +import React, { useContext, useEffect, useState } from "react"; +import { createCourse } from "../../modules/courses/application/create/createCourse"; +import { getAllCourses } from "../../modules/courses/application/get-all/getAllCourses"; +import { Course } from "../../modules/courses/domain/Course"; +import { CourseRepository } from "../../modules/courses/domain/CourseRepository"; + +export const CoursesContext = React.createContext({} as ContextState); + +// Provider receives the repository as a prop — it is the only component +// that knows which use case to call; children know nothing about repositories +export const CoursesContextProvider = ({ + children, + repository, +}: React.PropsWithChildren<{ repository: CourseRepository }>) => { + const [courses, setCourses] = useState([]); + + function create({ title, imageUrl }: { title: string; imageUrl: string }) { + const id = crypto.randomUUID(); + createCourse(repository, { id, title, imageUrl }); // use case gets repo as argument + getCourses(); + } + + function getCourses() { + getAllCourses(repository).then(setCourses); + } + + useEffect(() => { getCourses(); }, []); + + return ( + + {children} + + ); +}; + +export const useCoursesContext = () => useContext(CoursesContext); + +// --- src/modules/courses/application/create/createCourse.ts --- +import { Course, ensureCourseIsValid } from "../../domain/Course"; +import { CourseRepository } from "../../domain/CourseRepository"; + +// Use case: accepts repository as parameter (manual DI by argument) +export async function createCourse( + courseRepository: CourseRepository, + course: Course, +): Promise { + ensureCourseIsValid(course); + await courseRepository.save(course); +} + +// --- src/modules/courses/application/get-all/getAllCourses.ts --- +import { Course } from "../../domain/Course"; +import { CourseRepository } from "../../domain/CourseRepository"; + +export async function getAllCourses(courseRepository: CourseRepository): Promise { + return courseRepository.getAll(); +} +``` + +**Practical heuristic:** `App.tsx` is the only file that imports a concrete infrastructure class. Every other file imports only domain types, port interfaces, or use case functions. If a component imports a concrete repository, the configurator boundary is broken. + +--- + +### Testing the React Driving Adapter + +**Intent:** Test a component in isolation by passing an inline mock object that satisfies the repository port interface — no need for any real infrastructure. + +**How it works:** The test renders `CoursesContextProvider` with a hand-crafted object literal that implements `CourseRepository`. Because the provider receives the repository as a prop, the test can pass jest spy functions and assert they were called. This is the equivalent of passing a test double to the app's constructor in a backend context. + +```typescript +// tests/sections/courses/CreateCourseFormWithMockedRepository.spec.tsx +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { CoursesContextProvider } from "../../../src/sections/courses/CoursesContext"; +import { CreateCourseForm } from "../../../src/sections/courses/CreateCourseForm"; + +describe("CreateCourseForm component", () => { + it("calls repository.save when form is submitted", async () => { + const save = jest.fn(); + + render( + // Inline mock satisfies CourseRepository port — no framework, no localStorage + + + + ); + + await userEvent.type(screen.getByLabelText(/title/i), "Hexagonal Architecture"); + await userEvent.type(screen.getByLabelText(/image/i), "http://example.com/img.png"); + await userEvent.click(screen.getByText(/create course/i)); + + expect(save).toHaveBeenCalled(); + }); +}); +``` + +**Practical heuristic:** Component tests that need a repository should never call `createLocalStorageCourseRepository()` or `new HttpCourseRepository()`. Pass an inline mock object directly as the `repository` prop. The test acts as the configurator. diff --git a/skills/ddd-best-practices/references/read-models.md b/skills/ddd-best-practices/references/read-models.md new file mode 100644 index 0000000..fba7e45 --- /dev/null +++ b/skills/ddd-best-practices/references/read-models.md @@ -0,0 +1,359 @@ +# Read Models and Projections + +How to build the read side of CQRS: when to use read models instead of aggregates for queries, how to structure them, and how to keep them updated via projections driven by domain events. Sources: CodelyTV `use_case-aggregates_read_model_ddd-course` and `domain_modeling-projections-course`. + +--- + +## Read Models vs. Aggregates for Queries + +**Intent:** Use aggregates for writes that enforce invariants; use flat read models for queries that serve the UI. + +**How it works:** Aggregates are optimized for consistency and business rule enforcement. Distinguish three read mechanisms: an aggregate-derived response DTO, a database view/materialized view, and an independently stored projection. Only the last has its own synchronization lifecycle. The write model or event history remains authoritative business state; a projection is a disposable, rebuildable query-serving copy. + +**Example:** +```typescript +// Write side: aggregate enforces invariants +export class ProductReview extends AggregateRoot { + // private fields, value objects, business rules... + toPrimitives(): ProductReviewPrimitives { ... } +} + +// Read side: use case returns flat primitives, not the aggregate +export class ProductReviewsByUserSearcher { + constructor(private readonly repository: ProductReviewRepository) {} + + async search(userId: string): Promise[]> { + return (await this.repository.searchByUser(new UserId(userId))) + .map((review) => review.toPrimitives()); + } +} +``` + +**Practical heuristic:** If one Aggregate already contains everything the query needs, map it to a response DTO and do not call that mapping CQRS. Never expose Aggregate internals to the delivery layer. Introduce an independent projection only when its query shape, performance, ownership, or consistency lifecycle differs from the write model. + +--- + +## Use Case Structure for Reads + +**Intent:** Keep read use cases thin — they fetch and map, nothing more. + +**How it works:** A read use case receives a query parameter, creates the typed identifier, delegates to the repository, and maps results to primitives. It never contains business logic, never modifies state, and never raises domain events. The use case is the single place that converts between the domain representation and the primitive representation consumed by the delivery layer. + +**Example:** +```typescript +// application/find/UserFinder.ts — read use case +export class UserFinder { + constructor(private readonly repository: UserRepository) {} + + async find(id: string): Promise { + const user = await this.repository.search(new UserId(id)); + if (user === null) { + throw new UserDoesNotExistError(id); + } + return user.toPrimitives(); // returns flat object, not the aggregate + } +} +``` + +**Practical heuristic:** A read use case should be expressible in fewer than 10 lines. If it is longer, the logic belongs either in the repository query or in a dedicated projection. + +--- + +## When to Build a Dedicated Read Model + +**Intent:** Recognize when `.toPrimitives()` on an aggregate is insufficient and a separate read model is needed. + +**How it works:** When a query requires combining data from multiple aggregates, crossing bounded context boundaries, or joining data in a way that the aggregate's structure does not support, a dedicated read model (projection) is the right tool. The read model is stored in a separate table or collection optimized for the query — denormalized, precomputed, and shaped exactly for the consumer. It is kept in sync by subscribing to domain events from the write side. + +**When to use a dedicated read model:** +- A query needs fields from more than one aggregate type. +- A query crosses a bounded context boundary. +- A query requires aggregated/computed values (counts, averages, latest-of). +- The aggregate's structure is too complex to serialize efficiently for the client. + +**When NOT to use a dedicated read model:** +- A simple `.toPrimitives()` on a single aggregate satisfies the query. +- The read/write load is not high enough to justify the synchronization overhead. +- The team cannot yet afford eventual consistency in the read path. + +**Practical heuristic:** If you find yourself loading multiple aggregates and manually assembling a response DTO in a use case, that is the signal to introduce a dedicated read model with a projection. + +--- + +## Projections from Domain Events + +**Intent:** Keep a read model up to date by reacting to domain events emitted by the write side. + +**How it works:** A projection handler (event subscriber) listens for specific domain events. When an event arrives, it updates the read model stored in a separate read store. The handler is a thin application service — it calls a use case or repository method on the read side. The read model entity itself is a plain object (not an aggregate), mutable for update operations, and stored without invariant enforcement. Because the projection is driven by events, it is eventually consistent with the write side. + +**Example:** +```typescript +// Projection handler — subscribes to domain event, updates read model +export class CreateRetentionUserOnUserRegistered + implements DomainEventSubscriber +{ + constructor(private readonly creator: RetentionUserCreator) {} + + async on(event: UserRegisteredDomainEvent): Promise { + await this.creator.create(event.id, event.email, event.name); + } + + subscribedTo(): DomainEventClass[] { + return [UserRegisteredDomainEvent]; + } + + name(): string { + return "codely.retention.create_retention_user_on_user_registered"; + } +} + +// Read model entity — flat, no invariants +export class RetentionUser { + constructor( + public readonly id: UserId, + public email: string, + public readonly name: string, + ) {} + + static create(id: string, email: string, name: string): RetentionUser { + return new RetentionUser(new UserId(id), email, name); + } + + updateEmail(email: string): void { + this.email = email; + } + + toPrimitives(): RetentionUserPrimitives { + return { id: this.id.value, email: this.email, name: this.name }; + } +} + +// Projection use case — repository performs an atomic insert/upsert +export class RetentionUserCreator { + constructor(private readonly repository: RetentionUserRepository) {} + + async create(id: string, email: string, name: string): Promise { + const user = RetentionUser.create(id, email, name); + await this.repository.insertIfAbsent(user); + } +} +``` + +Back creation with a unique source identity and an atomic insert/upsert. `search()` followed by `save()` races when two consumers observe absence concurrently. For non-idempotent updates, store `(projection_name, event_id)` in an Inbox in the same transaction as the projection mutation. + +**Practical heuristic:** Projection handlers must tolerate duplicate delivery. Prove this with database constraints, atomic operations, or a transactional Inbox; a preliminary existence query is not a correctness mechanism. + +--- + +## Updating a Projection on State Change + +**Intent:** React to subsequent domain events to keep the read model current after initial creation. + +**How it works:** Each state-changing domain event (e.g., `UserEmailUpdatedDomainEvent`) has a matching projection handler that finds the read model entry and applies the change. The read model is mutable — unlike the aggregate, there is no invariant to enforce here, only data to update. The handler follows the same `DomainEventSubscriber` contract: `subscribedTo()` declares the event type, `on()` applies the change. + +**Example:** +```typescript +export class UpdateRetentionUserEmailOnUserEmailUpdated + implements DomainEventSubscriber +{ + constructor(private readonly updater: RetentionUserEmailUpdater) {} + + async on(event: UserEmailUpdatedDomainEvent): Promise { + await this.updater.update(event.id, event.email); + } + + subscribedTo(): DomainEventClass[] { + return [UserEmailUpdatedDomainEvent]; + } + + name(): string { + return "codely.retention.update_retention_user_email_on_user_email_updated"; + } +} +``` + +**Practical heuristic:** Name the subscriber class after the action it performs and the event it reacts to: `On`. This naming makes the event-handler mapping self-documenting. + +--- + +## Synchronous vs. Asynchronous Projections + +**Intent:** Choose between updating the projection in the same transaction as the write or in a background process driven by an event bus. + +**How it works:** A synchronous projection is atomic only when source state and projection update share one real database transaction. Two sequential writes in the same request still have a dual-write failure window. An asynchronous projection uses a transactional Outbox, durable subscriber delivery, and an idempotent projection transaction; it trades immediate visibility for independent scaling and recovery. + +**Practical heuristic:** Choose from the required consistency boundary and ownership, not latency alone. Keep a projection synchronous only when it belongs in the same transaction and database. Use durable asynchronous delivery across bounded contexts or independently operated stores, and define freshness/read-your-writes behavior explicitly. + +--- + +## Obtaining Data Missing from an Event + +Do not automatically enlarge every event or query another context during projection. Choose deliberately: + +| Option | Prefer when | Main cost | +|---|---|---| +| Enrich the Integration Event | The added fact naturally belongs in the stable public contract | Contract coupling, payload growth, privacy and versioning burden | +| Query the source while handling | Same availability boundary, current state is intentionally required, replay drift is acceptable | Temporal coupling; historical replay may use today's state | +| Maintain a local supporting projection | The consumer needs autonomous, replayable facts such as post-to-owner mapping | Another projection, prerequisite ordering, bootstrap and rebuild work | + +Prefer the smallest consumer-owned supporting projection for cross-context historical calculations. Translate producer events at an Anti-Corruption Layer rather than importing producer Domain Event classes as the consumer's domain model. + +--- + +## Incremental Updates and Rebuilds + +Counters, averages, bounded latest-item lists, and other incremental projections need explicit duplicate, ordering, correction, and concurrency semantics. Store source measures such as integer counts and derive ratios where possible; reconstructing totals from a floating-point average accumulates drift. Use atomic database mutations or optimistic version checks instead of concurrent load-mutate-save operations. + +Every independent projection needs a rebuild contract: stable name and schema version, source cursor, checkpoint, reset/resume behavior, duplicate protection, validation criteria, cutover, and rollback. Prefer rebuilding into a shadow `vNext` store, replaying to a high-water mark, tailing live events, reconciling results, then atomically switching the read alias. Never scan mutable source state and consume live events concurrently without a defined handoff point. + +A mixed read/write model is an exception, not a default. Embed denormalized display data in an Aggregate only when it is bounded, belongs to the same consistency boundary, and accepted staleness and write contention are explicit. Otherwise keep presentation fields in a dedicated projection. + +--- + +## Read Model Naming Conventions + +**Intent:** Name read model classes and files to distinguish them clearly from aggregates. + +**How it works:** A read model class should include the bounded context or the query purpose in its name — for example, `RetentionUser` (read model for the Retention bounded context) vs. `User` (aggregate in the RRSS context). Both may represent the same real-world user but hold different fields and serve different purposes. The projection handler name follows the pattern `On` to make event-to-handler wiring self-documenting. + +**Example:** +- Aggregate (write side): `User` in `contexts/rrss/users/domain/` +- Read model (retention context): `RetentionUser` in `contexts/retention/users/domain/` +- Handler: `CreateRetentionUserOnUserRegistered` +- Handler: `UpdateRetentionUserEmailOnUserEmailUpdated` + +**Practical heuristic:** If you are tempted to add a query-specific field to an aggregate, stop — that field belongs in a read model, not in the aggregate that enforces business rules. + +--- + +## Write Use Case Structure — Command → UseCase → Repository + EventBus + +**Intent:** Show the complete write path: the use case creates the aggregate, saves it, and publishes domain events through an event bus. + +**How it works:** The write use case receives primitives (strings, numbers), delegates creation to the aggregate's named constructor (which records domain events internally), saves the aggregate via the repository port, and publishes the collected events via the event bus port. The aggregate's `record()` method accumulates events; `pullDomainEvents()` drains and returns them for publishing. + +**Aggregate — records domain events on state change:** +```typescript +// contexts/rrss/users/domain/User.ts +import { AggregateRoot } from "../../../shared/domain/AggregateRoot"; +import { UserRegisteredDomainEvent } from "./UserRegisteredDomainEvent"; + +export type UserPrimitives = { + id: string; + name: string; + email: string; + profilePicture: string; + status: string; +}; + +export class User extends AggregateRoot { + private constructor( + public readonly id: UserId, + private readonly name: UserName, + private email: UserEmail, + private readonly profilePicture: UserProfilePicture, + private status: UserStatus, + ) { + super(); + } + + // Named constructor — records a domain event at creation + static create(id: string, name: string, email: string, profilePicture: string): User { + const defaultStatus = UserStatus.Active; + const user = new User( + new UserId(id), + new UserName(name), + new UserEmail(email), + new UserProfilePicture(profilePicture), + defaultStatus, + ); + user.record(new UserRegisteredDomainEvent(id, name, email, profilePicture, defaultStatus)); + return user; + } + + static fromPrimitives(primitives: UserPrimitives): User { + return new User( + new UserId(primitives.id), + new UserName(primitives.name), + new UserEmail(primitives.email), + new UserProfilePicture(primitives.profilePicture), + primitives.status as UserStatus, + ); + } + + toPrimitives(): UserPrimitives { + return { + id: this.id.value, + name: this.name.value, + email: this.email.value, + profilePicture: this.profilePicture.value, + status: this.status, + }; + } + + // State-changing method — records another domain event + updateEmail(email: string): void { + this.email = new UserEmail(email); + this.record(new UserEmailUpdatedDomainEvent(this.id.value, email)); + } +} +``` + +**Domain event — carries what changed as primitives:** +```typescript +// contexts/rrss/users/domain/UserRegisteredDomainEvent.ts +import { UserDomainEvent } from "./UserDomainEvent"; + +export class UserRegisteredDomainEvent extends UserDomainEvent { + static eventName = "codely.rrss.user.registered"; + + constructor( + public readonly id: string, + public readonly name: string, + public readonly email: string, + public readonly profilePicture: string, + eventId?: string, + occurredOn?: Date, + ) { + super(UserRegisteredDomainEvent.eventName, id, eventId, occurredOn); + } + + toPrimitives() { + return { id: this.id, name: this.name, email: this.email, profilePicture: this.profilePicture }; + } + + static fromPrimitives(aggregateId: string, eventId: string, occurredOn: Date, attributes: Record) { + return new UserRegisteredDomainEvent( + aggregateId, + attributes.name as string, + attributes.email as string, + attributes.profilePicture as string, + eventId, + occurredOn, + ); + } +} +``` + +**Write use case — save + publish:** +```typescript +// contexts/rrss/users/application/registrar/UserRegistrar.ts +import { EventBus } from "../../../../shared/domain/event/EventBus"; +import { User } from "../../domain/User"; +import { UserRepository } from "../../domain/UserRepository"; + +export class UserRegistrar { + constructor( + private readonly repository: UserRepository, + private readonly eventBus: EventBus, + ) {} + + async registrar(id: string, name: string, email: string, profilePicture: string): Promise { + const user = User.create(id, name, email, profilePicture); // records event internally + await this.repository.save(user); + await this.eventBus.publish(user.pullDomainEvents()); // drains and dispatches + } +} +``` + +**Practical heuristic:** The use case never constructs domain events directly — it delegates all business decisions (including which events to raise) to the aggregate's named constructor and mutation methods. The use case is the orchestrator: create, save, publish. Nothing more. diff --git a/skills/ddd-best-practices/references/repositories.md b/skills/ddd-best-practices/references/repositories.md new file mode 100644 index 0000000..35c0c42 --- /dev/null +++ b/skills/ddd-best-practices/references/repositories.md @@ -0,0 +1,171 @@ +# Repositories in DDD + +Source: principles and counterexamples reviewed from [CodelyTV/repository_pattern-course](https://github.com/CodelyTV/repository_pattern-course), corrected and generalized for production use. + +Use this reference to design or review repository contracts, distinguish repositories from DAOs and gateways, and choose the right persistence and query boundaries. + +## Decision Summary + +- Give a repository only to an Aggregate Root that needs global access. +- Define the contract in the core using domain types; implement it in infrastructure. +- Model a repository as a collection of complete Aggregates, not as a table-shaped CRUD service. +- Use a dedicated query service or read model for reporting, projections, joins, and partial records. +- Use a gateway for external capabilities such as email, payments, LLMs, or remote APIs. +- Keep transaction control outside individual repository methods and propagate one real transaction context through the whole use case. +- Test application orchestration with a double and test each production adapter against real infrastructure. + +Do not introduce a repository automatically for simple CRUD. Transaction Script, Active Record, or a small Data Mapper can be clearer when there is no meaningful Aggregate behavior to protect. + +## Repository Semantics + +A DDD repository represents the conceptual collection of all instances of one Aggregate Root. It controls which domain objects are globally accessible and hides storage, mapping, caching, and query technology. + +```typescript +export interface UserRepository { + save(user: User): Promise; + search(id: UserId): Promise; +} +``` + +The contract should expose domain capabilities and domain types. It should not expose ORM entities, query builders, database rows, SQL fragments, transaction handles, or generic maps of fields to update. + +One repository per Aggregate Root is a default design rule. Child entities are loaded and changed through the root so callers cannot bypass its invariants. Multiple tables may still store one Aggregate; table count does not determine repository count. + +## Contract Ownership and Dependency Direction + +The application or domain core owns the port because it defines what persistence capability the model needs. The infrastructure adapter depends inward on that contract. + +An Aggregate repository may legitimately be named `UserRepository`: its collection semantics are part of the domain model. Intention-oriented names such as `ForPersistingUsers` are another hexagonal convention, not a universal requirement. Prefer the name that makes the contract's role clearest and use it consistently. + +Interfaces are not mandatory. In a structurally typed or functional design, a narrow function can be the port: + +```typescript +type SaveUser = (user: User) => Promise; +``` + +Use a repository object when its operations form one cohesive collection abstraction. Do not create a broad interface solely to make mocking possible. + +## Repository, DAO, Query Service, and Gateway + +| Abstraction | Purpose | Typical shape | +|---|---|---| +| Repository | Load and persist Aggregate Roots in domain terms | `save(order)`, `search(orderId)` | +| DAO | Expose persistence-oriented access operations | `insert(row)`, `updateColumns(id, data)` | +| Data Mapper | Translate between stored representation and domain objects | `toAggregate(row)`, `toRow(order)` | +| Query service/read model | Return consumer-specific projections efficiently | `latestActiveUsers(): UserReport[]` | +| Gateway | Translate access to an external system or resource | `send(message)`, `charge(payment)`, `generate(prompt)` | + +A repository adapter may compose several DAOs or mappers. That persistence detail must not leak into application services. + +Do not return a partially hydrated Aggregate to optimize a query. Partial state cannot safely enforce invariants. Return a projection DTO from a query port instead. + +## Designing the Contract + +Prefer the smallest stable contract needed by current use cases. Avoid generic base repositories with universal CRUD methods unless every Aggregate genuinely shares those semantics. + +Use explicit methods when the query has stable domain meaning: + +```typescript +interface ShipmentRepository { + save(shipment: Shipment): Promise; + search(id: ShipmentId): Promise; + pendingForRoute(route: RouteId): Promise; +} +``` + +Use Criteria or Specification when filters, ordering, and pagination combine dynamically. Generic technical Criteria normally belongs to an application query/read-model port; put it in an Aggregate repository contract only when selectors use genuine domain vocabulary. Keep field/operator mappings in the adapter and bind all values as query parameters. Use a dedicated read model when the result crosses Aggregates, computes reports, or has a consumer-specific shape. + +Make hidden query policy explicit. Page size, ordering, similarity semantics, and cursor behavior belong in named parameters/value objects or result types, not as undocumented adapter constants. + +## Absence and Errors + +Keep repository lookup absence explicit: + +- `search(id): Aggregate | null` (or `Option`) lets the caller decide what absence means. +- An application/domain finder can translate absence into a typed error when the use case requires existence. +- Use `Result`/`Either` at a boundary when callers must recover differently from known operational failures. + +The repository should normally report persistence facts, not decide business policy. For example, a unique database collision can be translated to a stable application/domain conflict, while whether duplicate registration is permitted remains a business decision. + +Do not catch every infrastructure failure and return `null`; that makes an outage indistinguishable from absence. + +## Mapping and Reconstitution + +Separate new creation from stored-state reconstitution: + +- `create(...)` establishes a new valid identity and may record creation events. +- `fromPrimitives(...)`, `rehydrate(...)`, or a mapper restores state and records no new events. +- `toPrimitives()` is optional; a dedicated mapper is preferable when serialization would pollute the model. + +Validate the boundary between untrusted stored data and the domain. Type assertions do not validate nullable columns, malformed JSON, stale enum values, or driver-specific date/number representations. Keep schema constraints and mapper assumptions aligned. + +Persist the Aggregate as one consistency unit. Whole-Aggregate upserts without an expected version can silently overwrite concurrent decisions; use optimistic version checks when lost updates matter. + +## Transactions and Concurrency + +Repository methods should not independently begin and commit transactions. The use case or a transaction decorator owns the unit of work, and every participating repository must use the same transaction-scoped connection. + +Opening a transaction at the entry point is not sufficient if repositories continue using a pooled/global connection. Prove propagation with rollback integration tests. + +Do not implement business numbering as `MAX(number) + 1`. Use a database sequence, locked counter, serializable allocation with retry, or another atomic allocator. Back global uniqueness with a database constraint and translate collisions deliberately. + +For events that require reliable delivery, persist state and Outbox messages in the same transaction. A sequential `save -> publish` flow has a dual-write failure window. + +## Caching and Pagination + +A cache decorator must preserve repository semantics: + +- key Value Objects by canonical value, not object identity; +- define TTL, invalidation, capacity, and multi-process consistency; +- avoid exposing shared mutable Aggregate instances; +- test misses, stale entries, equivalent IDs, and stampede behavior relevant to the system. + +Cursor pagination requires a stable total order. If timestamps can tie, use a compound order and cursor such as `(publishedAt, id)`. Return enough metadata for the caller to continue safely, and test ties, deletion of cursor records, and concurrent inserts. + +## Testing Strategy + +Split tests by responsibility: + +| Test | Proves | +|---|---| +| Application unit | load, orchestration, save intent, and error choice | +| Shared repository contract | behavior common to every implementation | +| Adapter integration | queries, mapping, reconstitution, constraints, transactions | +| Concurrency integration | stale-write rejection, uniqueness, and atomic allocation | +| Outbox integration | Aggregate state and messages commit or roll back together | + +Arrange stubs before Act and assert recorded calls afterward. Never hide assertions inside `save()` or `publish()` on a self-asserting double: if the System Under Test omits the call, the assertion never executes. Reset or recreate doubles per test. + +An in-memory fake is useful for application tests, but it is not proof that SQL, constraints, isolation, mapping, or transaction propagation work. A fake should compare Value Object identities by value and should not accidentally grant semantics the real adapter lacks. + +## Refactoring a Legacy System + +Introduce the boundary incrementally: + +1. Characterize visible behavior and database side effects. +2. Extract the narrow capability the current use case needs. +3. Move SQL and hydration into an adapter without changing behavior. +4. Change the application service to depend on the port. +5. Add focused application tests with a double. +6. Retain adapter integration tests against the real database. +7. Only then improve naming, mapping, errors, or query design in separate steps. + +Use parameterized queries throughout the migration. A repository abstraction does not make interpolated SQL safe. + +## Review Checklist + +- Does the repository belong to an Aggregate Root rather than a table or child Entity? +- Does the contract use domain types and current use-case language? +- Are reporting and partial projections separated from write repositories? +- Are remote capabilities named as gateways rather than repositories? +- Is absence distinguishable from infrastructure failure? +- Does reconstitution avoid creation events and validate stored data? +- Do writes protect against lost updates where required? +- Does one real transaction context reach every participating adapter? +- Are query values bound and fields/operators allow-listed? +- Is pagination deterministic under ties and concurrent changes? +- Are adapter behavior, constraints, rollback, and concurrency tested on real infrastructure? + +## Course Caveats + +Use the CodelyTV course for its progression and design discussions, not as production-ready source code. Reviewed snapshots include interpolated SQL, incomplete DAO examples, an unused transaction connection, race-prone invoice allocation, identity-keyed caching, unstable cursor pagination, unwired gateway stubs, and mocks that can pass without observing the intended call. diff --git a/skills/ddd-best-practices/references/strategic-design.md b/skills/ddd-best-practices/references/strategic-design.md new file mode 100644 index 0000000..9561149 --- /dev/null +++ b/skills/ddd-best-practices/references/strategic-design.md @@ -0,0 +1,190 @@ +# Strategic Design + +Strategic DDD decisions define boundaries, relationships, and investment priorities across the domain. They are made before writing tactical code and shape the entire architecture. + +--- + +## Ubiquitous Language + +**Definition:** A shared, rigorous vocabulary co-created by developers and domain experts that is used consistently in conversation, code, diagrams, and documentation within a Bounded Context. + +**Why it matters:** Without a shared language, developers translate between their mental model and the domain experts' language constantly — and every translation is a place where understanding is lost. When the model-based language is spoken pervasively, the model becomes a living artifact rather than a design document. A change in the Ubiquitous Language is a change in the model: the two must stay in sync. + +**How to identify / apply:** +- Run collaborative modeling sessions (e.g., Event Storming) where domain experts and developers name things together out loud. +- Reject vague terms like "data," "record," or "process" — push until you get precise domain words (e.g., `BacklogItem`, `Sprint`, `Volunteer`). +- Use the exact same terms in class names, method names, variable names, test names, and spoken conversation — no synonyms. +- When a term feels awkward or is used inconsistently, treat it as a modeling signal: the model is incomplete or wrong. +- Domain experts should object to terms that fail to convey domain understanding; developers should flag ambiguity or inconsistency. +- Maintain a glossary in the project repository, but keep it lightweight — the code is the primary artifact. + +**Common mistakes:** +- Developers invent technical names that domain experts never use ("UserRecord", "DataProcessor"), creating a silent translation layer. +- Using the same word with different meanings in different parts of the codebase without realizing it. +- Letting the language drift — the code uses old terms after the team agrees on new ones. +- Treating the Ubiquitous Language as documentation rather than as the actual operating vocabulary of daily work. + +**Practical heuristic:** If a domain expert reads your class and method names and finds them foreign or imprecise, the Ubiquitous Language is broken — fix the names before writing more code. + +--- + +## Bounded Context + +**Definition:** A semantic boundary within which a specific domain model applies, every term has a precise meaning, and the model is consistently implemented as code. + +**Why it matters:** In any large system, the same word means different things in different parts of the business ("Account" in billing is not the same as "Account" in identity). Forcing a single unified model across the entire organization produces a bloated, ambiguous mess. A Bounded Context enforces that one team, one model, one language applies within its boundary — model integrity is maintained by the boundary itself. + +**How to identify / apply:** +- Look for places where the same term changes meaning: each distinct meaning signals a potential context boundary. +- Align Bounded Contexts with team ownership: one team should own one context; cross-team models become ambiguous quickly. +- Start conceptually (problem space) — what is this context responsible for? — then make it concrete in code (solution space). +- Each Bounded Context has its own codebase, its own schema, and deploys independently where possible. +- Name each context explicitly and add it to the Ubiquitous Language of the larger system. +- Example: a Scrum tool might have a `Project Management` context (BacklogItem, Sprint, Task) and a separate `Collaboration` context (Discussion, Forum, Post) — "Discussion" belongs in Collaboration, not in Project Management. + +**Common mistakes:** +- Creating one giant shared model that tries to serve all contexts — it satisfies none of them well. +- Confusing a Bounded Context with a microservice — they are related but not the same; a context is a logical boundary, a service is a deployment unit. +- Letting two teams modify the same context, causing language and model drift. +- Failing to name the context explicitly, leaving its purpose ambiguous. + +**Practical heuristic:** A Bounded Context is the right size when one small team can hold its entire model in their heads and speak its language fluently without a translation guide. + +--- + +## Subdomain Types: Core, Supporting, and Generic + +**Definition:** A subdomain is a distinct area of the business domain; it is classified as Core (competitive differentiator), Supporting (necessary but not differentiating), or Generic (commodity functionality used everywhere). + +**Why it matters:** Not all parts of the domain deserve the same investment. Applying your best engineers and deepest modeling effort to generic infrastructure is waste; failing to invest heavily in your Core Domain cedes competitive advantage. Subdomain classification is how you decide where to spend money, time, and talent. + +**How to identify / apply:** + +*Core Domain:* +- The part of the business that provides unique competitive advantage — what the organization does better than anyone else. +- If a competitor had this capability, it would significantly erode your market position. +- Invest here: best engineers, richest models, most rigorous testing, highest code quality. +- Examples: a recommendation engine at a streaming company, a pricing algorithm at an insurance firm, a routing optimizer at a logistics company. + +*Supporting Subdomain:* +- Necessary for the Core Domain to function, but does not differentiate the business on its own. +- Custom development is still needed because off-the-shelf solutions do not fit, but it does not need your best engineers. +- Examples: HR management for a software company, an internal notification system, a custom reporting module. + +*Generic Subdomain:* +- Standard functionality that every business needs and that is solved well by existing products. +- Do not build it — buy or adopt an existing solution (identity providers, payment processors, email delivery, calendar services). +- Examples: authentication/authorization, PDF generation, accounting ledgers. + +**Common mistakes:** +- Treating everything as Core Domain and over-investing everywhere equally. +- Building a Generic Subdomain from scratch because "we want control" — this is almost always waste. +- Misclassifying a Supporting Subdomain as Core because domain experts are enthusiastic about it. +- Not revisiting classifications — what is Generic today may become Core as the market evolves, or vice versa. + +**Practical heuristic:** Ask "If we bought this capability off-the-shelf, would we lose competitive advantage?" Yes → Core. No, but we still need custom code → Supporting. No, and a product already solves it well → Generic. + +--- + +## Relationship Between Subdomains and Bounded Contexts + +**Definition:** A Subdomain is a problem-space concept (a slice of the business domain); a Bounded Context is a solution-space concept (a boundary around a model in code). In an ideal design they align one-to-one, but in practice they often do not. + +**Why it matters:** Confusing the two leads to architectural mistakes: you might force multiple subdomains into one context (creating a muddled model) or split a single subdomain across many contexts (making integration painful). Understanding the relationship helps you reason about design trade-offs clearly. + +**How to identify / apply:** +- Start by identifying subdomains in the problem space through domain exploration with stakeholders. +- Then design Bounded Contexts in the solution space, aiming for one context per subdomain as the default. +- When legacy systems exist, one Bounded Context may contain multiple subdomains — recognize this as technical debt and work to separate them. +- When a team is small, one team may own multiple contexts — that is acceptable as long as each context's model stays distinct. +- Use a Context Map to make the relationships between all contexts explicit and visible. + +**Common mistakes:** +- Designing Bounded Contexts before understanding the subdomains — you get boundaries in the wrong places. +- Assuming one-to-one alignment always exists — legacy systems and organizational realities often prevent it. +- Ignoring the subdomain classification when sizing the Bounded Context — Generic Subdomains should get minimal custom modeling. + +**Practical heuristic:** Draw the subdomain map first (problem space), draw the context map second (solution space), then compare them — gaps and misalignments are architectural risks that need a decision, not an assumption. + +--- + +## Context Mapping + +**Definition:** A context map is an explicit document (or diagram) that identifies all Bounded Contexts in a system and describes the relationships and integration patterns between them. + +**Why it matters:** Most systems have multiple Bounded Contexts that must exchange data. Without a map, integration patterns are implicit and ad-hoc, leading to tight coupling, model pollution, and unclear team responsibilities. The map makes dependencies a first-class architectural concern. + +**How to identify / apply:** +- Identify every context boundary in the system and give each a name. +- For each pair of contexts that exchange data, choose an integration pattern: + - **Partnership:** two teams coordinate closely and evolve their models together. + - **Shared Kernel:** two contexts share a small, explicitly agreed-upon subset of the model — changes require joint approval. + - **Customer/Supplier (Upstream/Downstream):** the upstream context produces; the downstream context consumes. The upstream can influence but does not own the downstream's needs. + - **Conformist:** the downstream simply conforms to the upstream's model with no negotiation (common with third-party systems). + - **Anti-Corruption Layer (ACL):** the downstream context builds a translation layer to protect its model from the upstream's concepts bleeding in. + - **Open Host Service:** the upstream publishes a stable, versioned protocol for many consumers. + - **Published Language:** a shared, documented interchange format (e.g., JSON schema, Protobuf) agreed upon by multiple contexts. +- Draw the map and review it with all team leads — disagreements reveal real architectural tensions. + +**Common mistakes:** +- Not drawing the map at all and letting integrations grow organically. +- Letting a dominant upstream context's model bleed into downstream contexts without an ACL, polluting their Ubiquitous Language. +- Using Shared Kernel too broadly — it creates tight coupling between teams. +- Forgetting to update the Context Map as the system evolves. + +**Practical heuristic:** Every integration between two Bounded Contexts needs a named pattern on the Context Map — if you cannot name it, the integration is unplanned and therefore a risk. + +--- + +## Core Domain Distillation + +**Definition:** Distillation is the process of identifying, isolating, and emphasizing the Core Domain so that the most valuable parts of the system receive the most investment and remain clearly separated from supporting and generic concerns. + +**Why it matters:** In any large system, the Core Domain tends to become buried under layers of infrastructure, generic utilities, and supporting logic. Distillation keeps it visible, protected, and well-funded. It is the strategic answer to the question: "What is this software for, and what must it do extraordinarily well?" + +**How to identify / apply:** +- Write a Core Domain statement — one paragraph that says exactly what the Core Domain does and why it matters competitively. If you cannot write it, you have not found it yet. +- Create a Core Domain map or highlight document that marks which models, aggregates, and services belong to the Core and which do not. +- Move generic concerns (logging, authentication, email) out of Core Domain code into their own modules or external services. +- Move Supporting Subdomain logic into separate contexts rather than co-locating it with Core Domain models. +- Protect Core Domain vocabulary: the Ubiquitous Language of the Core must not be contaminated by terms from generic or supporting subdomains. +- Assign your most skilled engineers to Core Domain work. Delegate Generic Subdomain work to less senior engineers or third-party products. +- For Generic Subdomains: default to buying (SaaS, open source, vendor libraries). Build only when no adequate solution exists or when the subdomain is on a path to becoming Core. + +**Common mistakes:** +- Building all subdomains to the same quality bar — over-engineering Generic Subdomains and under-engineering the Core. +- Allowing the Core Domain to accumulate generic utilities until it becomes unclear what is Core. +- Buying off-the-shelf for a subdomain that is actually Core (ceding competitive advantage to a vendor). +- Failing to maintain the Core Domain statement — as the market changes, what is Core changes. + +**Practical heuristic:** If you stripped out everything except the Core Domain code and showed it to a domain expert, they should recognize immediately what the software is for and what makes it valuable — if not, the Core has been obscured. + +--- + +## Large-Scale Structure + +**Definition:** A large-scale structure is a set of high-level rules and patterns (such as responsibility layers or a knowledge-level model) that organizes the entire system and gives it a coherent shape across all Bounded Contexts. + +**Why it matters:** As systems grow, individual Bounded Contexts and their Context Maps can become difficult to reason about without an overarching structural metaphor. A large-scale structure gives every team a mental model for where things belong, reducing the cognitive overhead of navigating the full system. + +**How to identify / apply:** +- Identify whether a layering metaphor fits: e.g., Responsibility Layers (Operational, Capability, Policy, Decision Support) where each layer depends only on layers below it. +- Apply the structure loosely — it is a guide, not a rigid constraint. Contexts that do not fit neatly should still be mapped, and the mismatch surfaced for a deliberate decision. +- Evolve the structure as the system evolves; do not freeze it early. +- Use the structure to explain the system to new team members: "All policy logic lives in the Policy Layer; all operational models live in the Operational Layer." +- Only introduce a large-scale structure when the system is large enough that navigation without it becomes a real problem. + +**Common mistakes:** +- Inventing an elaborate large-scale structure before the system is complex enough to need one. +- Enforcing the structure rigidly, causing teams to fight the structure instead of modeling their domain naturally. +- Conflating large-scale structure with microservice topology — they are different concerns. + +**Practical heuristic:** A large-scale structure is worth defining when a new team member asks "where does X belong?" and the answer requires more than a five-minute conversation — at that point, make the structure explicit. + +--- + +## Related Skills + +- `design-patterns-best-practices` — tactical patterns for implementing models within a Bounded Context (Aggregate, Repository, Domain Event). +- `oop-best-practices` — object boundaries, value objects, and cohesion within a single context's model. +- `refactoring-best-practices` — incremental extraction of Core Domain code from legacy monoliths. diff --git a/skills/ddd-best-practices/references/tactical-patterns.md b/skills/ddd-best-practices/references/tactical-patterns.md new file mode 100644 index 0000000..56a72a9 --- /dev/null +++ b/skills/ddd-best-practices/references/tactical-patterns.md @@ -0,0 +1,405 @@ +# Tactical DDD Patterns + +Building blocks for modeling a domain. These are the vocabulary you use inside a Bounded Context to express domain logic in code. + +--- + +## Entity + +**Intent:** Represent a domain object whose identity persists through time and across representations, independent of its attributes. + +**How it works:** An Entity carries a unique identifier that never changes, even as the object's attributes change over its life cycle. Two Entities are the same thing if they share the same identity, regardless of attribute values. The class definition, behavior, and associations are organized around who the object *is*, not what it currently *looks like*. Entities fulfill most of their responsibilities by coordinating the objects they own. + +**When to use:** +- The thing must be tracked across system boundaries or over time (e.g., Customer, Order, Bank Transaction) +- Two instances with identical attributes are still conceptually distinct (two deposits of the same amount to the same account on the same day are different transactions) +- Lifecycle continuity matters to the business (an archived customer is still the same customer) + +**When NOT to use:** +- The concept is defined purely by its descriptive attributes and carries no meaningful lifecycle (use Value Object instead) +- Identity exists only in-memory (a technical pointer is not a domain identity) +- The object is transient and discarded after a single operation + +**Key trade-off:** Tracking identity adds analytical work and performance cost (unique key generation, distributed identity reconciliation). Every Entity requires a designed means of distinguishing it from all others — that design decision demands domain understanding. + +**Related patterns:** Value Object (complementary — strip attributes from Entities into VOs), Aggregate (Entities are clustered into Aggregates with one root), Repository (provides lifecycle management for persistent Entities) + +**Practical heuristic:** Strip the Entity's definition down to the minimum attributes that identify or match it. Move everything else into associated Value Objects or other Entities. + +**Evans quote or key insight:** "Some objects are not defined primarily by their attributes. They represent a thread of identity that runs through time and often across distinct representations." The model must define what it *means* to be the same thing. + +--- + +## Value Object + +**Intent:** Represent a descriptive aspect of the domain that has no conceptual identity — defined entirely by its attributes. + +**How it works:** A Value Object captures *what* something is, not *which* one it is. Two instances with the same defining values are interchangeable. Make observation deeply immutable by default so values can be copied or shared safely. Define semantic equality over every defining component and matching hashing where the language requires it. Keep only intrinsic, context-independent rules inside the value. + +**When to use:** +- The concept is defined by its attributes: a monetary amount, a date range, an address, a color +- Interchangeability is correct — you don't care *which* instance you have, only *what* it represents +- The object is used as an attribute of an Entity or passed as a parameter +- You want sharing or copying to be safe and cheap + +**When NOT to use:** +- Two instances with the same attributes need to be distinguished (they are Entities) +- The object needs to be updated in place by multiple holders (use a mutable Entity instead, but reconsider the design) +- The object needs its own lifecycle in the Repository + +**Key trade-off:** Immutability enables safe sharing and stable equality but means replacement rather than in-place mutation. Keep any measured mutable optimization as an exclusively owned implementation detail rather than a mutable Value Object contract. + +**Related patterns:** Entity (the contrast — Entities need identity; Values do not), Flyweight (an implementation optimization for shared immutable Values), Specification (often implemented as a Value Object). Use `oop-best-practices` for construction, equality, hashing, deep immutability, optionality, and persistence guidance. + +**Practical heuristic:** If you can replace one instance with another that has the same attributes and no behavior changes, it is a Value Object. Make it immutable by default. + +**Evans quote or key insight:** "An object that represents a descriptive aspect of the domain with no conceptual identity is called a VALUE OBJECT. VALUE OBJECTS are instantiated to represent elements of the design that we care about only for *what* they are, not *who* or *which* they are." The same address concept can be an Entity in one domain (postal service) and a Value Object in another (mail-order company) — the domain decides. + +--- + +## Service (Domain Service) + +**Intent:** Express a domain operation that is not a natural responsibility of any Entity or Value Object. + +**How it works:** When a significant process or transformation in the domain cuts across multiple objects or simply does not conceptually belong to one of them, forcing it into an object distorts the design. A Domain Service is a stateless operation declared in terms of the domain model. Its interface is defined using domain model elements and its name comes from the Ubiquitous Language. Because it is stateless, any client can use any instance without concern for individual history. + +A good Domain Service has three characteristics: +1. The operation relates to a domain concept that is not a natural part of an Entity or Value Object. +2. The interface is defined in terms of other domain model elements. +3. The operation is stateless. + +**When to use:** +- A significant domain operation spans multiple Aggregates or Entities (e.g., a funds transfer that debits one Account and credits another) +- Forcing the logic into one object would give it unrelated dependencies or distort its meaning +- The operation represents a domain-meaningful *activity* (a verb, not a noun) + +**When NOT to use:** +- The operation clearly belongs to one object — put it there; Services should not strip Entities of behavior +- The concept is purely technical (email sending, file I/O) — that belongs in the Infrastructure layer +- You are using a Service just to avoid thinking about which object should own the behavior (this produces anemic domain models) + +**Key trade-off:** Domain Services prevent Entities from becoming bloated with unrelated logic, but overuse produces procedural, anemic models where all behavior lives in Services and objects are mere data containers. + +**Related patterns:** Entity and Value Object (Services coordinate them), Application Service (a different layer — orchestrates use cases but contains no domain logic), Repository (Services often need to locate objects via Repositories) + +**Practical heuristic:** Name the Service after the activity it performs (a verb phrase from the Ubiquitous Language). If the name ends in "Manager" or "Handler" with no domain meaning, look harder for the right object. + +**Evans quote or key insight:** "Some concepts from the domain aren't natural to model as objects. Forcing the required domain functionality to be the responsibility of an ENTITY or VALUE either distorts the definition of a model-based object or adds meaningless artificial objects." + +--- + +## Aggregate + +**Intent:** Define a cluster of associated objects treated as a unit for the purposes of data change, with one object designated as the root controlling all access. + +**How it works:** Complex object graphs create problems: invariants that span multiple objects are hard to enforce, concurrent access creates contention, and lifecycle ownership becomes unclear. An Aggregate draws a boundary around a set of Entities and Value Objects that must change consistently. One Entity — the Aggregate Root — controls all mutation. External code interacts through the root and must not retain mutable access to internals. The root enforces the cluster's invariants after every accepted command. Persistence and deletion follow the aggregate's lifecycle policy; they do not have to mirror an ORM cascade. + +Rules: +- Only Aggregate Roots can be retrieved directly from the database (Repositories operate on Aggregate Roots) +- Internal members may be observed through immutable values or snapshots, but mutable references must not escape +- Objects within the boundary reference other Aggregate Roots by identity, not by a live mutable object reference + +**When to use:** +- A set of objects has invariants that must hold across all of them (e.g., a Purchase Order's total must not exceed its approved limit) +- Objects have a natural ownership relationship (PO owns its line items; line items have no meaning outside that PO) +- You need to coordinate locking and transactional consistency around a coherent cluster + +**When NOT to use:** +- The cluster would be so large it causes unacceptable contention — reconsider the boundary; invariants enforced at every moment may be too strict +- Objects are shared across many independently changing Aggregates (they may be their own Aggregate Roots) +- You are grouping objects for convenience rather than because they share invariants + +**Key trade-off:** Tight Aggregate boundaries enforce consistency but create contention under concurrent load. Loose boundaries allow concurrency but risk invariant violations. The Purchase Order example from Evans shows the tension: locking the whole PO enforces the limit invariant but blocks concurrent edits; locking only line items allows invariant violations. + +**Related patterns:** Entity (Aggregates are clusters of Entities and Value Objects), Aggregate Root (the controlling Entity), Repository (operates on Aggregate Roots), Factory (creates entire Aggregates in a consistent state). Read `aggregates.md` for the complete boundary-discovery, transaction, concurrency, and lifecycle guidance. + +**Practical heuristic:** Ask: "What must be true of this cluster at all times?" That scope defines the Aggregate. If the invariant only needs to hold eventually (not at every moment), the boundary may be too tight. + +**Evans quote or key insight:** "Cluster the ENTITIES and VALUE OBJECTS into AGGREGATES and define boundaries around each. Choose one ENTITY to be the root of each AGGREGATE, and control all access to the objects inside the boundary through the root." Aggregates mark the scope within which invariants must be maintained at every stage of the life cycle. + +--- + +## Aggregate Root + +**Intent:** Designate a single Entity within an Aggregate as the sole entry point for all external interaction, making it responsible for enforcing the cluster's invariants. + +**How it works:** The Aggregate Root is an Entity with a global identity (accessible by Repositories and held by external objects). All other members of the Aggregate have only local identity — they may be uniquely identified within the boundary but not globally. The root enforces rules that apply to the whole cluster. It controls whether internal members can be accessed by external objects, and any access it grants is transient. Because the root controls all mutations, it can never be blindsided by changes to the internals. + +**When to use:** +- When defining an Aggregate — every Aggregate must have exactly one root +- The root should be the object whose identity is meaningful beyond the cluster (e.g., PurchaseOrder, not LineItem) +- The root should naturally own the invariants of the cluster + +**When NOT to use:** +- Do not designate multiple roots for a single Aggregate — this breaks the invariant-enforcement contract +- Do not use an internal member as the root just because it is convenient; the conceptual owner should be the root + +**Key trade-off:** The root becomes a bottleneck for all access to the cluster. This is intentional — it is the price of coherent invariant enforcement. If the bottleneck is unacceptable, the Aggregate boundary is probably wrong. + +**Related patterns:** Aggregate (the pattern the root anchors), Repository (retrieves and stores Aggregate Roots), Factory (creates valid Aggregate Roots) + +**Practical heuristic:** The Aggregate Root should be the object you would naturally ask "does X exist in the system?" about — the concept whose lifecycle the domain cares about tracking. + +--- + +## Repository + +**Intent:** Provide the illusion of an in-memory collection of all objects of a given type, encapsulating the actual storage and retrieval mechanics. + +**How it works:** A Repository represents all objects of a certain type as a conceptual set. Clients add and remove objects; the Repository handles the underlying insertion, deletion, and querying against whatever persistence technology is in use. Clients request objects by submitting criteria expressed in domain terms (not SQL or database keys). The Repository translates these into whatever queries the storage layer requires and reconstitutes the domain objects from the result. Repositories are only provided for Aggregate Roots that need direct global access — transient Value Objects and internal Aggregate members are found by traversal, not by Repository queries. + +The Repository gives four advantages: +1. A simple model for obtaining persistent objects +2. Decoupling of domain and application code from persistence technology +3. Explicit communication of design decisions about which objects are globally accessible +4. Easy substitution of an in-memory implementation for testing + +**When to use:** +- You need global access to an Aggregate Root (Customer, Order, Product) +- The domain layer must not directly deal with database queries, SQL, or ORM mechanics +- You want to be able to substitute an in-memory fake for testing + +**When NOT to use:** +- For objects that are always accessed by traversal from an Aggregate Root (they don't need their own Repository) +- As a general-purpose data access layer — Repositories are domain constructs, not DAO replacements +- For Value Objects that can simply be reconstructed by value + +**Key trade-off:** The Repository interface belongs to the domain layer; the implementation belongs to infrastructure. This separation is powerful but means developers must understand what happens under the hood — "all objects" query bugs (loading an entire database into memory) are the canonical failure mode. + +**Related patterns:** Aggregate Root (Repositories operate only on roots), Factory (Repositories use Factories to reconstitute stored objects), Specification (used to express flexible query criteria) + +**Practical heuristic:** Define the Repository interface in terms of the domain model — `findByTrackingId(TrackingId)`, not `findById(long)`. Leave transaction control to the client; the Repository should not commit. + +**Evans quote or key insight:** "A REPOSITORY represents all objects of a certain type as a conceptual set (usually emulated). It acts like a collection, except with more elaborate querying capability." The key insight is the distinction from Factory: a Factory makes *new* objects; a Repository finds *existing* ones — reconstitution is not creation. + +### Repository: Concrete Implementation Strategies + +For the complete decision guide, including Repository vs DAO/query service/gateway, transaction propagation, concurrency, caching, pagination, and testing, read `repositories.md`. + +**Aggregate-only repositories:** Only Aggregate Roots get their own Repository. Internal entities and value objects within an Aggregate are loaded by traversal from the root, never by a separate Repository query. This enforces the Aggregate boundary and prevents bypassing invariants. + +**The interface lives in the domain, the implementation in infrastructure:** +```typescript +// domain/CourseRepository.ts — pure domain interface, no imports from infra +export interface CourseRepository { + save(course: Course): Promise; + search(id: CourseId): Promise; + searchAll(): Promise; +} + +// infrastructure/PostgresCourseRepository.ts — infra implementation +export class PostgresCourseRepository + extends PostgresRepository + implements CourseRepository +{ + async save(course: Course): Promise { + const p = course.toPrimitives(); + await this.execute` + INSERT INTO mooc.courses (id, name, summary, categories, published_at) + VALUES (${p.id}, ${p.name}, ${p.summary}, ${p.categories}, ${p.publishedAt}) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + summary = EXCLUDED.summary, + categories = EXCLUDED.categories, + published_at = EXCLUDED.published_at; + `; + } + + async search(id: CourseId): Promise { + return this.searchOne` + SELECT id, name, summary, categories, published_at + FROM mooc.courses WHERE id = ${id.value}; + `; + } + + protected toAggregate(row: DatabaseCourseRow): Course { + return Course.fromPrimitives({ + id: row.id, name: row.name, summary: row.summary, + categories: row.categories, + publishedAt: row.published_at.toISOString(), + }); + } +} +``` + +**`search` and required existence:** Let the repository `search` return `T | null` (or `Option`). When a use case requires existence, an application/domain finder translates absence into a typed error. This keeps persistence lookup separate from the caller's business meaning. + +```typescript +// domain/UserRepository.ts +export interface UserRepository { + save(user: User): Promise; + search(id: UserId): Promise; // caller handles null +} + +// application/UserFinder.ts — wraps repository with a domain-named error +export class UserFinder { + constructor(private readonly repository: UserRepository) {} + + async find(id: string): Promise { + const user = await this.repository.search(new UserId(id)); + if (user === null) throw new UserDoesNotExistError(id); + return user; + } +} +``` + +**Repository with Criteria (Specification-based querying):** For flexible, composable queries, the Repository accepts a `Criteria` object instead of individual filter parameters. This keeps SQL out of the domain and allows the same query logic to work across multiple storage backends. + +```typescript +// domain/CourseRepository.ts — accepts Criteria, not raw SQL params +export interface CourseRepository { + save(course: Course): Promise; + search(id: CourseId): Promise; + matching(criteria: Criteria): Promise; +} + +// infrastructure — converts Criteria to SQL +async matching(criteria: Criteria): Promise { + const { query, params } = this.criteriaConverter.convert(criteria); + const rows = await this.db.query(query, params); + return rows.map(this.toAggregate); +} +``` + +**Testing repositories with an in-memory fake:** The interface-in-domain / implementation-in-infrastructure split enables a fast in-memory fake for unit and integration tests without touching a real database. + +```typescript +// tests/InMemoryUserRepository.ts +export class InMemoryUserRepository implements UserRepository { + private users: Map = new Map(); + + async save(user: User): Promise { + this.users.set(user.id.value, user); + } + + async search(id: UserId): Promise { + return this.users.get(id.value) ?? null; + } +} + +// In tests — no database, fully deterministic +const repository = new InMemoryUserRepository(); +const finder = new UserFinder(repository); +``` + +**Practical heuristic:** If your application layer imports anything from an ORM or database driver, the Repository abstraction has leaked. The application layer should only know the domain interface. Query values must still be parameterized in the adapter; introducing a Repository does not make string-built SQL safe. + +### Outbox and Inbox Patterns with Domain Events + +When a selected fact must cross a process or Bounded Context boundary, translate it to a versioned Integration Event and append that message to an Outbox in the same transaction and connection as Aggregate state. A relay claims rows, preserves message identity across retries, publishes them, and completes only successfully published claims. Malformed or unsupported messages go to quarantine/dead letter; they are never silently filtered and deleted. + +At-least-once delivery makes duplicates normal. For local database effects, an Inbox record and the effect must commit in the same transaction. Remote email/payment effects require provider idempotency keys or a durable intent plus reconciliation because an Inbox cannot make a network call atomic with the database. + +This tactical reference states the pattern relationship only. Use the dedicated infrastructure guidance for claiming, fan-out, retries, ordering, dead letters, replay, brokers, and CDC. + +--- + +## Factory + +**Intent:** Encapsulate the complex assembly of a domain object or entire Aggregate, ensuring the created object is in a valid, consistent state without burdening clients with construction knowledge. + +**How it works:** Creating complex objects — especially entire Aggregates — requires knowledge of internal structure and invariants. If the client is responsible for construction, it becomes coupled to implementation details, invariant enforcement leaks out of the domain, and refactoring becomes expensive. A Factory centralizes this responsibility. Its interface reflects what the client wants, not how the object is assembled. Each creation method is atomic: it either returns a fully valid object (with all invariants satisfied) or it fails with an exception — it never returns a partially constructed object. + +Two basic requirements for a good Factory: +1. Each creation method is atomic and enforces all invariants of the created object or Aggregate. +2. The Factory is abstracted to the type desired, not the concrete class created. + +A Factory may be: a standalone Factory object/class, a Factory Method on a related Entity (e.g., `brokerageAccount.newBuyOrder(...)`), or simply a well-designed constructor for simple cases. + +**When to use:** +- Creating an Aggregate involves complex assembly, hidden internal structure, or polymorphic selection among subtypes +- A related Entity naturally "spawns" the new object and controls the rules governing what can be created +- You need to enforce invariants across the whole Aggregate at creation time + +**When NOT to use:** +- The class is simple, non-polymorphic, and all its attributes are available to the client — a plain constructor is clearer +- The Factory would obscure a simple object with no meaningful internal complexity + +**Key trade-off:** Factories add indirection and a new design element (one that does not appear in the model itself). That cost is worth paying when construction is genuinely complex. Avoid over-engineering simple creations. + +**Related patterns:** Aggregate (Factories create entire Aggregates in a valid state), Repository (uses Factories to reconstitute stored objects — reconstitution differs from creation: no new identity is assigned), Abstract Factory / Factory Method (GoF patterns applicable here) + +**Practical heuristic:** If a constructor is calling other constructors, or if the client needs to know internal types to call it correctly, extract a Factory. Keep Factory method parameters at the minimum needed to establish invariants. + +**Evans quote or key insight:** "Shift the responsibility for creating instances of complex objects and AGGREGATES to a separate object, which may itself have no responsibility in the domain model but is still part of the domain design. Provide an interface that encapsulates all complex assembly and that does not require the client to reference the concrete classes of the objects being instantiated. Create entire AGGREGATES as a piece, enforcing their invariants." + +--- + +## Module (a.k.a. Package) + +**Intent:** Group cohesive domain concepts together and define a high-level, navigable narrative of the domain model, with low coupling between modules and high cohesion within them. + +**How it works:** Modules are not just a code organization mechanism — they are a communications mechanism. They give people two views of the model: the interior detail when needed, and the inter-module relationships for a higher-level view. The key driver for module design is conceptual cohesion, not technical layering. When you place classes together in a Module, you tell the next developer to think about them together. The Module name becomes part of the Ubiquitous Language. Modules should coevolve with the model; early module structures often freeze and lag behind the model, increasing coupling and reducing clarity. + +**When to use:** +- The model is large enough that people need a higher-level organizing principle to navigate it +- A set of concepts is so closely related that discussions and design work naturally concentrate on them together +- Module names can be meaningful to domain experts + +**When NOT to use:** +- Do not let technical frameworks dictate module structure (infrastructure layering, tier separation) at the cost of conceptual cohesion +- Do not freeze module structure early and stop refactoring it — agile modules coevolve with the model + +**Key trade-off:** Refactoring Modules is far more disruptive than refactoring classes (naming changes ripple widely). Teams tend to under-refactor Modules, letting them drift from the model. The cost of not refactoring is a module structure that tells a misleading story about the domain. + +**Related patterns:** Bounded Context (the strategic-level equivalent — Modules are within a context; Bounded Contexts are between them), Ubiquitous Language (Module names should be part of it) + +**Practical heuristic:** Name modules after domain concepts, not technical roles. "customer", "shipping", "billing" — not "controllers", "services", "repositories". + +**Evans quote or key insight:** "Choose MODULES that tell the story of the system and contain a cohesive set of concepts... Give the MODULES names that become part of the UBIQUITOUS LANGUAGE. MODULES and their names should reflect insight into the domain." + +--- + +## Domain Event + +**Intent:** Model something that happened in the domain that domain experts care about, making state changes explicit, named, and usable as triggers for downstream reactions. + +**How it works:** A Domain Event is an immutable record of something that occurred in the domain — named in the past tense using Ubiquitous Language terms (e.g., `OrderPlaced`, `PaymentConfirmed`, `CargoBooked`). It captures the data that describes what happened (who, what, when, context). Its business payload has value semantics, while its envelope may carry an event ID, occurrence time, and delivery metadata for deduplication and tracing. Other parts of the same Bounded Context can react without tight coupling to the source Aggregate. At a context boundary, translate relevant facts into stable Integration Events rather than exposing the internal Domain Event schema. + +Note: Evans' 2003 book treated events implicitly (Handling Events in the cargo example); Domain Events were later formalized as a first-class pattern by the DDD community (Evans, Fowler, and others, circa 2005–2010). The pattern is now considered a core tactical building block. + +**When to use:** +- A state change in one Aggregate should trigger reactions in other Aggregates; cross-context reactions receive a translated Integration Event +- You want to decouple the source of a change from its downstream effects +- Auditing, logging, or eventual consistency between Aggregates is required +- A business expert talks about "when X happens, then Y should occur" + +**When NOT to use:** +- When a simple synchronous method call inside the same Aggregate is sufficient +- When the reaction is an internal invariant of the same Aggregate (keep it inside the boundary) +- When the added asynchrony and complexity of event handling is not justified by the coupling reduction + +**Key trade-off:** Events decouple producers from consumers and enable eventual consistency, but introduce ordering, idempotency, and delivery guarantee concerns. The richer the event bus infrastructure, the more non-domain complexity enters the system. + +**Related patterns:** Aggregate (Aggregates record events when meaningful state changes), Repository (state and Outbox messages may share a Unit of Work), Domain Service (may participate in establishing the fact), Bounded Context (translated Integration Events communicate selected facts across Contexts) + +**Practical heuristic:** Name events in the past tense with domain language: `OrderShipped`, not `OrderShipEvent`. Prefer a specific semantic fact over generic `Updated`, avoid embedding the live/full Aggregate by default, and read `domain-events.md` for the complete design guide. + +--- + +## Specification + +**Intent:** Encapsulate a business rule as a separate, named, reusable predicate object that can test whether a domain object satisfies certain criteria. + +**How it works:** Boolean test methods naturally accumulate in domain objects, and as rules grow complex they overwhelm the object's primary responsibility. The Specification pattern extracts these rules into separate Value Objects. A Specification states a constraint on the state of another object, which may or may not be present. A client creates a Specification, then asks it to evaluate a candidate object (`isSatisfiedBy(candidate)`). The same Specification can be used for three purposes without changing its conceptual definition: (1) validation — does this object satisfy the rule now? (2) selection — find all objects in a collection satisfying the rule; (3) construction / building to order — specify what a new object must look like. + +Specifications mesh naturally with Repositories: a Repository can accept a Specification as a query parameter, translating it into SQL or another query form to fetch matching objects efficiently. + +**When to use:** +- A business rule is complex, growing, or appears in multiple places and needs a named home +- You need the same rule to work for validation, querying, and generation +- The rule depends on data that does not belong in the object being evaluated +- The domain expert talks about the rule as a distinct concept (e.g., "delinquency policy", "eligibility criteria") + +**When NOT to use:** +- The rule is a simple, stable invariant that naturally lives as a method on the object — extracting it adds indirection without clarity +- The evaluation logic is purely technical with no domain meaning +- Full logic-programming-style predicate composition is needed — full implementation of combinable logic is a significant undertaking (Evans cautions against over-engineering this) + +**Key trade-off:** Specifications make rules explicit and reusable, but they add objects and indirection. The tension with Repositories is real: expressing a Specification as SQL can leak database schema into the domain layer (Evans shows several approaches to this problem, each with trade-offs). + +**Related patterns:** Value Object (Specifications are often implemented as Value Objects), Repository (Repositories accept Specifications as query criteria), Factory (can configure a Specification from contextual data), Strategy (Specification is a domain-motivated application of the same structural idea) + +**Practical heuristic:** When a boolean method on a domain object is growing, has multiple callers, or depends on data from outside the object, extract a Specification. Name it after the business concept it tests: `DelinquentInvoiceSpecification`, not `InvoiceRuleChecker`. + +**Evans quote or key insight:** "Create explicit predicate-like VALUE OBJECTS for specialized purposes. A SPECIFICATION is a predicate that determines if an object does or does not satisfy some criteria." The unifying insight is that validation, selection, and building-to-order are conceptually the same rule expressed in three different operational contexts. diff --git a/skills/ddd-best-practices/references/typescript-ddd-examples.md b/skills/ddd-best-practices/references/typescript-ddd-examples.md new file mode 100644 index 0000000..7ee165c --- /dev/null +++ b/skills/ddd-best-practices/references/typescript-ddd-examples.md @@ -0,0 +1,719 @@ +# TypeScript DDD Examples (CodelyTV Pattern) + +Practical TypeScript implementations of DDD building blocks, based on the CodelyTV `typescript-ddd-example` repository. These show the full stack of building blocks working together in a real bounded context. + +Source: https://github.com/CodelyTV/typescript-ddd-example + +--- + +## Folder Structure + +**Intent:** Organize code by Bounded Context, not by technical layer. + +**How it works:** The top-level `src/Contexts/` directory holds one folder per bounded context (e.g., `Mooc`, `Backoffice`). Each context contains sub-domains organized by aggregate. A `Shared/` context holds cross-cutting building blocks (base classes, event bus, criteria). Inside each aggregate folder, the split is always `domain/` → `application/` → `infrastructure/`. + +**Example:** +``` +src/ + Contexts/ + Mooc/ + Courses/ + domain/ + Course.ts # Aggregate Root + CourseRepository.ts # Port (interface) + CourseName.ts # Value Object + CourseDuration.ts # Value Object + CourseCreatedDomainEvent.ts # Domain Event + application/ + Create/ + CourseCreator.ts # Use Case (Application Service) + CreateCourseCommand.ts # Command DTO + CreateCourseCommandHandler.ts + infrastructure/ + persistence/ + mongo/ + MongoCourseRepository.ts # Driven Adapter + Shared/ + domain/ + AggregateRoot.ts + DomainEvent.ts + EventBus.ts + value-object/ + ValueObject.ts + StringValueObject.ts + Uuid.ts + infrastructure/ + persistence/ + mongo/ + MongoRepository.ts +tests/ + Contexts/ + Mooc/ + Courses/ + domain/CourseMother.ts # Object Mother + application/CreateCourse...test.ts +``` + +**Practical heuristic:** If you cannot tell from the folder name alone which business capability the code serves, the structure is wrong. Folder names like `controllers/` or `services/` are red flags. + +--- + +## Value Objects in TypeScript + +**Intent:** Give domain values explicit construction, immutable representation, and semantic equality without assuming one generic base can compare every representation safely. + +**How it works:** Implement equality explicitly for each semantic type. A shared base may remove repetition for scalar strings, numbers, or booleans, but `Date`, arrays, objects, and composite values need domain-aware comparison and defensive copies. Composition, branded scalars, and records are valid alternatives to inheritance. + +**Example:** +```typescript +// src/Contexts/Mooc/Courses/domain/CourseName.ts +export class CourseName { + private constructor(readonly value: string) {} + + static create(raw: string): CourseName { + const value = raw.trim(); + if (value.length === 0 || value.length > 30) { + throw new CourseNameLengthExceeded(value); + } + return new CourseName(value); + } + + equals(other: CourseName): boolean { + return this.value === other.value; + } +} + +// src/Contexts/Mooc/Shared/domain/Courses/CourseId.ts +export class CourseId { + private constructor(readonly value: string) {} + + static create(value: string): CourseId { + if (!validateUuid(value)) throw new InvalidCourseId(value); + return new CourseId(value); + } + + equals(other: CourseId): boolean { + return this.value === other.value; + } +} +``` + +**Practical heuristic:** Transport DTOs, commands, and messages normally carry primitives. Convert at a deliberate application/domain boundary, then use Value Objects in domain APIs where semantic distinction or guarantees justify them. Run `tsc --noEmit`; transpile-only tests do not prove type correctness. + +--- + +## AggregateRoot Base Class + +**Intent:** Give every aggregate root the ability to collect domain events before publishing them, and enforce serialization to primitives. + +**How it works:** `AggregateRoot` maintains a private list of domain events. The aggregate records an event via `record()` when state changes. A best-effort in-process application service can drain these events after persistence. Durable cross-process delivery requires handing them to an Outbox in the same transaction as Aggregate state; do not clear the only event copy before durable handoff. + +**Example:** +```typescript +// src/Contexts/Shared/domain/AggregateRoot.ts +export abstract class AggregateRoot { + private domainEvents: Array; + + constructor() { + this.domainEvents = []; + } + + pullDomainEvents(): Array { + const domainEvents = this.domainEvents.slice(); + this.domainEvents = []; + return domainEvents; + } + + record(event: DomainEvent): void { + this.domainEvents.push(event); + } + + abstract toPrimitives(): any; +} +``` + +**Practical heuristic:** Never publish from inside the Aggregate. The application/Unit of Work owns durable handoff. Treat direct `save -> pull -> publish` as educational in-process delivery only; use `infrastructure-design` for transactional Outbox delivery. + +--- + +## Aggregate Root (Concrete) + +**Intent:** Implement a rich aggregate root that uses the static factory method pattern to enforce valid creation and record domain events. + +**How it works:** The `Course` aggregate extends `AggregateRoot`. Construction through `new Course()` is valid but does not produce events — used for reconstitution from persistence via `fromPrimitives()`. Creation through the static `Course.create()` factory method records the `CourseCreatedDomainEvent`. All fields are typed as Value Objects, never raw primitives. + +**Example:** +```typescript +// src/Contexts/Mooc/Courses/domain/Course.ts +export class Course extends AggregateRoot { + readonly id: CourseId; + readonly name: CourseName; + readonly duration: CourseDuration; + + constructor(id: CourseId, name: CourseName, duration: CourseDuration) { + super(); + this.id = id; + this.name = name; + this.duration = duration; + } + + // Static factory: creates a new Course and records the creation event + static create(id: CourseId, name: CourseName, duration: CourseDuration): Course { + const course = new Course(id, name, duration); + course.record( + new CourseCreatedDomainEvent({ + aggregateId: course.id.value, + duration: course.duration.value, + name: course.name.value, + }) + ); + return course; + } + + // Reconstitution: used by the repository adapter — no events recorded + static fromPrimitives(plainData: { id: string; name: string; duration: string }): Course { + return new Course( + CourseId.create(plainData.id), + CourseName.create(plainData.name), + new CourseDuration(plainData.duration) + ); + } + + toPrimitives(): any { + return { + id: this.id.value, + name: this.name.value, + duration: this.duration.value, + }; + } +} +``` + +**Practical heuristic:** Keep creation and reconstitution semantically separate so loading cannot emit creation events. `create()` plus `fromPrimitives()` is one valid strategy; a dedicated mapper is another. + +--- + +## DomainEvent Base Class + +**Intent:** Give every domain event a consistent structure — event name, aggregate ID, event ID, and timestamp — while letting each concrete event define its own typed attributes. + +**How it works:** The abstract `DomainEvent` stores metadata automatically (auto-generating `eventId` as a UUID and `occurredOn` as now if not provided). Each concrete event declares a static `EVENT_NAME` constant and implements `toPrimitives()` for serialization plus a static `fromPrimitives()` factory for deserialization (used by message bus infrastructure). + +**Example:** +```typescript +// src/Contexts/Shared/domain/DomainEvent.ts +export abstract class DomainEvent { + static EVENT_NAME: string; + static fromPrimitives: (params: { + aggregateId: string; + eventId: string; + occurredOn: Date; + attributes: DomainEventAttributes; + }) => DomainEvent; + + readonly aggregateId: string; + readonly eventId: string; + private readonly occurredOnMs: number; + readonly eventName: string; + + constructor(params: { + eventName: string; + aggregateId: string; + eventId?: string; + occurredOn?: Date; + }) { + this.aggregateId = params.aggregateId; + this.eventId = params.eventId ?? Uuid.random().value; + this.occurredOnMs = (params.occurredOn ?? new Date()).getTime(); + if (!Number.isFinite(this.occurredOnMs)) throw new InvalidEventDate(); + this.eventName = params.eventName; + } + + get occurredOn(): Date { + return new Date(this.occurredOnMs); + } + + abstract toPrimitives(): DomainEventAttributes; +} + +// src/Contexts/Mooc/Courses/domain/CourseCreatedDomainEvent.ts +type CreateCourseDomainEventAttributes = { + readonly duration: string; + readonly name: string; +}; + +export class CourseCreatedDomainEvent extends DomainEvent { + static readonly EVENT_NAME = 'course.created'; + + readonly duration: string; + readonly name: string; + + constructor({ + aggregateId, + name, + duration, + eventId, + occurredOn, + }: { + aggregateId: string; + eventId?: string; + duration: string; + name: string; + occurredOn?: Date; + }) { + super({ + eventName: CourseCreatedDomainEvent.EVENT_NAME, + aggregateId, + eventId, + occurredOn, + }); + this.duration = duration; + this.name = name; + } + + toPrimitives(): CreateCourseDomainEventAttributes { + return { name: this.name, duration: this.duration }; + } + + // Used by the event bus infrastructure to deserialize incoming messages + static fromPrimitives(params: { + aggregateId: string; + attributes: CreateCourseDomainEventAttributes; + eventId: string; + occurredOn: Date; + }): DomainEvent { + return new CourseCreatedDomainEvent({ + aggregateId: params.aggregateId, + duration: params.attributes.duration, + name: params.attributes.name, + eventId: params.eventId, + occurredOn: params.occurredOn, + }); + } +} +``` + +**Practical heuristic:** Name domain events in the past tense with dot notation: `course.created`, `payment.confirmed`. The static `EVENT_NAME` is the contract between producers and consumers — treat it as an immutable API once deployed. + +--- + +## Repository Port (Interface) + +**Intent:** Declare what the domain needs from persistence in domain language, with no reference to any database technology. + +**How it works:** The repository interface lives in the `domain/` folder — it is part of the domain layer, not infrastructure. Method names use domain vocabulary. Return types are domain objects or aggregates, never raw database rows. The interface is the driven port; concrete adapters (Mongo, Postgres, in-memory) implement it in `infrastructure/`. + +**Example:** +```typescript +// src/Contexts/Mooc/Courses/domain/CourseRepository.ts +import { Course } from './Course'; + +export interface CourseRepository { + save(course: Course): Promise; + searchAll(): Promise>; +} +``` + +**Practical heuristic:** If you find yourself adding methods like `findByRawQuery()` or `executeSQL()` to a repository interface, the abstraction has broken down. Keep the interface in domain terms only. + +--- + +## EventBus Port (Interface) + +**Intent:** Declare the outbound event publishing contract so the domain layer never depends on RabbitMQ, Kafka, or any specific broker. + +**Example:** +```typescript +// src/Contexts/Shared/domain/EventBus.ts +export interface EventBus { + publish(events: Array): Promise; + addSubscribers(subscribers: DomainEventSubscribers): void; +} + +// src/Contexts/Shared/domain/DomainEventSubscriber.ts +export interface DomainEventSubscriber { + subscribedTo(): Array; + on(domainEvent: T): Promise; +} +``` + +**Practical heuristic:** For best-effort in-process reactions, inject an in-memory bus or spy. For production broker delivery, persist an Outbox atomically and let a relay publish to RabbitMQ; do not publish directly from this use case. + +--- + +## Use Case (Application Service) + +**Intent:** Orchestrate the domain — fetch, mutate via the aggregate, persist, publish events — with no business logic of its own. + +**How it works:** `CourseCreator` receives dependencies through constructor injection. The Command Handler translates the flat DTO command into typed Value Objects. The direct publication below is suitable only for best-effort in-process reactions; replace it with a Unit of Work plus Outbox when events must survive failures. + +**Example:** +```typescript +// src/Contexts/Mooc/Courses/application/Create/CourseCreator.ts +export class CourseCreator { + constructor( + private repository: CourseRepository, + private eventBus: EventBus + ) {} + + async run(params: { + id: CourseId; + name: CourseName; + duration: CourseDuration; + }): Promise { + const course = Course.create(params.id, params.name, params.duration); + await this.repository.save(course); + await this.eventBus.publish(course.pullDomainEvents()); + } +} + +// src/Contexts/Mooc/Courses/application/Create/CreateCourseCommandHandler.ts +export class CreateCourseCommandHandler + implements CommandHandler { + constructor(private courseCreator: CourseCreator) {} + + subscribedTo(): MessageConstructor { + return CreateCourseCommand; + } + + async handle(command: CreateCourseCommand): Promise { + const id = CourseId.create(command.id); + const name = CourseName.create(command.name); + const duration = new CourseDuration(command.duration); + await this.courseCreator.run({ id, name, duration }); + } +} +``` + +**Practical heuristic:** A use case may branch for orchestration, absence, authorization outcomes, retries, or policy results, but it must not own domain policy that belongs in the model. Treat it as a traffic controller, not as a rule-free syntax exercise. + +--- + +## Infrastructure: MongoRepository Base (Driven Adapter) + +**Intent:** Provide a reusable base class for all MongoDB-backed repository adapters, keeping the toPrimitives/fromPrimitives mapping in each concrete class. + +**How it works:** `MongoRepository` is generic over any `AggregateRoot`. It provides `persist()` (upsert by ID using `toPrimitives()`) and `searchByCriteria()` (for flexible querying). Concrete repository classes extend this base and implement `collectionName()`. The `fromPrimitives()` call translates raw Mongo documents back into domain aggregates. + +**Example:** +```typescript +// src/Contexts/Shared/infrastructure/persistence/mongo/MongoRepository.ts +export abstract class MongoRepository { + constructor(private _client: Promise) {} + + protected abstract collectionName(): string; + + protected async persist(id: string, aggregateRoot: T): Promise { + const collection = await this.collection(); + const document = { ...aggregateRoot.toPrimitives(), _id: id, id: undefined }; + await collection.updateOne({ _id: id }, { $set: document }, { upsert: true }); + } +} + +// Concrete adapter (in infrastructure/persistence/mongo/) +export class MongoCourseRepository + extends MongoRepository + implements CourseRepository { + protected collectionName(): string { + return 'courses'; + } + + async save(course: Course): Promise { + await this.persist(course.id.value, course); + } + + async searchAll(): Promise> { + const collection = await this.collection(); + const documents = await collection.find({}).toArray(); + return documents.map(Course.fromPrimitives); + } +} +``` + +**Practical heuristic:** The concrete adapter is the only place where `toPrimitives()` and `fromPrimitives()` are called. Everything else in the system works with typed domain objects. + +--- + +## Command / Query Contracts + +**Intent:** Give bus infrastructure type-safe command, query, response, and handler relationships without relying on empty structural marker types. + +**How it works:** Private/protected brands distinguish command and query families under TypeScript structural typing. A query carries its response type. Handlers return the constructor they subscribe to, not a message instance. + +**Example:** +```typescript +export abstract class Command { + protected readonly __commandBrand!: never; +} + +export abstract class Query { + protected readonly __responseBrand!: R; +} + +export type MessageConstructor = new (...args: any[]) => T; + +// Serialized/external commands normally carry flat primitives. +// src/Contexts/Mooc/Courses/domain/CreateCourseCommand.ts +type Params = { id: string; name: string; duration: string }; + +export class CreateCourseCommand extends Command { + id: string; + name: string; + duration: string; + + constructor({ id, name, duration }: Params) { + super(); + this.id = id; + this.name = name; + this.duration = duration; + } +} +``` + +**Practical heuristic:** Serialized commands normally carry transport primitives and are converted at the handler boundary. A trusted in-process command may carry Value Objects when that contract is deliberate and no serialization boundary is implied. + +--- + +## CommandBus / QueryBus — CQRS Bus Interfaces + +**Intent:** Decouple the controller (driver adapter) from the use case by routing through a bus. The controller does not need to know which handler exists — it only dispatches a command or queries with a query. + +**How it works:** `CommandBus` dispatches a command and returns completion. `QueryBus` infers the response type carried by `Query`. Both are interfaces implemented in infrastructure. + +**Example:** +```typescript +// src/Contexts/Shared/domain/CommandBus.ts +export interface CommandBus { + dispatch(command: T): Promise; +} + +// src/Contexts/Shared/domain/QueryBus.ts +export interface QueryBus { + ask(query: Query): Promise; +} + +// src/Contexts/Shared/domain/CommandHandler.ts +export interface CommandHandler { + subscribedTo(): MessageConstructor; + handle(command: T): Promise; +} + +// src/Contexts/Shared/domain/QueryHandler.ts +export interface QueryHandler> { + subscribedTo(): MessageConstructor; + handle(query: Q): Promise; +} +``` + +**Practical heuristic:** CQRS split is at the bus level: commands mutate state and return nothing; queries read state and return a response. Never mix the two in one handler. + +--- + +## CommandBus / QueryBus — In-Memory Implementations + +**Intent:** Provide a synchronous, in-process bus for development and testing. The registry (`CommandHandlers`, `QueryHandlers`) maps command/query classes to their handlers. + +**How it works:** The registry maps message constructors to handlers. Handlers register the concrete constructor returned by `subscribedTo()`. The bus looks up `command.constructor` at dispatch time and fails explicitly when no handler exists. + +**Example:** +```typescript +// src/Contexts/Shared/infrastructure/CommandBus/CommandHandlers.ts +export class CommandHandlers { + private readonly handlers = new Map, CommandHandler>(); + + constructor(commandHandlers: ReadonlyArray>) { + commandHandlers.forEach((handler) => { + this.handlers.set(handler.subscribedTo(), handler); + }); + } + + get(command: T): CommandHandler { + const constructor = command.constructor as MessageConstructor; + const commandHandler = this.handlers.get(constructor); + if (!commandHandler) { + throw new CommandNotRegisteredError(command); + } + return commandHandler as CommandHandler; + } +} + +// src/Contexts/Shared/infrastructure/CommandBus/InMemoryCommandBus.ts +export class InMemoryCommandBus implements CommandBus { + constructor(private commandHandlers: CommandHandlers) {} + + async dispatch(command: T): Promise { + const handler = this.commandHandlers.get(command); + await handler.handle(command); + } +} +``` + +**Practical heuristic:** The lookup key is the message constructor, not an instance. Mirror the same typed constructor registry for queries. More elaborate buses can use explicit stable message names when constructors cannot cross serialization boundaries. + +--- + +## Nullable Type Utility + +**Intent:** Express one canonical absence representation in a domain API. + +**Example:** +```typescript +// src/Contexts/Shared/domain/Nullable.ts +export type Nullable = T | null; + +// Usage in a repository return type +async findById(id: CourseId): Promise> { ... } +``` + +**Practical heuristic:** Choose either `null` or `undefined` within a domain API rather than combining both. Accept both only at an external parsing boundary and normalize immediately. + +--- + +## Criteria Pattern — Flexible Repository Queries + +**Intent:** Express genuinely dynamic query conditions without exposing SQL or storage syntax. Keep stable business searches as named methods and use a read-model query port for projections. + +**How it works:** A validated immutable Criteria carries a typed predicate tree, stable logical fields, order, and one bounded pagination variant. Infrastructure maps logical fields/operators to backend-native expressions and rejects unsupported semantics. + +**Example:** +```typescript +type Predicate = + | { readonly kind: "comparison"; readonly field: "name" | "publishedAt" | "id"; readonly operator: "eq" | "contains" | "gt"; readonly value: string } + | { readonly kind: "and"; readonly operands: readonly Predicate[] }; + +type Criteria = Readonly<{ + readonly predicate: Predicate | null; + readonly order: readonly { field: "publishedAt" | "id"; direction: "asc" | "desc" }[]; + readonly page: { readonly limit: number; readonly offset: number }; +}>; + +class InvalidCriteria extends Error {} + +function courseCriteria(input: Criteria): Criteria { + if (!Number.isInteger(input.page.limit) || input.page.limit < 1 || input.page.limit > 100) throw new InvalidCriteria(); + if (!Number.isInteger(input.page.offset) || input.page.offset < 0) throw new InvalidCriteria(); + if (input.order.at(-1)?.field !== "id") throw new InvalidCriteria(); + return Object.freeze({ + predicate: input.predicate, + order: Object.freeze(input.order.map((item) => Object.freeze({ ...item }))), + page: Object.freeze({ ...input.page }), + }); +} + +interface CourseRepository { + matching(criteria: Criteria): Promise; +} +``` + +**Practical heuristic:** Criteria's layer follows its vocabulary: domain-named selectors may live with an Aggregate repository; generic filters belong to the application/query core. No HTTP parameter, SQL identifier, or Elasticsearch DSL belongs in this contract. Use `design-patterns-best-practices` for converter, pagination, and testing details. + +--- + +## Object Mother Pattern — Test Data Factories + +**Intent:** Centralize valid test data creation so tests stay short and readable. Each domain concept gets its own Mother class with deterministic defaults and focused overrides. + +**How it works:** Each Mother has `create(explicit params)` and may provide seeded generation for intentional variation. The test specifies every value relevant to its rule. If randomness is used, inject/print the seed so failures reproduce exactly. + +**Example:** +```typescript +// tests/Contexts/Mooc/Courses/domain/CourseMother.ts +export class CourseMother { + static create(id: CourseId, name: CourseName, duration: CourseDuration): Course { + return new Course(id, name, duration); + } + + static from(command: CreateCourseCommand): Course { + return this.create( + CourseIdMother.create(command.id), + CourseNameMother.create(command.name), + CourseDurationMother.create(command.duration) + ); + } + + static random(seed: number): Course { + return this.create( + CourseIdMother.random(seed), + CourseNameMother.random(seed + 1), + CourseDurationMother.random(seed + 2) + ); + } +} + +// tests/Contexts/Mooc/Courses/domain/CourseNameMother.ts +export class CourseNameMother { + static create(value: string): CourseName { return CourseName.create(value); } + static random(seed: number): CourseName { + return this.create(WordMother.random({ seed, maxLength: 20 })); + } + static invalidName(): string { return 'a'.repeat(40); } +} + +// tests/Contexts/Mooc/Courses/application/CreateCourseCommandMother.ts +export class CreateCourseCommandMother { + static create(id: CourseId, name: CourseName, duration: CourseDuration): CreateCourseCommand { + return { id: id.value, name: name.value, duration: duration.value }; + } + + static random(seed: number): CreateCourseCommand { + return this.create( + CourseIdMother.random(seed), + CourseNameMother.random(seed + 1), + CourseDurationMother.random(seed + 2) + ); + } + + static invalid(seed: number): CreateCourseCommand { + return { + id: CourseIdMother.random(seed).value, + name: CourseNameMother.invalidName(), // 40 chars — exceeds limit + duration: CourseDurationMother.random(seed + 1).value + }; + } +} +``` + +**Practical heuristic:** Command Mothers produce flat primitives; Domain Mothers produce Value Objects. Keep them at the right layer — don't mix them. + +--- + +## Unit Test Structure — Command Handler + Mock Repository + Mock EventBus + +**Intent:** Show the idiomatic test structure: inject mock repository and EventBus, use Object Mothers to build inputs and expectations, use assertion helpers on the mock to verify side effects. + +**Example:** +```typescript +// tests/Contexts/Mooc/Courses/application/CreateCourseCommandHandler.test.ts +let repository: CourseRepositoryMock; +let creator: CourseCreator; +let eventBus: EventBusMock; +let handler: CreateCourseCommandHandler; + +beforeEach(() => { + repository = new CourseRepositoryMock(); + eventBus = new EventBusMock(); + creator = new CourseCreator(repository, eventBus); + handler = new CreateCourseCommandHandler(creator); +}); + +describe('CreateCourseCommandHandler', () => { + it('should create a valid course', async () => { + const command = CreateCourseCommandMother.random(42); + const course = CourseMother.from(command); + const domainEvent = CourseCreatedDomainEventMother.fromCourse(course); + + await handler.handle(command); + + repository.assertSaveHaveBeenCalledWith(course); + eventBus.assertLastPublishedEventIs(domainEvent); + }); + + it('should throw error if course name length is exceeded', async () => { + const command = CreateCourseCommandMother.invalid(42); + + await expect(handler.handle(command)).rejects.toThrow(CourseNameLengthExceeded); + }); +}); +``` + +**Practical heuristic:** Mothers provide valid deterministic defaults; override values relevant to the behavior. Verify calls after Act so a missing interaction cannot skip an assertion. Use seeded generation only when variation is intentional and reproducible. diff --git a/tests/inventory.test.mjs b/tests/inventory.test.mjs index 38956b3..67d32bf 100644 --- a/tests/inventory.test.mjs +++ b/tests/inventory.test.mjs @@ -31,11 +31,12 @@ test('live skills are unique and include the three original packages plus BotKit assert.ok(names.includes('setup-bot')); assert.ok(names.includes('retro')); assert.ok(names.includes('simple-as-writing')); + assert.ok(names.includes('ddd-best-practices')); assert.ok(names.includes('tdd')); assert.ok(names.includes('matt-tdd')); assert.ok(names.includes('teach')); assert.ok(names.includes('matt-teach')); - assert.equal(names.length, 96); + assert.equal(names.length, 97); }); test('pinned original sources remain present', async () => {