Initial commit
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
# Bounded Context Architecture Statement
|
||||
|
||||
This repository treats **bounded contexts as first-class language boundaries**.
|
||||
They should be identified early enough to shape naming, ownership, review, and code placement, but they are **not permanent**.
|
||||
A bounded context is a provisional domain boundary that may be split, merged, or reshaped later when real change pressure shows the current boundary is wrong.
|
||||
|
||||
The goal is to combine:
|
||||
|
||||
- the clarity of Domain-Driven Design language boundaries
|
||||
- the speed of feature-by-feature implementation
|
||||
- the reviewability benefits of smaller bounded seams
|
||||
- the safety of continuous refactoring over waterfall lock-in
|
||||
|
||||
## Core position
|
||||
|
||||
### 1. Bounded contexts are planned early, not frozen early
|
||||
|
||||
This repo does **not** treat bounded contexts as something that should be discovered only after a large amount of code exists.
|
||||
It is usually worth defining an initial bounded-context map early because it gives:
|
||||
|
||||
- a stable language boundary
|
||||
- a first-pass ownership model
|
||||
- an initial code organization strategy
|
||||
- a clearer review surface for human and AI contributors
|
||||
|
||||
However, this initial map is only a starting point.
|
||||
It should be treated as a hypothesis that is refined as the system evolves.
|
||||
|
||||
### 2. Cross-context business processes are modeled as top-level workflows
|
||||
|
||||
A business process such as checkout may involve multiple bounded contexts such as inventory, payment, and receipts.
|
||||
That does **not** mean those contexts should call each other directly.
|
||||
|
||||
Instead:
|
||||
|
||||
- cross-context coordination belongs in top-level workflows
|
||||
- workflows may orchestrate multiple contexts
|
||||
- each context keeps its own internal policies, models, and implementation details local
|
||||
|
||||
This makes the workflow graph the place where cross-context business processes are visible and reviewable.
|
||||
|
||||
### 3. Contexts do not talk to each other directly
|
||||
|
||||
A bounded context should not directly import another bounded context's internal files or make decisions inside another context's language.
|
||||
If one context needs something from another, that interaction should happen through a top-level workflow that calls each context through its public API.
|
||||
|
||||
The intended rule is:
|
||||
|
||||
> Cross-context access goes through public intent APIs via top-level workflows.
|
||||
|
||||
This keeps context ownership clear and prevents hidden coupling between contexts.
|
||||
|
||||
### 4. Internal seams are smaller than bounded contexts
|
||||
|
||||
A bounded context is not the smallest review unit.
|
||||
Inside a bounded context there may still be smaller seams such as:
|
||||
|
||||
- public intent APIs
|
||||
- workflow steps
|
||||
- policies
|
||||
- translators
|
||||
- adapters
|
||||
- module entrypoints
|
||||
|
||||
These seams exist to keep implementation reviewable and refactorable.
|
||||
A bounded context gives the outer language boundary.
|
||||
Internal seams give local structure inside that boundary.
|
||||
|
||||
## Repository structure implications
|
||||
|
||||
The primary organization should be **by context**, not by global layer.
|
||||
Top-level folders should stay small and reserved for true cross-context concerns.
|
||||
|
||||
A typical direction is:
|
||||
|
||||
```text
|
||||
src/
|
||||
├── shared/ # tiny ubiquitous primitives only
|
||||
├── workflows/ # top-level cross-context orchestration only
|
||||
├── tasks/ # optional cross-context workflow steps, only if needed
|
||||
└── contexts/ # first-class bounded contexts
|
||||
```
|
||||
|
||||
Within each context, the context may contain some combination of:
|
||||
|
||||
- domain models
|
||||
- policies
|
||||
- workflows or tasks
|
||||
- layer definitions
|
||||
- adapters
|
||||
- registries
|
||||
- translators
|
||||
- lib
|
||||
|
||||
But these should be treated as an **allowed menu**, not a mandatory checklist.
|
||||
Do not create internal folders just to satisfy symmetry.
|
||||
Only create the internal seams that the current context actually needs.
|
||||
|
||||
## Shared types and translation
|
||||
|
||||
The repository should avoid sharing **rich domain types** across bounded contexts.
|
||||
A type that looks generic often becomes a hidden coupling point if different contexts need different meaning, rules, or fields.
|
||||
|
||||
Examples:
|
||||
|
||||
- a shipping address may not be the same concept as a profile address
|
||||
- a payment identifier may not mean the same thing as an accounting identifier
|
||||
- an inventory quantity may not carry the same rules as a purchasing quantity
|
||||
|
||||
So the rule is:
|
||||
|
||||
- keep `shared/` small
|
||||
- use it only for truly ubiquitous primitives and low-meaning technical building blocks
|
||||
- keep rich domain models local to their context
|
||||
- translate at the boundary when data moves between contexts
|
||||
|
||||
This follows the DDD idea that context edges are where translation belongs.
|
||||
The purpose of translation is not ceremony for its own sake.
|
||||
It is to stop one context's language from silently taking over another.
|
||||
|
||||
## Policies at the top level
|
||||
|
||||
This repository should **not** start with a top-level `policies/` folder by default.
|
||||
Most policy logic belongs inside a bounded context because most business rules are written in the language of one context.
|
||||
|
||||
For cross-context workflows:
|
||||
|
||||
- start with orchestration logic in the workflow
|
||||
- keep cross-context policy inline until it becomes a real stable seam
|
||||
- only extract a top-level policy when the rule is truly about the relationship between contexts or the process as a whole
|
||||
|
||||
This avoids blessing a global policy layer before there is evidence that it is needed.
|
||||
|
||||
## Folders before packages
|
||||
|
||||
Bounded contexts should usually start as **folders/modules in one package**, not as separate packages.
|
||||
|
||||
Why folders first:
|
||||
|
||||
- lower tooling overhead
|
||||
- easier to reshape while boundaries are still provisional
|
||||
- simpler imports and refactors during early design pressure
|
||||
- enough structure to enforce public APIs and context ownership
|
||||
|
||||
Why not packages immediately:
|
||||
|
||||
- package boundaries are harder to change
|
||||
- they add build and tooling complexity early
|
||||
- they can create premature rigidity before the domain map has earned it
|
||||
|
||||
Packages may make sense later if a context becomes operationally independent enough to justify stronger enforcement.
|
||||
But the default should be folders first, packages later if proven necessary.
|
||||
|
||||
## Refactoring stance
|
||||
|
||||
Refactoring is part of the architecture, not a repair step after the fact.
|
||||
This repo should assume that:
|
||||
|
||||
- some initial bounded contexts will be wrong
|
||||
- some contexts will need to split
|
||||
- some initially separate contexts will later merge
|
||||
- internal seams will evolve under real use
|
||||
|
||||
That is not a failure of DDD.
|
||||
It is how a domain model becomes more accurate over time.
|
||||
|
||||
The important thing is to preserve a stable review model while allowing structure to improve.
|
||||
The combination used here is:
|
||||
|
||||
- define provisional bounded contexts early
|
||||
- keep cross-context coordination at top-level workflows
|
||||
- keep rich models inside contexts
|
||||
- prefer public APIs at boundaries
|
||||
- refine seams and boundaries through refactoring when change pressure justifies it
|
||||
|
||||
## Lightweight principles
|
||||
|
||||
Use these as the short version:
|
||||
|
||||
1. Bounded contexts are first-class language boundaries.
|
||||
2. Bounded contexts are planned early but treated as provisional.
|
||||
3. Only top-level workflows coordinate across contexts.
|
||||
4. Contexts do not call each other directly.
|
||||
5. Cross-context access goes through public intent APIs via top-level workflows.
|
||||
6. `shared/` is tiny and contains only truly ubiquitous primitives.
|
||||
7. Rich domain models stay local to their context.
|
||||
8. Translation happens at context edges when data crosses boundaries.
|
||||
9. Internal seams are smaller than bounded contexts and should emerge under real pressure.
|
||||
10. Split or merge bounded contexts based on change pressure and reviewability, not theory alone.
|
||||
|
||||
## What this architecture is optimizing for
|
||||
|
||||
This shape is intended to optimize for:
|
||||
|
||||
- strong domain language boundaries
|
||||
- low hidden coupling
|
||||
- easier human review of AI-generated code
|
||||
- safer localized refactoring
|
||||
- incremental delivery without pretending the first domain map is final
|
||||
@@ -0,0 +1,26 @@
|
||||
# Composition over Events
|
||||
|
||||
## The Decision
|
||||
We prioritize **Direct Function Composition** (via `pipe`) for core business logic. We use **Events** only for side effects that do not impact the transaction's success.
|
||||
|
||||
## The Reasoning
|
||||
|
||||
### 1. The "Microservice Envy" Trap
|
||||
Developers often break monoliths into event-driven fragments to "decouple" them. Inside a single application, this leads to:
|
||||
- **Loss of Observability**: You can't trace a request top-to-bottom.
|
||||
- **Error Handling Complexity**: If the event consumer fails, the producer doesn't know.
|
||||
- **Refactoring Pain**: "Find Usages" breaks.
|
||||
|
||||
### 2. Local Reasoning
|
||||
With Effect, `step1.pipe(step2)` provides:
|
||||
- **Transactional Integrity**: If step2 fails, the whole block fails (or recovers).
|
||||
- **Type Safety**: The output of step1 is typed as the input of step2.
|
||||
- **Readability**: The linear flow is visible.
|
||||
|
||||
### 3. When to use Events
|
||||
Use events for **Fire-and-Forget** side effects:
|
||||
- Sending analytics
|
||||
- Sending welcome emails
|
||||
- Notifying external webhooks
|
||||
|
||||
If the business outcome depends on it (e.g., "Charge Card"), it must be a function call, not an event.
|
||||
@@ -0,0 +1,65 @@
|
||||
# History & Architectural Decisions
|
||||
|
||||
This document tracks the evolution of our architectural decisions, including the paths we considered and ultimately rejected.
|
||||
|
||||
## 1. The "3-Layer" vs "Granular Seams" Debate
|
||||
|
||||
**The Consideration:**
|
||||
Should we stick to the industry-standard 3-Layer architecture (Controller -> Service -> Repository) for simplicity?
|
||||
|
||||
**The Counter-Argument (Why we rejected 3-Layer):**
|
||||
- **The "Fat Service" Problem:** In 3-layer systems, the "Service" layer becomes a magnet for *everything*—business logic, validation, I/O, and orchestration.
|
||||
- **Testing Difficulty:** To test a business rule in a Service, you often have to mock the Repository, because they are tightly coupled.
|
||||
- **Effect Granularity:** In Effect, seams are cheap. There is no performance penalty for splitting decisions from mechanics.
|
||||
|
||||
**The First Decision:**
|
||||
We initially described the repo as an **Onion Architecture** with granular layers:
|
||||
1. Domain Models (Pure Logic)
|
||||
2. Policies (Pure Decisions)
|
||||
3. Workflows (Impure Orchestration)
|
||||
4. Infrastructure (Impure I/O)
|
||||
|
||||
**The Later Reframe:**
|
||||
We kept the purity-based seam logic, but stopped treating the global layer tree as the main repository map.
|
||||
The repo now prefers **bounded contexts first**, with those smaller seams living mostly *inside each context*.
|
||||
|
||||
## 2. The "Event-Driven Everything" Debate
|
||||
|
||||
**The Consideration:**
|
||||
Should we use Effect's Event system (Pub/Sub) to decouple workflows? (e.g., `Workflow A` emits `Event X`, `Workflow B` listens).
|
||||
|
||||
**The Counter-Argument (Why we rejected internal events):**
|
||||
- **Loss of Locality:** You cannot click "Go to Definition" to see what happens next. The control flow becomes invisible.
|
||||
- **Error Propagation:** If the listener fails, the emitter doesn't know. You lose the transactional integrity of `Effect.gen`/`pipe`.
|
||||
- **Complexity:** It requires an internal event bus infrastructure that mimics microservices but inside a monolith ("Microservice Envy").
|
||||
|
||||
**The Decision:**
|
||||
Use **Direct Composition** (Functions calling Functions) for all core business logic.
|
||||
Use **Events** ONLY for "fire-and-forget" side effects (e.g., sending analytics, welcome emails) where failure does not invalidate the transaction.
|
||||
|
||||
## 3. The naming of adapters
|
||||
|
||||
**The Consideration:**
|
||||
Should we name adapters by their implementation (e.g., `StripeAdapter`) or their role (e.g., `SmallPaymentService`)?
|
||||
|
||||
**The Counter-Argument:**
|
||||
- **Implementation Names (`StripeAdapter`)**: Couples the domain to the vendor. If we switch to PayPal, we have to rename everything.
|
||||
- **Role Names (`SmallPaymentService`)**: Can drift semantically. What if "Small" becomes "VIP"? The name becomes a lie.
|
||||
|
||||
**The Decision:**
|
||||
Use **Strategy Pattern**:
|
||||
- **Interface**: `PaymentInterface` (Generic).
|
||||
- **Policy**: Returns a Strategy Value (e.g., `RETAIL_CHANNEL`).
|
||||
- **Registry**: Maps `RETAIL_CHANNEL` -> `StripeAdapter`.
|
||||
- **Workflow**: Asks Registry for the implementation.
|
||||
|
||||
## 4. The "Checks" Layer
|
||||
|
||||
**The Consideration:**
|
||||
Should we have a top-level `checks/` folder for simple predicates?
|
||||
|
||||
**The Rejection:**
|
||||
It encouraged "Anemic Domain Models" where logic was stripped away from the data types.
|
||||
|
||||
**The Decision:**
|
||||
Move "Checks" into the **Domain Model** modules (`src/domain/models/`). Logic about an entity belongs *with* the entity schema.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Architecture Reasoning
|
||||
|
||||
This folder contains deep-dive explanations for the architectural decisions made in this template.
|
||||
The current governing direction is **context-first architecture**: bounded contexts are the main language boundary, while purity and orchestration rules still shape the seams inside and between contexts.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Bounded Context Architecture Statement](bounded-contexts.md): The current architecture baseline for code placement, seams, and cross-context coordination.
|
||||
- [Architecture Rationale Summary](rationale-summary.md): The short version for review refreshers and interviews.
|
||||
- [Layers & Granularity](layers-granularity.md): How purity-based seams still matter once organization becomes context-first.
|
||||
- [Pure Logic vs Orchestration](pure-logic.md): The separation of decisions from orchestration inside contexts and top-level workflows.
|
||||
- [Service Strategy](service-strategy.md): How public APIs, adapters, and optional registries fit inside bounded contexts.
|
||||
- [Composition over Events](composition.md): Why we prefer direct function composition over internal event buses for core logic.
|
||||
- [Type-Driven Design](type-driven-design.md): Encoding business rules into the type system ("Make Illegal States Unrepresentable").
|
||||
- [History & Decisions](history.md): A log of architectural alternatives considered, adopted, or later reframed.
|
||||
- [Security Verification Rationale](security-verification-rationale.md): Why security review is modeled as verification gates, why there are separate design and implementation skills, and why sink handling and capability scope are emphasized.
|
||||
@@ -0,0 +1,30 @@
|
||||
### Core String Security & AI Architecture Principles
|
||||
|
||||
#### 1. Type-Level Security (Data Provenance & Taint Tracking)
|
||||
* **Never pass raw strings:** A raw string has no memory. Do not pass raw `string` types to sensitive sinks (database queries, AI contexts, DOM rendering).
|
||||
* **Enforce wrapper objects:** Use strongly typed objects (e.g., `TrustedInput`, `TaintedInput`) to carry the string.
|
||||
* **Track provenance:** The type must explicitly state where the string came from (its source) and whether it is trusted or tainted. *Enforce this at the compiler/type-checker level if your language allows it (e.g., TypeScript, Rust).*
|
||||
|
||||
#### 2. Defense in Depth (Ingestion vs. Use)
|
||||
* **Filter strictly at Ingestion:** Reject bad data early. Enforce maximum lengths, validate file extensions/magic numbers, check data types, and drop edge cases (like null bytes). This keeps the system state clean.
|
||||
* **Sanitize/Encode AT the Point of Use:** Remember that "dangerous" is context-dependent. Apply specific encoding right before the string is consumed (e.g., HTML escaping for web renders, Parameterized Binding for SQL, strict XML encapsulation for LLMs).
|
||||
|
||||
#### 3. Structural Boundaries (No Concatenation)
|
||||
* **Parameterized Queries & Messages:** Never concatenate strings to build a query or an AI prompt. For databases, rely on the ORM or parameterized query objects. For AI, use API message arrays (e.g., `[{role: "system", content: "..."}, {role: "user", content: "..."}]`) to structuralize the conversation.
|
||||
|
||||
#### 4. The Data Abstraction (Control vs. Data Planes)
|
||||
* **Isolate Sub-Agents:** When handling untrusted data, pass the data to an isolated "Worker" AI that has zero access to tools or APIs.
|
||||
* **Shield the Orchestrator:** Strive to return the Worker AI's output directly to the user. Avoid sending the tainted output back into the context window of your main "Boss/Orchestrator" AI.
|
||||
|
||||
#### 5. Mitigation for Orchestrator Taint (If you *must* feed it back)
|
||||
* **Principle of Least Privilege:** If the orchestrator *must* read a tainted summary, restrict its available tools to the absolute minimum necessary for that specific turn of conversation.
|
||||
* **Human-in-the-Loop (HITL):** Any destructive or sensitive command invoked by an AI (e.g., dropping databases, sending emails, transferring funds) must pause execution and require explicit human authorization.
|
||||
* *(New)* **Explicit Delimiters:** When injecting untrusted text into a prompt, lock it inside clear, hard-to-guess delineators (e.g., `<user_document_untrusted> [DATA] </user_document_untrusted>`) so the model knows where the data starts and stops.
|
||||
|
||||
|
||||
#### 6. AI Guardrails (Pre-computation Filtering)
|
||||
* Before sending user input to a slow, expensive AI agent, pass it through a tiny, fast classification model (like Llama Guard) designed solely to detect prompt injection or jailbreak attempts. If it flags as malicious, block the request before it even reaches your main orchestration logic.
|
||||
|
||||
#### 7. Audit Logging
|
||||
* Because AI agents are non-deterministic, you must log all tool invocations and the exact context (the prompts and tainted strings) that led to that tool being called. If a prompt injection attack *does* succeed, you need this log to figure out how they tricked the agent.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Layers & Granularity After the Bounded-Context Shift
|
||||
|
||||
## The Decision
|
||||
We no longer treat a global N-layer onion as the primary repository map.
|
||||
Instead, we organize the repo by **bounded context first**, while still using **purity-based seams** inside each context and across top-level workflows.
|
||||
|
||||
## The Reasoning
|
||||
|
||||
### 1. The trap of the global service layer still exists
|
||||
In traditional 3-layer architectures, the "Service" layer becomes a dumping ground. It often handles:
|
||||
- Validation
|
||||
- Business Rules
|
||||
- Orchestration
|
||||
- I/O
|
||||
- Data Transformation
|
||||
|
||||
That problem still matters.
|
||||
The fix is still to separate concerns by purity and intent.
|
||||
What changed is **where that separation is expressed**: mostly inside each context, not as one global folder tree.
|
||||
|
||||
### 2. Context-first, seams-second
|
||||
In this template, bounded contexts give the outer language boundary.
|
||||
Inside those boundaries, we still use smaller seams based on intent.
|
||||
|
||||
| Seam inside a context | Purpose |
|
||||
|-----------------------|---------|
|
||||
| **Models** | Rich local state and pure operations |
|
||||
| **Policies** | Pure local decisions |
|
||||
| **Context workflows/tasks** | Local orchestration when a context needs it |
|
||||
| **Public API** | The intent surface other code is allowed to call |
|
||||
| **Interfaces / capabilities** | The internal contract between domain-facing code and effectful or replaceable collaborators |
|
||||
| **Adapters / registries / translators** | Boundary and implementation details |
|
||||
|
||||
At the repository top level, `src/workflows/` remains the place for **cross-context orchestration**.
|
||||
|
||||
### 3. Why this is better than one global onion tree
|
||||
A global folder tree keeps technical roles visible, but it weakens language ownership.
|
||||
You end up grouping code by purity category even when the more important question is, "Which domain language owns this concept?"
|
||||
|
||||
Context-first structure improves:
|
||||
- **Ownership clarity**: related concepts stay together
|
||||
- **Boundary reviewability**: cross-context interactions are easier to spot
|
||||
- **Refactoring safety**: a context can split or merge without rewriting the whole repo map
|
||||
|
||||
### 4. What still stays true
|
||||
The bounded-context shift did **not** repeal the old purity guidance.
|
||||
These points still stand:
|
||||
- pure decisions should stay out of I/O-heavy orchestration code
|
||||
- workflows should coordinate rather than bury business rules
|
||||
- interfaces/capabilities remain crucial internal seams
|
||||
- adapters should implement capabilities rather than own domain policy
|
||||
- registries are optional seams, not default architecture furniture
|
||||
|
||||
## Practical takeaway
|
||||
Think about architecture in two levels:
|
||||
1. **Outer boundary**: which bounded context owns the language?
|
||||
2. **Inner seams**: within that context, what should be a model, policy, workflow, translator, adapter, or public API?
|
||||
|
||||
That gives you the benefits of granular design without forcing the entire repo into one technical-layer-first shape.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Pure Logic vs Orchestration
|
||||
|
||||
## The Decision
|
||||
We still separate **decisions** from **mechanics**, but now we describe that separation in a context-first way:
|
||||
|
||||
- pure logic lives inside the bounded context that owns the language
|
||||
- top-level workflows coordinate across contexts
|
||||
- cross-context coordination should not erase local policy ownership
|
||||
|
||||
## The Reasoning
|
||||
|
||||
### 1. The "process vs rule" conflict still matters
|
||||
In many codebases, the rule "User must be over 18" is mixed with the code that "fetches the user from DB."
|
||||
That means to test the rule, you have to mock the DB.
|
||||
|
||||
We still separate them:
|
||||
- **Policy or model logic**: `canAccess(user)` -> pure function
|
||||
- **Workflow**: `fetchUser()` -> `canAccess(user)` -> `return`
|
||||
|
||||
### 2. Where pure logic belongs now
|
||||
The old docs sometimes implied that all decisions should be expressed through one top-level `src/policies/` folder.
|
||||
That is no longer the default.
|
||||
|
||||
Pure logic now usually lives in two places **inside a context**:
|
||||
1. **Models**: pure logic about one entity or state shape
|
||||
2. **Policies**: pure logic about decisions or relationships within that context
|
||||
|
||||
For cross-context workflows:
|
||||
- start with orchestration in the top-level workflow
|
||||
- keep cross-context rule logic inline until it earns a stable seam
|
||||
- only extract a top-level policy when the rule is genuinely about the relationship between contexts or the whole process
|
||||
|
||||
### 3. Determinism is still the payoff
|
||||
By keeping policy logic pure, we ensure that for any given input, the business decision is always the same.
|
||||
That makes testing easier, debugging easier, and review easier.
|
||||
|
||||
### 4. The workflow's job
|
||||
A workflow gathers data, invokes pure logic, coordinates effects, and makes the cross-context process visible.
|
||||
It should not quietly become the place where all business meaning accumulates.
|
||||
|
||||
That is why the repo now prefers:
|
||||
- local pure decisions inside contexts
|
||||
- public APIs at context boundaries
|
||||
- top-level workflows for visible cross-context coordination
|
||||
@@ -0,0 +1,90 @@
|
||||
# Architecture Rationale Summary
|
||||
|
||||
This page is the short version of why this template uses its current architecture.
|
||||
Use it as a refresher before design reviews, architecture discussions, or interviews.
|
||||
|
||||
## The central goal
|
||||
|
||||
The architecture is designed to make business logic easier to design, easier to review, and easier to change without dragging technical and collaborator concerns through every part of the codebase.
|
||||
|
||||
## The current shape
|
||||
|
||||
The repository is now optimized around **bounded contexts first**.
|
||||
That means the main organizing boundary is domain language ownership, not one global tree of layers.
|
||||
Purity still matters, but it mostly shows up **inside a context** and at **top-level workflow seams**.
|
||||
|
||||
## Why this structure exists
|
||||
|
||||
### Bounded contexts give language boundaries
|
||||
|
||||
A bounded context gives a local vocabulary, ownership seam, and review surface.
|
||||
That reduces hidden coupling and makes it easier to see where translation is required.
|
||||
|
||||
### Workflows should coordinate across contexts, not contexts calling each other directly
|
||||
|
||||
Cross-context business processes belong in top-level workflows.
|
||||
That keeps coordination visible and prevents one context from reaching into another context's internal language.
|
||||
|
||||
### Pure logic should stay easy to reason about
|
||||
|
||||
Inside a context, domain models and decisions should stay free of I/O where practical.
|
||||
That keeps the important rules easier to test, easier to review, and easier to trust.
|
||||
|
||||
### Types should carry business meaning
|
||||
|
||||
The design aims to make illegal states unrepresentable where practical.
|
||||
That is why the architecture prefers rich local domain types and explicit state variants instead of generic shapes plus status flags.
|
||||
|
||||
### Shared code should stay tiny
|
||||
|
||||
A type that looks reusable can become hidden cross-context coupling.
|
||||
The architecture therefore keeps `shared/` small and prefers translation at context boundaries.
|
||||
|
||||
## Why not one global layer tree
|
||||
|
||||
A global `src/domain/`, `src/policies/`, `src/adapters/`, and similar layout can keep purity visible, but it tends to weaken bounded language ownership.
|
||||
Over time it encourages unrelated concepts to sit beside each other just because they share a technical role.
|
||||
|
||||
This template now prefers:
|
||||
|
||||
- contexts as the first-class organizational boundary
|
||||
- local models, policies, and capability interfaces inside each context
|
||||
- top-level workflows for cross-context coordination
|
||||
- public APIs and translators at boundaries
|
||||
|
||||
## Why not events for everything
|
||||
|
||||
The architecture prefers direct composition for core application flow.
|
||||
Internal events are useful, but if they become the default glue, the system can lose local traceability and make refactoring harder.
|
||||
|
||||
The template therefore favors explicit function calls for the core path and reserves event-style thinking for domain outcomes and selected integration boundaries.
|
||||
|
||||
## Main tradeoffs
|
||||
|
||||
This architecture gives you:
|
||||
|
||||
- stronger language boundaries
|
||||
- lower hidden coupling
|
||||
- clearer review surfaces for cross-context flows
|
||||
- more explicit local domain models
|
||||
- safer localized refactoring
|
||||
|
||||
It also costs you:
|
||||
|
||||
- more up-front naming and boundary decisions
|
||||
- more translation at context edges
|
||||
- a need for discipline so `shared/` stays small and public APIs stay real
|
||||
|
||||
## What to say in an interview
|
||||
|
||||
A short summary is:
|
||||
|
||||
> We organize the system around bounded contexts so domain language and ownership stay explicit. Cross-context business processes are coordinated in top-level workflows, while pure decisions and state logic stay local to each context. We keep shared code small, translate at boundaries, and use rich types so business meaning stays reviewable and change remains localized.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- Read `bounded-contexts.md` for the governing architecture statement.
|
||||
- Read `index.md` for the full architecture map.
|
||||
- Read `history.md` for alternatives considered and reframed.
|
||||
- Read `type-driven-design.md` for the type-modeling rationale.
|
||||
- Read `pure-logic.md` for the decision versus workflow split.
|
||||
@@ -0,0 +1,457 @@
|
||||
# Security Verification Rationale
|
||||
|
||||
This document explains why the repository now treats security review as an explicit verification activity and why the new security-review skills are shaped the way they are.
|
||||
|
||||
It is meant to be a durable reference for future contributors who ask questions such as:
|
||||
|
||||
- Why is security review a verification gate instead of a numbered TDFDDD phase?
|
||||
- Why are there two skills instead of one giant security reviewer?
|
||||
- Why is the review report structured the way it is?
|
||||
- Why do we emphasize trust boundaries, sinks, capability scope, and blast radius so heavily?
|
||||
- Why do we reject a single generic sanitizer in favor of context-specific handling?
|
||||
|
||||
## 1. Why security review was added
|
||||
|
||||
The original TDFDDD process already pushed the project toward strong design boundaries:
|
||||
|
||||
- parse untrusted data at the edge
|
||||
- keep domain logic pure
|
||||
- make illegal states harder to express
|
||||
- isolate infrastructure behind explicit seams
|
||||
|
||||
Those choices improve security indirectly because they make review easier and reduce accidental complexity.
|
||||
However, they do not by themselves guarantee secure software.
|
||||
|
||||
A codebase can still have:
|
||||
|
||||
- over-broad runtime authority
|
||||
- unsafe sink usage
|
||||
- missing recovery and detection planning
|
||||
- dangerous supply-chain assumptions
|
||||
- poor secret handling
|
||||
- security-sensitive omissions in tests and design docs
|
||||
|
||||
That is why the repository now treats security review as a first-class part of the development workflow.
|
||||
The goal is not to replace the design process with a security process.
|
||||
The goal is to add explicit checkpoints where reviewers can ask security-specific questions before risk silently ships.
|
||||
|
||||
## 2. Why security verification is a gate, not a core phase
|
||||
|
||||
The main TDFDDD phases exist to discover and freeze domain meaning:
|
||||
|
||||
1. discovery
|
||||
2. policy sketch
|
||||
3. domain modeling
|
||||
4. contract finalization
|
||||
5. implementation
|
||||
|
||||
Those phases answer questions like:
|
||||
|
||||
- What is the business action?
|
||||
- What information is required to decide it?
|
||||
- What states and events exist?
|
||||
- What is the contract between pure logic and orchestration?
|
||||
|
||||
Security review plays a different role.
|
||||
It does not create domain meaning.
|
||||
It evaluates whether the emerging design or implementation introduces avoidable risk.
|
||||
|
||||
That distinction matters.
|
||||
If security review were framed as just another design phase, it would encourage the wrong mental model:
|
||||
security would look like one more artifact to generate rather than a quality gate that can block, refine, or redirect work.
|
||||
|
||||
Calling it a **verification gate** keeps the relationship clear:
|
||||
|
||||
- the design phases build the artifact
|
||||
- the verification gate reviews the artifact
|
||||
- findings can send the work back for clarification or redesign
|
||||
|
||||
This is similar to testing, architecture review, or type-checking.
|
||||
They are essential to delivery, but they are not themselves the business-design phases.
|
||||
|
||||
## 3. Why there is a single skill with two phases
|
||||
|
||||
We chose to consolidate security review into one skill:
|
||||
|
||||
- `tdfddd-security-verification` (covers both Design and Implementation)
|
||||
|
||||
This was intentional to reduce instruction duplication, but internally it still branches its logic based on the phase.
|
||||
|
||||
### 3.1 Design review and implementation review answer different questions
|
||||
|
||||
Design review asks:
|
||||
|
||||
- Are trust boundaries visible?
|
||||
- Are capability boundaries explicit?
|
||||
- Are likely dangerous sinks named?
|
||||
- Are secrets, blast radius, recovery, and testing expectations discussed?
|
||||
- Is the proposed architecture setting up an implementation that can be secure?
|
||||
|
||||
Implementation review asks:
|
||||
|
||||
- Did the code actually parse untrusted input before treating it as trusted?
|
||||
- Did the implementation use safe sink patterns?
|
||||
- Did the workflow receive too much authority at runtime?
|
||||
- Did the tests cover the risky seam?
|
||||
- Did config or setup choices introduce additional exposure?
|
||||
|
||||
A single set of generic rules would blur these concerns.
|
||||
That would create two failure modes:
|
||||
|
||||
1. **False precision during design review**: the reviewer would start demanding code-level proof from artifacts that do not yet contain code.
|
||||
2. **Shallow code review during implementation**: the reviewer would spend too much time repeating design-level concerns instead of checking the concrete implementation.
|
||||
|
||||
The skill uses phase-specific heuristics so it can operate at the right level of evidence.
|
||||
|
||||
### 3.2 Early review is cheaper than late review
|
||||
|
||||
A security issue found in the design is usually cheaper to fix than the same issue found after implementation.
|
||||
|
||||
Examples:
|
||||
|
||||
- realizing that a workflow needs a read-only capability instead of a general admin capability
|
||||
- noticing that dangerous sinks exist but the ownership of sanitization/escaping is unspecified
|
||||
- discovering that secrets and operator assumptions are absent from the design
|
||||
- identifying that no detection or recovery story exists for the workflow
|
||||
|
||||
These are architecture-shaping issues.
|
||||
Catching them before assembly avoids rework and reduces the chance that an insecure pattern gets copied into multiple concrete implementations.
|
||||
|
||||
### 3.3 Review-only behavior is safer
|
||||
|
||||
Both skills are deliberately review-only.
|
||||
They do not auto-apply fixes.
|
||||
|
||||
This decision reflects a trust and workflow concern:
|
||||
security review often requires human judgment about context, tradeoffs, operational constraints, and false positives.
|
||||
A reviewer may correctly identify a risk but still misunderstand the broader product context.
|
||||
|
||||
By keeping the skills review-only:
|
||||
|
||||
- the human stays in control of interpretation
|
||||
- the review can raise questions before code changes happen
|
||||
- the system avoids auto-patching based on assumptions
|
||||
- remediation can be handled by a separate implementation pass once the human confirms direction
|
||||
|
||||
This matches the repository philosophy that design and review should reduce improvisation rather than automate it blindly.
|
||||
|
||||
## 4. Why the report is structured and evidence-based
|
||||
|
||||
The skills use a fixed report shape with sections such as:
|
||||
|
||||
- Scope Reviewed
|
||||
- Artifacts Read
|
||||
- Security Assumptions
|
||||
- Overall Risk Summary
|
||||
- one section per security concern category
|
||||
- Cross-Cutting Questions
|
||||
- Suggested Next Actions
|
||||
|
||||
The report also distinguishes between:
|
||||
|
||||
- **Finding**
|
||||
- **Possible Concern**
|
||||
- **Insufficient Evidence**
|
||||
|
||||
### 4.1 Why use a fixed format
|
||||
|
||||
A fixed structure prevents the review from skipping important categories just because the current feature is small or because one concern happens to dominate attention.
|
||||
|
||||
Without a fixed structure, reviews often become uneven:
|
||||
|
||||
- one reviewer focuses heavily on input validation but misses privilege boundaries
|
||||
- another focuses on secrets but ignores unsafe sinks
|
||||
- another talks only about abstract best practices without grounding them in the artifacts
|
||||
|
||||
The fixed format forces coverage consistency.
|
||||
It also makes reports easier to compare across features and easier for a human to skim.
|
||||
|
||||
### 4.2 Why include evidence levels
|
||||
|
||||
Security review always includes uncertainty.
|
||||
A reviewer may see a likely issue without enough evidence to state it as fact.
|
||||
If the skill is forced to choose only between “secure” and “insecure,” it will either hallucinate confidence or stay overly silent.
|
||||
|
||||
The three evidence levels solve that:
|
||||
|
||||
- **Finding** keeps the bar high for concrete claims.
|
||||
- **Possible Concern** makes room for reviewer intuition without overstating certainty.
|
||||
- **Insufficient Evidence** explicitly allows “I cannot responsibly assess this from what I read.”
|
||||
|
||||
This design reflects a key principle: honest uncertainty is better than invented precision.
|
||||
|
||||
### 4.3 Why findings include severity per issue
|
||||
|
||||
Each issue receives its own severity score rather than producing a single binary verdict.
|
||||
This supports prioritization.
|
||||
|
||||
A feature may have:
|
||||
|
||||
- one high-severity sink flaw
|
||||
- several medium-severity capability concerns
|
||||
- a handful of low-severity documentation omissions
|
||||
|
||||
Collapsing everything into pass/fail would hide useful nuance.
|
||||
Per-issue severity makes the output actionable without pretending that all flaws are equal.
|
||||
|
||||
## 5. Why the same master categories are used for both reviews
|
||||
|
||||
The design and implementation skills share the same master concern categories:
|
||||
|
||||
1. Trust Boundaries & Data Flow
|
||||
2. Input Parsing & Validation
|
||||
3. Sink Handling & Contextual Sanitization
|
||||
4. Capabilities & Least Privilege
|
||||
5. Secrets & Sensitive Config
|
||||
6. Isolation & Blast Radius
|
||||
7. Dependency & Supply Chain Assumptions
|
||||
8. AuthN/AuthZ if applicable
|
||||
9. Logging, Telemetry & Detection
|
||||
10. Recovery & Failure Modes
|
||||
11. Unsafe Dynamic Behavior
|
||||
12. Security Testing Coverage
|
||||
|
||||
The reason is consistency.
|
||||
|
||||
Contributors should not need one mental model for design review and a different one for implementation review.
|
||||
Using the same categories gives the process continuity:
|
||||
|
||||
- at design time you ask whether the category is visible and sufficiently specified
|
||||
- at implementation time you ask whether the code and config actually satisfy the category
|
||||
|
||||
This makes handoff cleaner.
|
||||
A design review finding in “Capabilities & Least Privilege” can later be checked concretely during implementation under the same category name.
|
||||
|
||||
## 6. Why context-specific sink handling was chosen over a generic sanitizer
|
||||
|
||||
One of the most important design decisions was to reject the idea of a single generic sanitizer.
|
||||
At first glance, a universal sanitizer sounds safer because it centralizes responsibility.
|
||||
In practice, it creates a false sense of completeness.
|
||||
|
||||
The problem is that different sinks interpret data differently.
|
||||
A value that is safe in one context may be dangerous in another.
|
||||
|
||||
Examples:
|
||||
|
||||
- HTML body rendering
|
||||
- HTML attribute rendering
|
||||
- JavaScript string interpolation
|
||||
- URL query construction
|
||||
- SQL statements
|
||||
- shell commands
|
||||
- file paths
|
||||
- email templates
|
||||
- logs
|
||||
|
||||
There is no single transformation that makes arbitrary input safe for every one of those interpreters.
|
||||
That is why the repository direction is:
|
||||
|
||||
- centralize the **policy and approved APIs by sink category**
|
||||
- use vetted libraries and safe framework primitives
|
||||
- keep the handling context-specific
|
||||
- prefer parameterized APIs where available instead of string escaping
|
||||
|
||||
This produces a better form of centralization:
|
||||
not “one sanitizer for everything,” but “one documented, reusable, approved strategy per sink class.”
|
||||
|
||||
### 6.1 Why generic sanitization is risky
|
||||
|
||||
A universal sanitizer encourages developers to think:
|
||||
|
||||
> this string is now clean forever
|
||||
|
||||
But security is not a permanent property of the raw string.
|
||||
It depends on how that data is used.
|
||||
|
||||
For example:
|
||||
|
||||
- SQL injection is best prevented with parameterized queries, not a magical sanitized string
|
||||
- XSS prevention depends on the output context and framework sink
|
||||
- shell safety depends on command construction strategy, not generic string cleanup
|
||||
- path safety depends on path semantics and allowlists, not generic character stripping
|
||||
|
||||
The right abstraction is usually not `SanitizedString`.
|
||||
It is more often one of:
|
||||
|
||||
- a parsed domain type
|
||||
- a context-specific safe output operation
|
||||
- a parameterized call into a safer API
|
||||
- a narrowly defined value object such as `EmailAddress`, `Url`, `Slug`, or `SqlIdentifier`
|
||||
|
||||
### 6.2 What gets centralized instead
|
||||
|
||||
Although a generic sanitizer was rejected, the project still benefits from centralization in these forms:
|
||||
|
||||
- a documented catalog of sink categories and approved defenses
|
||||
- reusable utilities for context-specific escaping where that is the correct defense
|
||||
- preferred libraries such as HTML sanitizers instead of handwritten logic
|
||||
- review rules that force the agent to inspect sink handling explicitly
|
||||
|
||||
This balances consistency with correctness.
|
||||
|
||||
## 7. Why capability scope and injected authority matter even in functional code
|
||||
|
||||
A major theme of the new docs is that purity is not the same thing as least privilege.
|
||||
This is an easy place for confusion.
|
||||
|
||||
In a functional architecture, code often looks clean because:
|
||||
|
||||
- dependencies are injected explicitly
|
||||
- data is immutable
|
||||
- workflows compose functions rather than mutating shared objects
|
||||
|
||||
Those are valuable properties.
|
||||
But they do not automatically limit runtime authority.
|
||||
|
||||
A workflow can still receive a capability that is broader than it needs.
|
||||
For example, a workflow that only needs to read data might be handed a service that can:
|
||||
|
||||
- read data
|
||||
- write data
|
||||
- delete data
|
||||
- access unrelated network systems
|
||||
- administer resources
|
||||
|
||||
The code is still functional.
|
||||
It is also still over-privileged.
|
||||
|
||||
That is why the review language emphasizes **capabilities** and **injected authority**.
|
||||
What matters is not only how pure the call graph is, but also what real-world powers the injected service represents.
|
||||
|
||||
### 7.1 What “injected authority” means here
|
||||
|
||||
Injected authority is the runtime power available through a dependency.
|
||||
This may include:
|
||||
|
||||
- database credentials with read/write/admin access
|
||||
- tokens that can call external APIs
|
||||
- filesystem access
|
||||
- shell execution access
|
||||
- network reachability to sensitive systems
|
||||
|
||||
The concern is not object-oriented statefulness.
|
||||
It is authority.
|
||||
A pure function can still orchestrate a dangerously powerful adapter.
|
||||
|
||||
### 7.2 Why small capabilities reduce blast radius
|
||||
|
||||
Narrow capabilities make compromise more containable.
|
||||
If a workflow only receives what it genuinely needs, then:
|
||||
|
||||
- accidental misuse is harder
|
||||
- malicious use has fewer options
|
||||
- review becomes more precise
|
||||
- tests can assert the intended seam more clearly
|
||||
|
||||
This is where the architecture helps.
|
||||
The repository’s use of explicit interfaces and workflow seams makes it easier to design small, purpose-specific capabilities rather than god-objects or god-services.
|
||||
The architecture does not guarantee least privilege automatically, but it makes it much easier to enforce.
|
||||
|
||||
## 8. Why blast radius is treated as a design concern, not just an ops concern
|
||||
|
||||
Blast radius is often discussed only in operational terms such as network segmentation, container boundaries, or IAM roles.
|
||||
Those matter, but application design also shapes blast radius.
|
||||
|
||||
Examples of design choices that affect blast radius:
|
||||
|
||||
- whether workflows depend on broad or narrow capabilities
|
||||
- whether dangerous sinks are concentrated and reviewable
|
||||
- whether secrets are globally available or tightly scoped
|
||||
- whether untrusted data becomes trusted only after explicit parsing
|
||||
- whether the system documents recovery and detection expectations
|
||||
|
||||
By reviewing blast radius at design time, the repository encourages contributors to ask:
|
||||
|
||||
- If this adapter or workflow is compromised, what else can it reach?
|
||||
- If this parsing boundary fails, where can unsafe data flow?
|
||||
- If this secret leaks, what authority does it grant?
|
||||
- If this workflow is abused, what bounded surface limits impact?
|
||||
|
||||
These are not only deployment questions.
|
||||
They are architectural questions.
|
||||
|
||||
## 9. Why manual activation was chosen
|
||||
|
||||
The new skills are manual rather than automatically triggered.
|
||||
This was chosen for two reasons.
|
||||
|
||||
### 9.1 Security review should be intentional
|
||||
|
||||
Security review can be expensive in both time and attention.
|
||||
Auto-triggering it on every related phrase would create noise and reduce trust in the output.
|
||||
Manual invocation makes the act deliberate.
|
||||
|
||||
### 9.2 Scope often needs clarification
|
||||
|
||||
Security review quality depends heavily on scope.
|
||||
A reviewer should know whether it is inspecting:
|
||||
|
||||
- only a single workflow design
|
||||
- a set of changed files
|
||||
- the adjacent seams
|
||||
- the wider project surface
|
||||
|
||||
Forcing manual activation makes it more natural to start with a short “grill the human” step if the requested scope is unclear.
|
||||
|
||||
## 10. Why the implementation review defaults to changed files plus adjacent seams
|
||||
|
||||
Implementation review can become expensive and noisy if it always expands to the whole repository.
|
||||
Most feature work changes a narrow slice.
|
||||
That means the security review should usually focus on:
|
||||
|
||||
- the changed workflow
|
||||
- touched policies, models, interfaces, services, layers, and tests
|
||||
- the nearby seam where risk actually appears
|
||||
|
||||
This keeps the review useful and proportionate.
|
||||
|
||||
Expanded-surface review still exists for times when a change touches broader concerns such as:
|
||||
|
||||
- dependency updates
|
||||
- setup scripts
|
||||
- CI configuration
|
||||
- agent/MCP config
|
||||
- deployment-related settings
|
||||
|
||||
But that broader review is opt-in because it should match the real change surface.
|
||||
|
||||
## 11. Why external standards are referenced
|
||||
|
||||
The skills explicitly allow and encourage references to external standards such as OWASP ASVS and OWASP cheat sheets.
|
||||
This was chosen to avoid two bad extremes:
|
||||
|
||||
1. a repo-local security process that reinvents common security guidance badly
|
||||
2. a review process that cites standards so generically that it stops being useful
|
||||
|
||||
External standards help anchor findings in established practice.
|
||||
Repo-local guidance then adapts those standards to this architecture.
|
||||
|
||||
This gives contributors both:
|
||||
|
||||
- a principled external reference
|
||||
- a concrete local interpretation
|
||||
|
||||
## 12. What this means for future work
|
||||
|
||||
The current decisions create a foundation, not a final destination.
|
||||
Future work may include:
|
||||
|
||||
- documenting approved sink-specific handling patterns in more detail
|
||||
- adding threat-model sections to design artifacts
|
||||
- creating a package-version lookup tool for agents so they can find recent versions before pinning
|
||||
- expanding the review checklist with more operational security prompts
|
||||
- documenting secure capability design examples for workflows and adapters
|
||||
|
||||
The important point is that security review is now explicit.
|
||||
It is part of how the template thinks, not just a separate external pipeline.
|
||||
|
||||
## 13. Short summary
|
||||
|
||||
The repository added security verification because architecture alone is not enough.
|
||||
It chose verification gates instead of a new numbered phase because security review evaluates artifacts rather than defining domain meaning.
|
||||
It split the work into design and implementation skills because they operate with different evidence and different goals.
|
||||
It made those skills review-only to preserve human judgment.
|
||||
It rejected a generic sanitizer in favor of sink-specific safe handling backed by centralized policy and approved APIs.
|
||||
And it emphasized narrow capabilities and injected authority because functional purity does not automatically provide least privilege.
|
||||
|
||||
These decisions aim to make the template more secure without making it vague, magical, or auto-patching by assumption.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Adapter Strategy: Public APIs, Adapters, and Registries
|
||||
|
||||
## The Decision
|
||||
In the bounded-context direction, the main seam is not a global domain-layer interface tree.
|
||||
The first seam is the **context's public API**.
|
||||
But inside a bounded context, **interfaces/capabilities are still a crucial seam** for isolating pure logic from effectful or replaceable collaborators and for keeping workflows reviewable.
|
||||
Inside or behind the public API, contexts may define capabilities, adapters, and optional registries as needed.
|
||||
|
||||
**Vocabulary Note:** In Effect, a "Layer" is a *constructor/provider* for an implementation, not the interface itself. `PostgresLive` is a Layer that constructs and provides the `Postgres` implementation.
|
||||
|
||||
## The Reasoning
|
||||
|
||||
### 1. The public API is the boundary seam
|
||||
Other contexts and top-level workflows should not know a context's internal files.
|
||||
They should call the context through a public entrypoint that expresses **intent** in that context's language.
|
||||
|
||||
That public API may internally depend on narrower capabilities, but those are not throwaway details.
|
||||
They are often the key seam that lets a context keep policy and workflow code stable while implementations and collaborators change behind it.
|
||||
|
||||
### 2. Interfaces are still the internal seam
|
||||
Domain logic should not know about `Stripe` or `Neo4j`.
|
||||
It should speak in the language of the owning context, while interfaces/capabilities define what collaborators can do without leaking implementation details.
|
||||
Adapters or other implementations then satisfy those capabilities.
|
||||
|
||||
So the preferred shape is:
|
||||
- **Public context API**: what other code may call
|
||||
- **Local capability/interface seam**: the contract the context depends on internally
|
||||
- **Adapter**: concrete implementation
|
||||
- **Layer**: wiring/provider
|
||||
|
||||
When a context talks to storage, external APIs, queues, filesystems, time, identity, configuration, feature flags, or other collaborators, an internal interface is often the thing that keeps:
|
||||
- workflows focused on orchestration instead of concrete implementation details
|
||||
- policy and model code free of effectful collaborator concerns
|
||||
- tests able to swap implementations cleanly
|
||||
- collaborator churn from rewriting domain logic
|
||||
|
||||
### 3. The naming problem still exists
|
||||
- **Bad**: another context imports `billing/adapters/StripeAdapter` directly
|
||||
- **Bad**: broad vague names like `SmallPaymentService`
|
||||
- **Good**: a top-level workflow calls the billing context's public API, and the billing context hides its wiring details
|
||||
|
||||
### 4. Strategy pattern and registries
|
||||
Sometimes implementation selection depends on domain data.
|
||||
That still does not belong in an adapter, and it should not be scattered through the workflow graph.
|
||||
|
||||
Use a registry only when dynamic runtime selection is a real seam:
|
||||
1. **Local policy** decides a strategy value
|
||||
2. **Registry** maps that strategy to an implementation of a capability/interface
|
||||
3. **Workflow or context API** asks the registry for the implementation
|
||||
|
||||
Registries are optional.
|
||||
Do not create them by default when direct wiring is enough.
|
||||
|
||||
## Practical takeaway
|
||||
Prefer this order of thought:
|
||||
1. What is the public intent API for this context?
|
||||
2. What capability/interface seams are needed behind it?
|
||||
3. Which adapters implement those capabilities?
|
||||
4. Is a registry actually needed, or would it be architecture furniture?
|
||||
|
||||
The important correction is: **bounded contexts replaced the global interface tree as the repository map, but they did not replace interfaces as a crucial internal seam.**
|
||||
@@ -0,0 +1,60 @@
|
||||
# Type-Driven Design & Rich Domain Models
|
||||
|
||||
## The Philosophy
|
||||
We follow the "Make Illegal States Unrepresentable" philosophy from Scott Wlaschin's *Domain Modeling Made Functional*.
|
||||
Instead of writing runtime checks for everything, we use the Type System to ensure that if code compiles, the data is likely valid.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### 1. Branded Types (Single-Case Union Models)
|
||||
Never use bare primitives for domain concepts. `string` is too broad for an Email. `number` is too dangerous for a Price.
|
||||
|
||||
- **Bad**: `const sendEmail = (to: string, body: string)` (Easy to mix up arguments)
|
||||
- **Good**: `const sendEmail = (to: Email, body: Body)`
|
||||
|
||||
In Effect, we use `Schema.brand` to create these distinct types with zero runtime overhead after validation.
|
||||
|
||||
### 2. Making Illegal States Unrepresentable
|
||||
If a User cannot have a "PaidDate" unless they have a "PaidStatus", do not make `paidDate` optional on the generic User. Create two types.
|
||||
|
||||
- **Bad**:
|
||||
```typescript
|
||||
type Order = {
|
||||
state: 'Unpaid' | 'Paid';
|
||||
paidAt?: Date; // Ambiguous: Can I have an Unpaid order with a paidAt date?
|
||||
}
|
||||
```
|
||||
|
||||
- **Good**:
|
||||
```typescript
|
||||
type UnpaidOrder = { state: 'Unpaid' }
|
||||
type PaidOrder = { state: 'Paid', paidAt: Date }
|
||||
type Order = UnpaidOrder | PaidOrder
|
||||
```
|
||||
Now it is *impossible* to access `paidAt` on an Unpaid order. The compiler forces you to check the state first.
|
||||
|
||||
### 3. Parse, Don't Validate
|
||||
"Validation" implies checking a value and returning true/false, but keeping the original untrusted type (e.g., `string`).
|
||||
"Parsing" implies checking a value and returning a *new, trusted type* (e.g., `Email`).
|
||||
|
||||
- **Workflow**:
|
||||
1. Receive `unknown` input.
|
||||
2. **Parse** it into a Domain Type (`Schema.decode`).
|
||||
3. Pass the Domain Type to your logic.
|
||||
4. Your logic never handles validation errors; it trusts the type.
|
||||
|
||||
### 4. The "Always Valid" Domain Model
|
||||
Functions in the `domain/` layer should generally assume their inputs are valid.
|
||||
- `calculateTotal(cart: Cart)`: Assumes `Cart` is a valid structure.
|
||||
- Validation happens at the *edges* (in the Workflow or Input Adapters), converting `RawJson` -> `Cart`.
|
||||
|
||||
### 5. Pipeline States (Intermediate Types)
|
||||
In Workflows, use specific types to represent the progress of a process. This ensures that steps cannot be executed out of order. You cannot "Ship" an order that hasn't been "Paid" because the `ship()` function demands a `PaidOrder` type, which is only created by the `pay()` function.
|
||||
|
||||
- **Pattern**:
|
||||
```typescript
|
||||
validateOrder(order: UnvalidatedOrder): ValidatedOrder // ensures address exists
|
||||
priceOrder(order: ValidatedOrder): PricedOrder // calculates taxes/totals
|
||||
makePayment(order: PricedOrder): PaidOrder // confirms transaction
|
||||
shipOrder(order: PaidOrder): ShippingConfirmation
|
||||
```
|
||||
Reference in New Issue
Block a user