diff --git a/.github/prompts/openspec-apply.prompt.md b/.github/prompts/openspec-apply.prompt.md new file mode 100644 index 0000000..c964ead --- /dev/null +++ b/.github/prompts/openspec-apply.prompt.md @@ -0,0 +1,22 @@ +--- +description: Implement an approved OpenSpec change and keep tasks in sync. +--- + +$ARGUMENTS + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. + +**Steps** +Track these steps as TODOs and complete them one by one. +1. Read `changes//proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria. +2. Work through tasks sequentially, keeping edits minimal and focused on the requested change. +3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished. +4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality. +5. Reference `openspec list` or `openspec show ` when additional context is required. + +**Reference** +- Use `openspec show --json --deltas-only` if you need additional context from the proposal while implementing. + diff --git a/.github/prompts/openspec-archive.prompt.md b/.github/prompts/openspec-archive.prompt.md new file mode 100644 index 0000000..d7440aa --- /dev/null +++ b/.github/prompts/openspec-archive.prompt.md @@ -0,0 +1,26 @@ +--- +description: Archive a deployed OpenSpec change and update specs. +--- + +$ARGUMENTS + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. + +**Steps** +1. Determine the change ID to archive: + - If this prompt already includes a specific change ID (for example inside a `` block populated by slash-command arguments), use that value after trimming whitespace. + - If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends. + - Otherwise, review the conversation, run `openspec list`, and ask the user which change to archive; wait for a confirmed change ID before proceeding. + - If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet. +2. Validate the change ID by running `openspec list` (or `openspec show `) and stop if the change is missing, already archived, or otherwise not ready to archive. +3. Run `openspec archive --yes` so the CLI moves the change and applies spec updates without prompts (use `--skip-specs` only for tooling-only work). +4. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`. +5. Validate with `openspec validate --strict --no-interactive` and inspect with `openspec show ` if anything looks off. + +**Reference** +- Use `openspec list` to confirm change IDs before archiving. +- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off. + diff --git a/.github/prompts/openspec-proposal.prompt.md b/.github/prompts/openspec-proposal.prompt.md new file mode 100644 index 0000000..2ec0172 --- /dev/null +++ b/.github/prompts/openspec-proposal.prompt.md @@ -0,0 +1,27 @@ +--- +description: Scaffold a new OpenSpec change and validate strictly. +--- + +$ARGUMENTS + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. +- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files. +- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval. + +**Steps** +1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification. +2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes//`. +3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing. +4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs. +5. Draft spec deltas in `changes//specs//spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant. +6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work. +7. Validate with `openspec validate --strict --no-interactive` and resolve every issue before sharing the proposal. + +**Reference** +- Use `openspec show --json --deltas-only` or `openspec show --type spec` to inspect details when validation fails. +- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones. +- Explore the codebase with `rg `, `ls`, or direct file reads so proposals align with current implementation realities. + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e7ff89a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + pull_request: + push: + branches: + - develop + - main + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Dart + uses: dart-lang/setup-dart@v1 + + - name: Install workspace deps + run: dart pub get + + - name: Bootstrap + run: dart run melos bootstrap + + - name: Format (check) + run: dart format --output=none --set-exit-if-changed . + + - name: Analyze + run: dart run melos run analyze --no-select + + - name: Test + run: dart run melos run test --no-select diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..8d5c217 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,53 @@ +name: Publish + +on: + workflow_dispatch: + push: + tags: + - "v*.*.*" + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Dart + uses: dart-lang/setup-dart@v1 + + - name: Install workspace deps + run: dart pub get + + - name: Bootstrap + run: dart run melos bootstrap + + - name: Format (check) + run: dart format --output=none --set-exit-if-changed . + + - name: Analyze + run: dart run melos run analyze --no-select + + - name: Test + run: dart run melos run test --no-select + + - name: Configure pub.dev credentials + env: + PUB_CREDENTIALS: ${{ secrets.PUB_CREDENTIALS }} + run: | + test -n "$PUB_CREDENTIALS" + mkdir -p "$HOME/.config/dart" + printf '%s' "$PUB_CREDENTIALS" > "$HOME/.config/dart/pub-credentials.json" + + - name: Publish packages + run: | + # Publish in workspace dependency order (dependencies first). + # melos publish is dry-run by default; we explicitly publish. + dart run melos publish --no-private --no-dry-run --yes diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd5eb98 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..4c6dc11 --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "8b872868494e429d94fa06dca855c306438b22c0" + channel: "stable" + +project_type: package diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0669699 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ + +# OpenSpec Instructions + +These instructions are for AI assistants working in this project. + +Always open `@/openspec/AGENTS.md` when the request: +- Mentions planning or proposals (words like proposal, spec, change, plan) +- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work +- Sounds ambiguous and you need the authoritative spec before coding + +Use `@/openspec/AGENTS.md` to learn: +- How to create and apply change proposals +- Spec format and conventions +- Project structure and guidelines + +Keep this managed block so 'openspec update' can refresh the instructions. + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ecedfbb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Strongly-typed identity primitives (`Identity` interface, `TypedIdentity` class) +- Value object support with structural equality (`ValueObject` mixin) +- Entity mixin with identity-based equality +- Aggregate root mixin with domain event collection +- Domain event interface (`DomainEvent`) +- Event collection methods on aggregates (record, retrieve, clear) +- Pure Dart package with no infrastructure dependencies + diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed8c9d8 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# bounded workspace + +This repository is a Melos-managed Dart workspace. + +## Packages + +- `packages/bounded`: The core Domain-Driven Design (DDD) common kernel package (published as `bounded`). +- `packages/bounded_lints`: Custom analyzer lints for DDD/domain design principles (published as `bounded_lints`). + +## Development + +From the repository root: + +- Bootstrap: `dart run melos bootstrap` +- Test all packages: `dart run melos run test` +- Analyze all packages: `dart run melos run analyze` + +For usage instructions, see each package's README. + diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..1353d31 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,76 @@ +include: package:lints/recommended.yaml + +formatter: + # Keep trailing commas. + trailing_commas: preserve + + # Set the maximum line length. + page_width: 160 + +# Customize additional linter rules. +linter: + # Specify rules to be enabled or disabled. + rules: + # **Style Rules** + # Enforce using const constructors where possible. + prefer_const_constructors: true + prefer_const_literals_to_create_immutables: true + + # Prefer single quotes over double quotes where possible. + prefer_single_quotes: true + + # Enforce a consistent type definition for variables. + always_specify_types: false + + # **Best Practices** + # Avoid using dynamic types. + avoid_dynamic_calls: true + + # Avoid using print statements in production code. + avoid_print: true + + # Prefer using the `final` keyword for variables that are not reassigned. + prefer_final_fields: true + prefer_final_locals: true + + # **Error Prevention** + + # Enforce non-nullable types where possible. + always_require_non_null_named_parameters: true + + # **Documentation** + # Require documentation for public members. + public_member_api_docs: false + + # **Other Useful Rules** + # Enforce sorting of directives (e.g., imports). + directives_ordering: true + + # Prefer using `isEmpty` and `isNotEmpty` over `length` comparisons. + prefer_is_empty: true + prefer_is_not_empty: true + + # **Disable Rules (if necessary)** + # Uncomment the following lines to disable specific lints. + # avoid_unused_constructor_parameters: false + # unnecessary_null_in_if_null_operators: false + +# Analyzer settings. +analyzer: + # Exclude certain files or directories from analysis. + exclude: + - "**/*.g.dart" + - "**/build/**" + - "**/generated/**" + - "**/mocks/**" + - "**/*.freezed.dart/**" + + # Language-specific analyzer strictness options. + language: + strict-casts: false + strict-inference: false + strict-raw-types: false + + strong-mode: + implicit-casts: false + implicit-dynamic: false \ No newline at end of file diff --git a/openspec/AGENTS.md b/openspec/AGENTS.md new file mode 100644 index 0000000..6c1703e --- /dev/null +++ b/openspec/AGENTS.md @@ -0,0 +1,456 @@ +# OpenSpec Instructions + +Instructions for AI coding assistants using OpenSpec for spec-driven development. + +## TL;DR Quick Checklist + +- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search) +- Decide scope: new capability vs modify existing capability +- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`) +- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability +- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement +- Validate: `openspec validate [change-id] --strict --no-interactive` and fix issues +- Request approval: Do not start implementation until proposal is approved + +## Three-Stage Workflow + +### Stage 1: Creating Changes +Create proposal when you need to: +- Add features or functionality +- Make breaking changes (API, schema) +- Change architecture or patterns +- Optimize performance (changes behavior) +- Update security patterns + +Triggers (examples): +- "Help me create a change proposal" +- "Help me plan a change" +- "Help me create a proposal" +- "I want to create a spec proposal" +- "I want to create a spec" + +Loose matching guidance: +- Contains one of: `proposal`, `change`, `spec` +- With one of: `create`, `plan`, `make`, `start`, `help` + +Skip proposal for: +- Bug fixes (restore intended behavior) +- Typos, formatting, comments +- Dependency updates (non-breaking) +- Configuration changes +- Tests for existing behavior + +**Workflow** +1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context. +2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes//`. +3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement. +4. Run `openspec validate --strict --no-interactive` and resolve any issues before sharing the proposal. + +### Stage 2: Implementing Changes +Track these steps as TODOs and complete them one by one. +1. **Read proposal.md** - Understand what's being built +2. **Read design.md** (if exists) - Review technical decisions +3. **Read tasks.md** - Get implementation checklist +4. **Implement tasks sequentially** - Complete in order +5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses +6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality +7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved + +### Stage 3: Archiving Changes +After deployment, create separate PR to: +- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/` +- Update `specs/` if capabilities changed +- Use `openspec archive --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly) +- Run `openspec validate --strict --no-interactive` to confirm the archived change passes checks + +## Before Any Task + +**Context Checklist:** +- [ ] Read relevant specs in `specs/[capability]/spec.md` +- [ ] Check pending changes in `changes/` for conflicts +- [ ] Read `openspec/project.md` for conventions +- [ ] Run `openspec list` to see active changes +- [ ] Run `openspec list --specs` to see existing capabilities + +**Before Creating Specs:** +- Always check if capability already exists +- Prefer modifying existing specs over creating duplicates +- Use `openspec show [spec]` to review current state +- If request is ambiguous, ask 1–2 clarifying questions before scaffolding + +### Search Guidance +- Enumerate specs: `openspec spec list --long` (or `--json` for scripts) +- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available) +- Show details: + - Spec: `openspec show --type spec` (use `--json` for filters) + - Change: `openspec show --json --deltas-only` +- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs` + +## Quick Start + +### CLI Commands + +```bash +# Essential commands +openspec list # List active changes +openspec list --specs # List specifications +openspec show [item] # Display change or spec +openspec validate [item] # Validate changes or specs +openspec archive [--yes|-y] # Archive after deployment (add --yes for non-interactive runs) + +# Project management +openspec init [path] # Initialize OpenSpec +openspec update [path] # Update instruction files + +# Interactive mode +openspec show # Prompts for selection +openspec validate # Bulk validation mode + +# Debugging +openspec show [change] --json --deltas-only +openspec validate [change] --strict --no-interactive +``` + +### Command Flags + +- `--json` - Machine-readable output +- `--type change|spec` - Disambiguate items +- `--strict` - Comprehensive validation +- `--no-interactive` - Disable prompts +- `--skip-specs` - Archive without spec updates +- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive) + +## Directory Structure + +``` +openspec/ +├── project.md # Project conventions +├── specs/ # Current truth - what IS built +│ └── [capability]/ # Single focused capability +│ ├── spec.md # Requirements and scenarios +│ └── design.md # Technical patterns +├── changes/ # Proposals - what SHOULD change +│ ├── [change-name]/ +│ │ ├── proposal.md # Why, what, impact +│ │ ├── tasks.md # Implementation checklist +│ │ ├── design.md # Technical decisions (optional; see criteria) +│ │ └── specs/ # Delta changes +│ │ └── [capability]/ +│ │ └── spec.md # ADDED/MODIFIED/REMOVED +│ └── archive/ # Completed changes +``` + +## Creating Change Proposals + +### Decision Tree + +``` +New request? +├─ Bug fix restoring spec behavior? → Fix directly +├─ Typo/format/comment? → Fix directly +├─ New feature/capability? → Create proposal +├─ Breaking change? → Create proposal +├─ Architecture change? → Create proposal +└─ Unclear? → Create proposal (safer) +``` + +### Proposal Structure + +1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique) + +2. **Write proposal.md:** +```markdown +# Change: [Brief description of change] + +## Why +[1-2 sentences on problem/opportunity] + +## What Changes +- [Bullet list of changes] +- [Mark breaking changes with **BREAKING**] + +## Impact +- Affected specs: [list capabilities] +- Affected code: [key files/systems] +``` + +3. **Create spec deltas:** `specs/[capability]/spec.md` +```markdown +## ADDED Requirements +### Requirement: New Feature +The system SHALL provide... + +#### Scenario: Success case +- **WHEN** user performs action +- **THEN** expected result + +## MODIFIED Requirements +### Requirement: Existing Feature +[Complete modified requirement] + +## REMOVED Requirements +### Requirement: Old Feature +**Reason**: [Why removing] +**Migration**: [How to handle] +``` +If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs//spec.md`—one per capability. + +4. **Create tasks.md:** +```markdown +## 1. Implementation +- [ ] 1.1 Create database schema +- [ ] 1.2 Implement API endpoint +- [ ] 1.3 Add frontend component +- [ ] 1.4 Write tests +``` + +5. **Create design.md when needed:** +Create `design.md` if any of the following apply; otherwise omit it: +- Cross-cutting change (multiple services/modules) or a new architectural pattern +- New external dependency or significant data model changes +- Security, performance, or migration complexity +- Ambiguity that benefits from technical decisions before coding + +Minimal `design.md` skeleton: +```markdown +## Context +[Background, constraints, stakeholders] + +## Goals / Non-Goals +- Goals: [...] +- Non-Goals: [...] + +## Decisions +- Decision: [What and why] +- Alternatives considered: [Options + rationale] + +## Risks / Trade-offs +- [Risk] → Mitigation + +## Migration Plan +[Steps, rollback] + +## Open Questions +- [...] +``` + +## Spec File Format + +### Critical: Scenario Formatting + +**CORRECT** (use #### headers): +```markdown +#### Scenario: User login success +- **WHEN** valid credentials provided +- **THEN** return JWT token +``` + +**WRONG** (don't use bullets or bold): +```markdown +- **Scenario: User login** ❌ +**Scenario**: User login ❌ +### Scenario: User login ❌ +``` + +Every requirement MUST have at least one scenario. + +### Requirement Wording +- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative) + +### Delta Operations + +- `## ADDED Requirements` - New capabilities +- `## MODIFIED Requirements` - Changed behavior +- `## REMOVED Requirements` - Deprecated features +- `## RENAMED Requirements` - Name changes + +Headers matched with `trim(header)` - whitespace ignored. + +#### When to use ADDED vs MODIFIED +- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement. +- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details. +- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name. + +Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead. + +Authoring a MODIFIED requirement correctly: +1) Locate the existing requirement in `openspec/specs//spec.md`. +2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios). +3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior. +4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`. + +Example for RENAMED: +```markdown +## RENAMED Requirements +- FROM: `### Requirement: Login` +- TO: `### Requirement: User Authentication` +``` + +## Troubleshooting + +### Common Errors + +**"Change must have at least one delta"** +- Check `changes/[name]/specs/` exists with .md files +- Verify files have operation prefixes (## ADDED Requirements) + +**"Requirement must have at least one scenario"** +- Check scenarios use `#### Scenario:` format (4 hashtags) +- Don't use bullet points or bold for scenario headers + +**Silent scenario parsing failures** +- Exact format required: `#### Scenario: Name` +- Debug with: `openspec show [change] --json --deltas-only` + +### Validation Tips + +```bash +# Always use strict mode for comprehensive checks +openspec validate [change] --strict --no-interactive + +# Debug delta parsing +openspec show [change] --json | jq '.deltas' + +# Check specific requirement +openspec show [spec] --json -r 1 +``` + +## Happy Path Script + +```bash +# 1) Explore current state +openspec spec list --long +openspec list +# Optional full-text search: +# rg -n "Requirement:|Scenario:" openspec/specs +# rg -n "^#|Requirement:" openspec/changes + +# 2) Choose change id and scaffold +CHANGE=add-two-factor-auth +mkdir -p openspec/changes/$CHANGE/{specs/auth} +printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md +printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md + +# 3) Add deltas (example) +cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF' +## ADDED Requirements +### Requirement: Two-Factor Authentication +Users MUST provide a second factor during login. + +#### Scenario: OTP required +- **WHEN** valid credentials are provided +- **THEN** an OTP challenge is required +EOF + +# 4) Validate +openspec validate $CHANGE --strict --no-interactive +``` + +## Multi-Capability Example + +``` +openspec/changes/add-2fa-notify/ +├── proposal.md +├── tasks.md +└── specs/ + ├── auth/ + │ └── spec.md # ADDED: Two-Factor Authentication + └── notifications/ + └── spec.md # ADDED: OTP email notification +``` + +auth/spec.md +```markdown +## ADDED Requirements +### Requirement: Two-Factor Authentication +... +``` + +notifications/spec.md +```markdown +## ADDED Requirements +### Requirement: OTP Email Notification +... +``` + +## Best Practices + +### Simplicity First +- Default to <100 lines of new code +- Single-file implementations until proven insufficient +- Avoid frameworks without clear justification +- Choose boring, proven patterns + +### Complexity Triggers +Only add complexity with: +- Performance data showing current solution too slow +- Concrete scale requirements (>1000 users, >100MB data) +- Multiple proven use cases requiring abstraction + +### Clear References +- Use `file.ts:42` format for code locations +- Reference specs as `specs/auth/spec.md` +- Link related changes and PRs + +### Capability Naming +- Use verb-noun: `user-auth`, `payment-capture` +- Single purpose per capability +- 10-minute understandability rule +- Split if description needs "AND" + +### Change ID Naming +- Use kebab-case, short and descriptive: `add-two-factor-auth` +- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-` +- Ensure uniqueness; if taken, append `-2`, `-3`, etc. + +## Tool Selection Guide + +| Task | Tool | Why | +|------|------|-----| +| Find files by pattern | Glob | Fast pattern matching | +| Search code content | Grep | Optimized regex search | +| Read specific files | Read | Direct file access | +| Explore unknown scope | Task | Multi-step investigation | + +## Error Recovery + +### Change Conflicts +1. Run `openspec list` to see active changes +2. Check for overlapping specs +3. Coordinate with change owners +4. Consider combining proposals + +### Validation Failures +1. Run with `--strict` flag +2. Check JSON output for details +3. Verify spec file format +4. Ensure scenarios properly formatted + +### Missing Context +1. Read project.md first +2. Check related specs +3. Review recent archives +4. Ask for clarification + +## Quick Reference + +### Stage Indicators +- `changes/` - Proposed, not yet built +- `specs/` - Built and deployed +- `archive/` - Completed changes + +### File Purposes +- `proposal.md` - Why and what +- `tasks.md` - Implementation steps +- `design.md` - Technical decisions +- `spec.md` - Requirements and behavior + +### CLI Essentials +```bash +openspec list # What's in progress? +openspec show [item] # View details +openspec validate --strict --no-interactive # Is it correct? +openspec archive [--yes|-y] # Mark complete (add --yes for automation) +``` + +Remember: Specs are truth. Changes are proposals. Keep them in sync. diff --git a/openspec/changes/add-domain-event-traits/proposal.md b/openspec/changes/add-domain-event-traits/proposal.md new file mode 100644 index 0000000..703538b --- /dev/null +++ b/openspec/changes/add-domain-event-traits/proposal.md @@ -0,0 +1,18 @@ +# Change: Add domain event traits + +## Why +Consumers frequently need to attach *cross-cutting* information to domain events (a stable identifier, an occurrence timestamp, and optional metadata). Today, each package re-implements these small contracts. + +Adding a small set of optional interfaces keeps event handling code consistent across the ecosystem while keeping `bounded` infrastructure-free. + +## What Changes +- Add optional domain-event traits: + - `IdentifiedDomainEvent` + - `TimestampedDomainEvent` + - `MetadataDomainEvent` +- Add a convenience combined interface `BoundedDomainEvent` that composes the above traits. + +## Impact +- Affected specs: `domain-events` +- Affected code: `lib/src/domain_event.dart`, tests under `test/` +- Compatibility: additive only (no breaking API changes) diff --git a/openspec/changes/add-domain-event-traits/specs/domain-events/spec.md b/openspec/changes/add-domain-event-traits/specs/domain-events/spec.md new file mode 100644 index 0000000..34a35fe --- /dev/null +++ b/openspec/changes/add-domain-event-traits/specs/domain-events/spec.md @@ -0,0 +1,31 @@ +# domain-events Specification (Delta) + +## ADDED Requirements + +### Requirement: Optional domain event traits +The system SHALL provide optional domain event interfaces for common cross-cutting concerns: +- stable identification +- occurrence timestamp +- arbitrary metadata + +These traits MUST be infrastructure-free and SHOULD be usable independently or in combination. + +#### Scenario: Defining an identified domain event +- **WHEN** a consumer defines a domain event that needs correlation across logs, buses, or storage +- **THEN** it can implement an interface that exposes a stable `id` property + +#### Scenario: Defining a timestamped domain event +- **WHEN** a consumer defines a domain event that needs ordering or auditing +- **THEN** it can implement an interface that exposes an `occurredOn` property + +#### Scenario: Defining a metadata-carrying domain event +- **WHEN** a consumer defines a domain event that needs non-domain metadata (e.g., correlation IDs) +- **THEN** it can implement an interface that exposes a `metadata` map + +### Requirement: Composed domain event contract +The system SHALL provide a convenience interface that combines the optional traits into a single contract. + +#### Scenario: Using a combined interface +- **GIVEN** a consumer prefers a single, consistent contract for events +- **WHEN** it defines an event +- **THEN** it can implement a composed interface that includes identification, timestamping, and metadata diff --git a/openspec/changes/add-domain-event-traits/tasks.md b/openspec/changes/add-domain-event-traits/tasks.md new file mode 100644 index 0000000..ca66bed --- /dev/null +++ b/openspec/changes/add-domain-event-traits/tasks.md @@ -0,0 +1,17 @@ +# Tasks: add-domain-event-traits + +## 1. Specification +- [x] Add requirements to `domain-events` for optional event traits (id/timestamp/metadata) and a composed interface. + +## 2. Implementation +- [x] Add `IdentifiedDomainEvent`, `TimestampedDomainEvent`, `MetadataDomainEvent`, and `BoundedDomainEvent` to `lib/src/domain_event.dart`. +- [x] Ensure all new types are exported via `package:bounded/bounded.dart` (should be automatic since it exports `src/domain_event.dart`). + +## 3. Tests +- [x] Add a new test file that validates the new interfaces compose correctly and are implementable. + +## 4. Docs +- [x] Update README event examples to show `BoundedDomainEvent` usage. + +## 5. Validation +- [x] Run unit tests. diff --git a/openspec/changes/add-workspace-and-domain-lints/design.md b/openspec/changes/add-workspace-and-domain-lints/design.md new file mode 100644 index 0000000..6e4bd51 --- /dev/null +++ b/openspec/changes/add-workspace-and-domain-lints/design.md @@ -0,0 +1,50 @@ +## Context +This repository currently ships a single Dart package (`bounded`). Adding ecosystem packages (such as static analysis rules) is easiest when the repo is a workspace with per-package boundaries. + +The requested linter capability is best delivered via Dart Analyzer-compatible tooling so violations appear directly in IDEs/CI. + +## Goals +- Introduce a `packages/` workspace layout managed by Melos. +- Keep the `bounded` runtime package small and infrastructure-free. +- Deliver a published lints package that warns about violations of domain design principles aligned with `bounded` concepts. + +## Non-Goals +- Building a full DDD framework or enforcing a specific architecture. +- Adding runtime checks or reflection. +- Providing auto-fixes. + +## Decisions +### Decision: Use `custom_lint` plugin architecture +`bounded_lints` will be implemented as a `custom_lint` plugin so: +- Lints run in editors and CI (`dart run custom_lint`). +- Rules can be authored with the analyzer AST and type system. + +### Decision: Workspace structure +The workspace will place all packages under `packages/`: +- `packages/bounded/` (existing package, moved) +- `packages/bounded_lints/` (new published lints plugin) + +Melos will manage bootstrap and scripts for consistent dev workflow. + +### Decision: Rule semantics (initial set) +Initial rules focus on high-signal, low-surprise DDD guidance: +- Value object immutability +- Domain event immutability (+ prefer `const`) +- Discourage recording events outside aggregate root instance methods + +Rules will be warnings by default. + +## Risks / Trade-offs +- AST/type analysis can introduce false positives if rules are too broad. + - Mitigation: scope checks to `bounded`-related types/markers, and provide configuration/exclusions. +- Repository restructuring is disruptive for contributors. + - Mitigation: keep package names and public APIs stable; document new workflow. + +## Migration Plan +- Move existing package content into `packages/bounded/` and update tooling paths. +- Add Melos workspace configuration and bootstrap scripts. +- Add `bounded_lints` package and validate it on a small example package in the workspace. + +## Open Questions +- Minimum supported Dart SDK for consumers of `bounded_lints` (align with `bounded` unless constraints require otherwise). +- Whether to add a small `packages/example/` package for documentation and lint verification, or rely purely on tests. diff --git a/openspec/changes/add-workspace-and-domain-lints/proposal.md b/openspec/changes/add-workspace-and-domain-lints/proposal.md new file mode 100644 index 0000000..e235485 --- /dev/null +++ b/openspec/changes/add-workspace-and-domain-lints/proposal.md @@ -0,0 +1,30 @@ +# Change: Add Melos workspace and domain lints package + +## Why +This repository currently contains a single package (`bounded`). As the ecosystem grows (tooling, analysis, and integrations), the repo needs a workspace layout that supports multiple related packages without diluting the core. + +In addition, consumers need fast feedback when their domain model violates key DDD design principles (immutability, explicit boundaries, and event recording discipline). + +## What Changes +- Convert the repository into a Melos-managed workspace with a `packages/` directory. +- Move the existing `bounded` package into `packages/bounded/`. +- Add a new published package `bounded_lints` that provides custom lints to warn about common domain design principle violations. + +## Impact +- Affected specs: + - New capability: `workspace` (tooling + repository layout) + - New capability: `domain-lints` (static analysis rules) + - Existing runtime capabilities remain unchanged: `identity`, `value-objects`, `entities-aggregates`, `domain-events` +- Affected code: + - Repository structure changes (source + tests move under `packages/bounded/`) + - New package added under `packages/bounded_lints/` +- **BREAKING (repository structure)**: paths for contributors and tooling change, but the published package name `bounded` and its public API remain the same. + +## Non-Goals +- Auto-fix support (quick fixes) for lint violations. +- Enforcing a specific architecture (CQRS, ES, hexagonal, etc.). +- Runtime enforcement; lints are advisory warnings. + +## Notes +- The lints are intended to be opt-in and consumer-facing (installed as a dev dependency). +- Default severity is warnings; projects may elevate to errors if desired. diff --git a/openspec/changes/add-workspace-and-domain-lints/specs/domain-lints/spec.md b/openspec/changes/add-workspace-and-domain-lints/specs/domain-lints/spec.md new file mode 100644 index 0000000..760ae05 --- /dev/null +++ b/openspec/changes/add-workspace-and-domain-lints/specs/domain-lints/spec.md @@ -0,0 +1,50 @@ +# domain-lints (Delta) Specification + +## ADDED Requirements + +### Requirement: Published custom lints package +The system SHALL provide a published package named `bounded_lints` that surfaces domain design principle violations as analyzer warnings. + +#### Scenario: Consumer installs bounded_lints +- **WHEN** a consumer adds `bounded_lints` and `custom_lint` as dev dependencies +- **AND** they enable custom_lint in their analysis workflow +- **THEN** domain principle violations are reported as warnings in IDEs and CI + +### Requirement: Value object immutability lint +The system SHALL warn when a class that mixes in `ValueObject` appears to be mutable. + +#### Scenario: Value object declares a mutable field +- **GIVEN** a class mixes in `ValueObject` +- **WHEN** it declares a non-final instance field or a setter +- **THEN** the analyzer reports a warning identifying the mutable member + +### Requirement: Domain event immutability lint +The system SHALL warn when a class implementing `DomainEvent` or `BoundedDomainEvent` appears to be mutable. + +#### Scenario: Domain event declares a setter +- **GIVEN** a class implements `DomainEvent` (directly or via `BoundedDomainEvent`) +- **WHEN** it declares a setter or a non-final instance field +- **THEN** the analyzer reports a warning identifying the mutable member + +### Requirement: Prefer const domain events +The system SHALL warn when a domain event type could reasonably be `const` but is not. + +#### Scenario: Domain event has only final fields but no const ctor +- **GIVEN** a domain event type with only final instance fields +- **WHEN** it has a non-const generative constructor +- **THEN** the analyzer reports a warning recommending a `const` constructor + +### Requirement: Discourage recording events outside aggregate transitions +The system SHALL warn when `recordEvent(...)` is invoked from outside an aggregate root’s own instance methods. + +#### Scenario: External service records an event on an aggregate +- **GIVEN** application-layer code calls `someAggregate.recordEvent(...)` +- **WHEN** the call occurs outside the declaring aggregate root type’s instance methods +- **THEN** the analyzer reports a warning recommending recording the event inside the aggregate transition + +### Requirement: Configuration and opt-out +The system SHALL allow consumers to disable individual rules and/or exclude files or paths from lint reporting. + +#### Scenario: Consumer disables a rule +- **WHEN** a consumer disables a specific `bounded_lints` rule in configuration +- **THEN** that rule no longer reports warnings in the consumer project diff --git a/openspec/changes/add-workspace-and-domain-lints/specs/workspace/spec.md b/openspec/changes/add-workspace-and-domain-lints/specs/workspace/spec.md new file mode 100644 index 0000000..7fd8d67 --- /dev/null +++ b/openspec/changes/add-workspace-and-domain-lints/specs/workspace/spec.md @@ -0,0 +1,28 @@ +# workspace (Delta) Specification + +## ADDED Requirements + +### Requirement: Melos workspace layout +The system SHALL organize this repository as a Melos-managed workspace with all packages located under a `packages/` directory. + +#### Scenario: Adding a new ecosystem package +- **GIVEN** the repository is a Melos-managed workspace +- **WHEN** a maintainer adds a new related package +- **THEN** it is created under `packages//` +- **AND** Melos discovers it via the workspace configuration + +### Requirement: `bounded` package remains publishable +The system SHALL keep `bounded` as a publishable package with the same public API and package name, even if its source is moved under `packages/bounded/`. + +#### Scenario: Consumer depends on bounded +- **WHEN** a consumer adds `bounded` as a dependency +- **THEN** they import `package:bounded/bounded.dart` +- **AND** the public API behaves consistently with the existing specs + +### Requirement: Workspace bootstrap commands +The system SHALL provide a consistent bootstrap and test workflow via Melos. + +#### Scenario: Developer sets up the repo +- **WHEN** a developer runs the workspace bootstrap command +- **THEN** all workspace packages have their dependencies resolved +- **AND** tests can be run for all packages with a single command diff --git a/openspec/changes/add-workspace-and-domain-lints/tasks.md b/openspec/changes/add-workspace-and-domain-lints/tasks.md new file mode 100644 index 0000000..b410a92 --- /dev/null +++ b/openspec/changes/add-workspace-and-domain-lints/tasks.md @@ -0,0 +1,22 @@ +## 1. Workspace conversion (Melos + packages/) +- [x] 1.1 Add Melos configuration (`melos.yaml`) defining packages under `packages/**` and standard scripts. +- [x] 1.2 Create a non-published workspace root `pubspec.yaml` (or adjust existing) to support workspace tooling. +- [x] 1.3 Move the existing `bounded` package into `packages/bounded/`. +- [x] 1.4 Update README and contributor workflow docs for the new workspace layout. +- [x] 1.5 Ensure `dart test` still passes for the `bounded` package (via Melos scripts). + +## 2. Add `bounded_lints` package (published) +- [x] 2.1 Create `packages/bounded_lints/` as a `custom_lint` plugin package. +- [x] 2.2 Implement initial lint rules: + - Value objects SHOULD be immutable (no setters / no non-final fields) when mixing `ValueObject`. + - Domain events SHOULD be immutable (no setters / no non-final fields) when implementing `DomainEvent`/`BoundedDomainEvent`. + - Domain events SHOULD prefer `const` constructors when possible. + - Code SHOULD NOT call `recordEvent(...)` from outside aggregate root instance methods (discourage external/event-service recording). +- [x] 2.3 Add configuration surface (enable/disable rules; allow excludes) consistent with `custom_lint` conventions. +- [x] 2.4 Add lint tests using the recommended `custom_lint_builder` testing approach. +- [x] 2.5 Add end-user documentation for installation and usage (README snippet + example `analysis_options.yaml`). + +## 3. Validation +- [x] 3.1 Run `melos bootstrap` to resolve workspace dependencies. +- [x] 3.2 Run `melos run test` (or equivalent) to validate behavior. +- [x] 3.3 Run `dart run custom_lint` against a small example package to verify warnings appear as intended. diff --git a/openspec/changes/archive/2026-01-21-add-core-domain-kernel/design.md b/openspec/changes/archive/2026-01-21-add-core-domain-kernel/design.md new file mode 100644 index 0000000..51d2218 --- /dev/null +++ b/openspec/changes/archive/2026-01-21-add-core-domain-kernel/design.md @@ -0,0 +1,37 @@ +## Context +`bounded` is the common kernel for a DDD ecosystem. It should be small, explicit, and free of infrastructure concerns. The goal is to define a stable set of primitives used by higher-level packages. + +## Goals +- Provide minimal building blocks for modeling domains: identities, value objects, entities/aggregates, domain events. +- Make common semantics explicit: equality, immutability expectations, event collection. +- Keep public APIs small, easy to test, and framework-agnostic. + +## Non-Goals +- Implement event sourcing, persistence, repositories, dispatchers, or transports. +- Provide code generation or reflection-based “magic”. + +## Decisions +### Decision: Strongly typed identities +Prefer identities tied to a domain type (e.g., per-aggregate) to reduce accidental mixing across bounded contexts. The concrete representation (String/UUID/int) stays an implementation detail. + +### Decision: Equality semantics by concept +- Value objects: structural equality based on their components. +- Entities/Aggregates: identity-based equality. + +### Decision: Aggregate event collection (not dispatch) +Aggregates record domain events as part of state transitions, but `bounded` does not dispatch them. This keeps the domain pure and allows event sourcing/publishers to integrate later. + +## Alternatives considered +- Provide only interfaces (no helpers): rejected because ergonomic helpers reduce boilerplate without adding infrastructure coupling. +- Provide an `Either/Result` and `Guard` library now: deferred; keep scope minimal until core primitives stabilize. + +## Risks / Trade-offs +- Too many helpers could bloat the kernel → mitigate by keeping a strict “minimal primitives” scope. +- Strong typing may add generics verbosity → mitigate with ergonomic constructors/typedefs (non-normative). + +## Migration plan +None (initial capability set). + +## Open questions +- Flutter vs pure Dart package target. +- Whether to include optional guard/result utilities in a follow-up change. diff --git a/openspec/changes/archive/2026-01-21-add-core-domain-kernel/proposal.md b/openspec/changes/archive/2026-01-21-add-core-domain-kernel/proposal.md new file mode 100644 index 0000000..0dcd7d2 --- /dev/null +++ b/openspec/changes/archive/2026-01-21-add-core-domain-kernel/proposal.md @@ -0,0 +1,34 @@ +# Change: Add core domain kernel + +## Why +`bounded` is intended to be a small, framework-agnostic common kernel for Domain-Driven Design (DDD). Today, the repository has no published specs and the public Dart entrypoint is empty, so downstream packages (event sourcing, event publishing, orchestration layers) have nothing stable to build on. + +This change defines the minimal, explicit domain modeling contracts and helpers needed to implement DDD-style architectures without coupling domain models to infrastructure. + +## What Changes +- Add initial OpenSpec capabilities for foundational DDD building blocks: + - Identity + - Value Objects + - Entities and Aggregates + - Domain Events +- Define requirements for contracts, semantics (equality, immutability), and event collection on aggregates. +- Define a minimal public API surface intended to remain stable and small. + +## Non-Goals +- Event sourcing, persistence, repositories +- Event dispatching / transport +- Framework integrations (Flutter, Riverpod, etc.) +- A prescribed architecture (CQRS/ES/microservices) + +## Impact +- Affected specs: new capabilities under `openspec/specs/` after implementation/approval. +- Affected code: will introduce a first implementation in `lib/` and export surface via `lib/bounded.dart`. + +## Assumptions +- Consumers want infrastructure-free primitives that are easy to unit test. +- Public APIs should prefer explicitness over “magic” code-gen. + +## Open Questions +- Should `bounded` remain a Flutter package, or become a pure Dart package (remove the Flutter SDK dependency) so it can be used on server/CLI as well? +- Should identity types be strongly typed per aggregate (recommended), or generic string/int wrappers only? +- Do we want optional ergonomic helpers (e.g., `Result`, `Either`, `Guard`) in this package, or keep strictly to DDD primitives? diff --git a/openspec/changes/archive/2026-01-21-add-core-domain-kernel/specs/domain-events/spec.md b/openspec/changes/archive/2026-01-21-add-core-domain-kernel/specs/domain-events/spec.md new file mode 100644 index 0000000..1eac296 --- /dev/null +++ b/openspec/changes/archive/2026-01-21-add-core-domain-kernel/specs/domain-events/spec.md @@ -0,0 +1,29 @@ +# Capability: Domain Events + +## ADDED Requirements + +### Requirement: Domain event contract +The system SHALL provide a domain event abstraction representing something that happened in the domain. + +#### Scenario: Defining a domain event +- **WHEN** a consumer defines a domain event +- **THEN** it can be stored, tested, and compared in a deterministic way + +### Requirement: Aggregate records events +The system SHALL allow aggregate roots to record domain events as part of successful state transitions. + +#### Scenario: Recording an event during a transition +- **WHEN** an aggregate transition succeeds +- **THEN** the aggregate records a domain event in an internal collection + +#### Scenario: Reading and clearing events +- **WHEN** application code reads an aggregate’s pending events +- **THEN** it receives a read-only view of the events +- **AND** it can clear the recorded events explicitly + +### Requirement: No dispatching +The system SHALL NOT dispatch, publish, or transport events. + +#### Scenario: Integrating with a dispatcher +- **WHEN** a consumer needs to publish events +- **THEN** they integrate with an external package or application layer code diff --git a/openspec/changes/archive/2026-01-21-add-core-domain-kernel/specs/entities-aggregates/spec.md b/openspec/changes/archive/2026-01-21-add-core-domain-kernel/specs/entities-aggregates/spec.md new file mode 100644 index 0000000..8b319db --- /dev/null +++ b/openspec/changes/archive/2026-01-21-add-core-domain-kernel/specs/entities-aggregates/spec.md @@ -0,0 +1,25 @@ +# Capability: Entities and Aggregates + +## ADDED Requirements + +### Requirement: Entity identity +The system SHALL provide an entity abstraction whose identity is represented by an identity type. + +#### Scenario: Entities compare by identity +- **WHEN** a consumer compares two entities with the same identity +- **THEN** they compare equal regardless of other state + +### Requirement: Aggregate root boundary +The system SHALL provide an aggregate root abstraction to model transactional consistency boundaries. + +#### Scenario: Aggregate root owns invariants +- **WHEN** a consumer models an aggregate root +- **THEN** state transitions SHOULD be implemented on the aggregate root +- **AND** invariants SHOULD be enforced within those transitions + +### Requirement: Explicit invariant enforcement +The system SHALL enable modeling invariants without infrastructure coupling. + +#### Scenario: Preventing illegal state +- **WHEN** a state transition would violate an invariant +- **THEN** the transition fails explicitly (e.g., by throwing a domain error or returning a failure type) diff --git a/openspec/changes/archive/2026-01-21-add-core-domain-kernel/specs/identity/spec.md b/openspec/changes/archive/2026-01-21-add-core-domain-kernel/specs/identity/spec.md new file mode 100644 index 0000000..3dcceec --- /dev/null +++ b/openspec/changes/archive/2026-01-21-add-core-domain-kernel/specs/identity/spec.md @@ -0,0 +1,23 @@ +# Capability: Identity + +## ADDED Requirements + +### Requirement: Typed identities +The system SHALL provide identity types that can be used to uniquely identify entities and aggregates without depending on infrastructure. + +#### Scenario: Creating and comparing identities +- **WHEN** a consumer creates two identity instances with the same underlying value +- **THEN** they compare equal +- **AND** they produce the same hash code + +#### Scenario: Prevent mixing unrelated identities +- **WHEN** a consumer uses identities for two different domain types +- **THEN** the type system SHOULD make accidental mixing difficult or impossible + +### Requirement: Safe, explicit representation +The system SHALL provide a safe way to expose identity values for debugging and logging. + +#### Scenario: Logging an identity +- **WHEN** a consumer logs an identity +- **THEN** the identity renders as a stable string representation +- **AND** it does not require reflection or runtime type metadata diff --git a/openspec/changes/archive/2026-01-21-add-core-domain-kernel/specs/value-objects/spec.md b/openspec/changes/archive/2026-01-21-add-core-domain-kernel/specs/value-objects/spec.md new file mode 100644 index 0000000..e4bc48f --- /dev/null +++ b/openspec/changes/archive/2026-01-21-add-core-domain-kernel/specs/value-objects/spec.md @@ -0,0 +1,25 @@ +# Capability: Value Objects + +## ADDED Requirements + +### Requirement: Structural equality +The system SHALL support value object modeling where equality is determined by the value object’s components rather than identity. + +#### Scenario: Two value objects with same components +- **WHEN** a consumer creates two value objects with identical component values +- **THEN** they compare equal +- **AND** they produce the same hash code + +### Requirement: Immutability by convention +The system SHALL provide guidance and/or base types that encourage value objects to be immutable. + +#### Scenario: Modeling an immutable value object +- **WHEN** a consumer defines a value object +- **THEN** typical usage SHOULD encourage final fields and no setters + +### Requirement: Explicit decomposition +The system SHALL allow a value object to declare which components participate in equality. + +#### Scenario: Equality uses declared components +- **WHEN** a value object declares its equality components +- **THEN** equality and hashing are based only on those components diff --git a/openspec/changes/archive/2026-01-21-add-core-domain-kernel/tasks.md b/openspec/changes/archive/2026-01-21-add-core-domain-kernel/tasks.md new file mode 100644 index 0000000..721bc6a --- /dev/null +++ b/openspec/changes/archive/2026-01-21-add-core-domain-kernel/tasks.md @@ -0,0 +1,22 @@ +# Tasks: Add core domain kernel + +## 1. Specs +- [x] 1.1 Add/validate capability delta specs under `openspec/changes/add-core-domain-kernel/specs/*/spec.md` +- [x] 1.2 Run `openspec validate add-core-domain-kernel --strict --no-interactive` + +## 2. Public API (Dart) +- [x] 2.1 Decide package target (Flutter vs pure Dart) and update `pubspec.yaml` accordingly +- [x] 2.2 Implement identity primitives per spec (typed IDs, equality, display/debug) +- [x] 2.3 Implement value object base/mixin per spec (immutability + structural equality) +- [x] 2.4 Implement entity + aggregate root primitives per spec (identity-based equality) +- [x] 2.5 Implement domain event contract and aggregate event collection per spec +- [x] 2.6 Export stable API from `lib/bounded.dart` + +## 3. Documentation +- [x] 3.1 Replace placeholder `README.md` with package purpose and minimal usage examples +- [x] 3.2 Update `CHANGELOG.md` initial release notes + +## 4. Tests +- [x] 4.1 Add unit tests for equality semantics (value objects vs entities) +- [x] 4.2 Add unit tests for aggregate domain event collection behavior +- [x] 4.3 Ensure `dart test` / `flutter test` passes (depending on package target) diff --git a/openspec/changes/archive/2026-01-21-add-pull-events-and-guards/proposal.md b/openspec/changes/archive/2026-01-21-add-pull-events-and-guards/proposal.md new file mode 100644 index 0000000..49f9abb --- /dev/null +++ b/openspec/changes/archive/2026-01-21-add-pull-events-and-guards/proposal.md @@ -0,0 +1,18 @@ +# Change: Add `pullEvents` and invariant guards + +## Why +Working with aggregate domain events often requires a “read then clear” sequence that is easy to forget or implement inconsistently. +Likewise, invariant enforcement is encouraged by the library but currently requires ad-hoc `if`/`throw` code everywhere. + +## What Changes +- Add an aggregate helper to atomically drain recorded domain events (`AggregateRoot.pullEvents()`). +- Add small, infrastructure-free guard helpers to make invariant enforcement explicit and consistent. + +## Impact +- Affected specs: `domain-events`, `entities-aggregates` +- Affected code: `lib/src/aggregate_root.dart`, new guard helper module exported from `lib/bounded.dart` + +## Non-Goals +- No event dispatching/publishing/transport. +- No persistence or repository abstractions. +- No framework dependencies. diff --git a/openspec/changes/archive/2026-01-21-add-pull-events-and-guards/specs/domain-events/spec.md b/openspec/changes/archive/2026-01-21-add-pull-events-and-guards/specs/domain-events/spec.md new file mode 100644 index 0000000..171a437 --- /dev/null +++ b/openspec/changes/archive/2026-01-21-add-pull-events-and-guards/specs/domain-events/spec.md @@ -0,0 +1,19 @@ +# Capability: Domain Events + +## ADDED Requirements + +### Requirement: Drain recorded events +The system SHALL provide an aggregate-level helper that returns all currently recorded domain events and clears them in a single operation. + +#### Scenario: Application drains events after handling +- **GIVEN** an aggregate has recorded one or more domain events +- **WHEN** application code drains the pending events +- **THEN** it receives the events in the order they were recorded +- **AND** the aggregate’s pending event collection is cleared + +### Requirement: No dispatching remains +The system SHALL NOT dispatch, publish, or transport events. + +#### Scenario: Integrating with a dispatcher using drained events +- **WHEN** a consumer needs to publish drained events +- **THEN** they integrate with an external package or application layer code diff --git a/openspec/changes/archive/2026-01-21-add-pull-events-and-guards/specs/entities-aggregates/spec.md b/openspec/changes/archive/2026-01-21-add-pull-events-and-guards/specs/entities-aggregates/spec.md new file mode 100644 index 0000000..f1a06fb --- /dev/null +++ b/openspec/changes/archive/2026-01-21-add-pull-events-and-guards/specs/entities-aggregates/spec.md @@ -0,0 +1,14 @@ +# Capability: Entities and Aggregates + +## ADDED Requirements + +### Requirement: Invariant guard helpers +The system SHALL provide small helper APIs to enforce invariants explicitly without infrastructure coupling. + +#### Scenario: Preventing illegal state with a guard +- **WHEN** a state transition would violate an invariant +- **THEN** the consumer can fail the transition explicitly using a guard helper (e.g., by throwing a domain error or `StateError`) + +#### Scenario: Guard passes when invariant holds +- **WHEN** the invariant condition holds +- **THEN** the guard helper does not throw diff --git a/openspec/changes/archive/2026-01-21-add-pull-events-and-guards/tasks.md b/openspec/changes/archive/2026-01-21-add-pull-events-and-guards/tasks.md new file mode 100644 index 0000000..bf9324a --- /dev/null +++ b/openspec/changes/archive/2026-01-21-add-pull-events-and-guards/tasks.md @@ -0,0 +1,14 @@ +## 1. Implementation +- [x] Add `pullEvents()` (drain + clear) to `AggregateRoot`. +- [x] Add guard helpers for invariant enforcement (throwing). +- [x] Export guard helpers from `bounded.dart`. + +## 2. Tests +- [x] Add unit tests covering `pullEvents()` drains in order and clears. +- [x] Add unit tests for guard helpers (throws when violated, no-op when satisfied). + +## 3. Documentation +- [x] Update README with short examples for `pullEvents()` and guards. + +## 4. Quality +- [x] Run `dart test`. diff --git a/openspec/project.md b/openspec/project.md new file mode 100644 index 0000000..63a97d4 --- /dev/null +++ b/openspec/project.md @@ -0,0 +1,117 @@ +# bounded + +## Purpose + +**bounded** is a core Domain-Driven Design (DDD) package that provides the foundational building blocks for modeling domains in a clear, explicit, and framework-agnostic way. + +It defines the *contracts, abstractions, and helpers* that domain models, event sourcing systems, and event publishing mechanisms build upon. + +`bounded` is intentionally small, opinionated about boundaries, and agnostic about infrastructure. + +--- + +## What this project is + +`bounded` provides: + +- Core domain modeling abstractions +- Explicit boundaries for domain logic +- Shared contracts used by higher-level DDD tooling +- Helpers that reduce boilerplate without hiding intent + +It acts as the **common kernel** for the ecosystem, allowing other packages +to integrate without coupling domain models to technical concerns. + +--- + +## What this project is NOT + +`bounded` does **not**: + +- Implement event sourcing +- Dispatch or transport events +- Provide persistence or repositories +- Depend on frameworks or runtimes +- Contain business logic +- Enforce a specific architecture (CQRS, ES, microservices, etc.) + +Those concerns belong in separate, opt-in packages. + +--- + +## Core concepts + +`bounded` focuses on modeling fundamentals such as: + +- Aggregates and Aggregate Roots +- Entities and identity +- Value Objects +- Domain Events (definition, not dispatch) +- Domain invariants and rules +- Explicit boundaries between domain, application, and infrastructure + +All concepts are designed to be: + +- Explicit +- Testable +- Composable +- Infrastructure-free + +--- + +## Position in the ecosystem + +`bounded` is designed to be used as a dependency by other libraries, such as: + +- **Event sourcing systems** (e.g. `continuum`) +- **Domain event publishing systems** (e.g. `raiser`) +- Application-level orchestration layers +- Testing and simulation tooling + +Higher-level packages depend on `bounded`; +`bounded` depends on nothing domain-external. + +--- + +## Design principles + +- **Domain first** + The domain model is the primary artifact. + +- **Explicit boundaries** + Illegal states should be unrepresentable. + +- **No hidden magic** + Behavior is visible and intentional. + +- **Opt-in complexity** + Advanced patterns are layered on top, not forced. + +- **Long-term maintainability** + Favor clarity over convenience. + +--- + +## Intended audience + +`bounded` is intended for developers who: + +- Practice Domain-Driven Design seriously +- Want to keep domain models pure +- Prefer explicit abstractions over frameworks +- Build systems that evolve over time + +--- + +## Status + +This project is under active development. + +Public APIs aim to be small, stable, and intentional. +Breaking changes are expected early and documented clearly. + +--- + +## License + +See `LICENSE` for details. diff --git a/openspec/specs/domain-events/spec.md b/openspec/specs/domain-events/spec.md new file mode 100644 index 0000000..d43b06a --- /dev/null +++ b/openspec/specs/domain-events/spec.md @@ -0,0 +1,47 @@ +# domain-events Specification + +## Purpose +TBD - created by archiving change add-pull-events-and-guards. Update Purpose after archive. +## Requirements +### Requirement: Drain recorded events +The system SHALL provide an aggregate-level helper that returns all currently recorded domain events and clears them in a single operation. + +#### Scenario: Application drains events after handling +- **GIVEN** an aggregate has recorded one or more domain events +- **WHEN** application code drains the pending events +- **THEN** it receives the events in the order they were recorded +- **AND** the aggregate’s pending event collection is cleared + +### Requirement: No dispatching remains +The system SHALL NOT dispatch, publish, or transport events. + +#### Scenario: Integrating with a dispatcher using drained events +- **WHEN** a consumer needs to publish drained events +- **THEN** they integrate with an external package or application layer code + +### Requirement: Domain event contract +The system SHALL provide a domain event abstraction representing something that happened in the domain. + +#### Scenario: Defining a domain event +- **WHEN** a consumer defines a domain event +- **THEN** it can be stored, tested, and compared in a deterministic way + +### Requirement: Aggregate records events +The system SHALL allow aggregate roots to record domain events as part of successful state transitions. + +#### Scenario: Recording an event during a transition +- **WHEN** an aggregate transition succeeds +- **THEN** the aggregate records a domain event in an internal collection + +#### Scenario: Reading and clearing events +- **WHEN** application code reads an aggregate’s pending events +- **THEN** it receives a read-only view of the events +- **AND** it can clear the recorded events explicitly + +### Requirement: No dispatching +The system SHALL NOT dispatch, publish, or transport events. + +#### Scenario: Integrating with a dispatcher +- **WHEN** a consumer needs to publish events +- **THEN** they integrate with an external package or application layer code + diff --git a/openspec/specs/entities-aggregates/spec.md b/openspec/specs/entities-aggregates/spec.md new file mode 100644 index 0000000..a6a9c19 --- /dev/null +++ b/openspec/specs/entities-aggregates/spec.md @@ -0,0 +1,38 @@ +# entities-aggregates Specification + +## Purpose +TBD - created by archiving change add-pull-events-and-guards. Update Purpose after archive. +## Requirements +### Requirement: Invariant guard helpers +The system SHALL provide small helper APIs to enforce invariants explicitly without infrastructure coupling. + +#### Scenario: Preventing illegal state with a guard +- **WHEN** a state transition would violate an invariant +- **THEN** the consumer can fail the transition explicitly using a guard helper (e.g., by throwing a domain error or `StateError`) + +#### Scenario: Guard passes when invariant holds +- **WHEN** the invariant condition holds +- **THEN** the guard helper does not throw + +### Requirement: Entity identity +The system SHALL provide an entity abstraction whose identity is represented by an identity type. + +#### Scenario: Entities compare by identity +- **WHEN** a consumer compares two entities with the same identity +- **THEN** they compare equal regardless of other state + +### Requirement: Aggregate root boundary +The system SHALL provide an aggregate root abstraction to model transactional consistency boundaries. + +#### Scenario: Aggregate root owns invariants +- **WHEN** a consumer models an aggregate root +- **THEN** state transitions SHOULD be implemented on the aggregate root +- **AND** invariants SHOULD be enforced within those transitions + +### Requirement: Explicit invariant enforcement +The system SHALL enable modeling invariants without infrastructure coupling. + +#### Scenario: Preventing illegal state +- **WHEN** a state transition would violate an invariant +- **THEN** the transition fails explicitly (e.g., by throwing a domain error or returning a failure type) + diff --git a/openspec/specs/identity/spec.md b/openspec/specs/identity/spec.md new file mode 100644 index 0000000..866b457 --- /dev/null +++ b/openspec/specs/identity/spec.md @@ -0,0 +1,25 @@ +# identity Specification + +## Purpose +TBD - created by archiving change add-core-domain-kernel. Update Purpose after archive. +## Requirements +### Requirement: Typed identities +The system SHALL provide identity types that can be used to uniquely identify entities and aggregates without depending on infrastructure. + +#### Scenario: Creating and comparing identities +- **WHEN** a consumer creates two identity instances with the same underlying value +- **THEN** they compare equal +- **AND** they produce the same hash code + +#### Scenario: Prevent mixing unrelated identities +- **WHEN** a consumer uses identities for two different domain types +- **THEN** the type system SHOULD make accidental mixing difficult or impossible + +### Requirement: Safe, explicit representation +The system SHALL provide a safe way to expose identity values for debugging and logging. + +#### Scenario: Logging an identity +- **WHEN** a consumer logs an identity +- **THEN** the identity renders as a stable string representation +- **AND** it does not require reflection or runtime type metadata + diff --git a/openspec/specs/value-objects/spec.md b/openspec/specs/value-objects/spec.md new file mode 100644 index 0000000..5b07e7e --- /dev/null +++ b/openspec/specs/value-objects/spec.md @@ -0,0 +1,27 @@ +# value-objects Specification + +## Purpose +TBD - created by archiving change add-core-domain-kernel. Update Purpose after archive. +## Requirements +### Requirement: Structural equality +The system SHALL support value object modeling where equality is determined by the value object’s components rather than identity. + +#### Scenario: Two value objects with same components +- **WHEN** a consumer creates two value objects with identical component values +- **THEN** they compare equal +- **AND** they produce the same hash code + +### Requirement: Immutability by convention +The system SHALL provide guidance and/or base types that encourage value objects to be immutable. + +#### Scenario: Modeling an immutable value object +- **WHEN** a consumer defines a value object +- **THEN** typical usage SHOULD encourage final fields and no setters + +### Requirement: Explicit decomposition +The system SHALL allow a value object to declare which components participate in equality. + +#### Scenario: Equality uses declared components +- **WHEN** a value object declares its equality components +- **THEN** equality and hashing are based only on those components + diff --git a/packages/bounded/CHANGELOG.md b/packages/bounded/CHANGELOG.md new file mode 100644 index 0000000..6ab7957 --- /dev/null +++ b/packages/bounded/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Strongly-typed identity primitives (`Identity` interface, `TypedIdentity` class) +- Value object support with structural equality (`ValueObject` mixin) +- Entity mixin with identity-based equality +- Aggregate root mixin with domain event collection +- Domain event interface (`DomainEvent`) +- Event collection methods on aggregates (record, retrieve, clear) +- Pure Dart package with no infrastructure dependencies diff --git a/packages/bounded/LICENSE b/packages/bounded/LICENSE new file mode 100644 index 0000000..a5627b4 --- /dev/null +++ b/packages/bounded/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Zooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/bounded/README.md b/packages/bounded/README.md new file mode 100644 index 0000000..ba91b3d --- /dev/null +++ b/packages/bounded/README.md @@ -0,0 +1,169 @@ +# bounded + +A minimal, framework-agnostic common kernel for Domain-Driven Design (DDD). + +`bounded` provides fundamental building blocks for modeling domains without coupling to infrastructure concerns. It focuses on core DDD primitives that help you implement clean, testable domain models. + +## Features + +- **Strongly-typed Identities**: Type-safe identifiers that prevent accidental mixing across domain types +- **Value Objects**: Immutable objects with structural equality based on their components +- **Entities**: Objects with distinct identity that persists through different states +- **Aggregate Roots**: Consistency boundaries that enforce domain invariants +- **Domain Events**: Immutable records of significant domain occurrences + +All primitives are infrastructure-free and can be unit tested without external dependencies. + +## Getting started + +Add `bounded` to your `pubspec.yaml`: + +```yaml +dependencies: + bounded: ^0.0.1 +``` + +## Usage + +### Identities + +Create strongly-typed identities to uniquely identify entities: + +```dart +import 'package:bounded/bounded.dart'; + +class UserId extends TypedIdentity { + const UserId(super.value); +} + +class OrderId extends TypedIdentity { + const OrderId(super.value); +} + +void main() { + final userId = UserId('user-123'); + final orderId = OrderId('order-456'); + + // Type system prevents mixing different identity types + // userId == orderId; // Compile error! +} +``` + +### Value Objects + +Model immutable value objects with structural equality: + +```dart +import 'package:bounded/bounded.dart'; + +class Money with ValueObject { + const Money(this.amount, this.currency); + + final num amount; + final String currency; + + @override + List get props => [amount, currency]; +} + +void main() { + final price1 = Money(100, 'USD'); + final price2 = Money(100, 'USD'); + + print(price1 == price2); // true - structural equality +} +``` + +### Entities and Aggregates + +Create entities with identity-based equality and aggregates that enforce invariants: + +```dart +import 'package:bounded/bounded.dart'; + +class OrderId extends TypedIdentity { + const OrderId(super.value); +} + +class OrderPlaced with ValueObject implements BoundedDomainEvent { + const OrderPlaced(this.orderId, this.placedAt); + + final OrderId orderId; + final DateTime placedAt; + + @override + OrderId get id => orderId; + + @override + DateTime get occurredOn => placedAt; + + @override + Map get metadata => const {}; + + @override + List get props => [orderId, placedAt]; +} + +enum OrderStatus { pending, placed, shipped } + +class Order extends AggregateRoot { + Order(super.id); + + OrderStatus status = OrderStatus.pending; + + void place() { + Guard.invariant(status == OrderStatus.pending, 'Order can only be placed when pending'); + status = OrderStatus.placed; + recordEvent(OrderPlaced(id, DateTime.now())); + } +} + +void main() { + final order = Order(OrderId('order-123')); + order.place(); + + // Events are recorded but not dispatched + print(order.events); // [OrderPlaced(...)] + + // Application layer can publish events + final events = order.pullEvents(); + // ... publish to event bus, store in event stream, etc. +} +``` + +## Design Principles + +- **No Infrastructure**: Domain models remain pure and testable +- **Explicit over Magic**: No code generation or reflection required +- **Small API Surface**: Minimal primitives that compose well +- **Type Safety**: Leverage Dart's type system to prevent errors + +## Lints (optional) + +This project also ships a companion package, `bounded_lints`, which provides `custom_lint` rules that warn when code violates the domain design principles encouraged by `bounded` (for example: domain event/value object immutability). + +To use it: + +```yaml +dev_dependencies: + custom_lint: ^0.8.1 + bounded_lints: ^0.0.1 +``` + +And enable the plugin: + +```yaml +analyzer: + plugins: + - custom_lint +``` + +## Additional information + +This package is the foundation of a DDD ecosystem. It intentionally excludes: +- Event sourcing and persistence +- Event dispatching and transport +- Repository patterns +- Framework integrations + +These concerns are handled by higher-level packages that build on `bounded`. diff --git a/packages/bounded/analysis_options.yaml b/packages/bounded/analysis_options.yaml new file mode 100644 index 0000000..f04c6cf --- /dev/null +++ b/packages/bounded/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/bounded/lib/bounded.dart b/packages/bounded/lib/bounded.dart new file mode 100644 index 0000000..18197c9 --- /dev/null +++ b/packages/bounded/lib/bounded.dart @@ -0,0 +1,19 @@ +/// A minimal, framework-agnostic common kernel for Domain-Driven Design (DDD). +/// +/// This library provides fundamental building blocks for modeling domains: +/// - **Identity**: Strongly-typed identifiers for entities and aggregates +/// - **Value Objects**: Immutable objects with structural equality +/// - **Entities**: Objects with distinct identity +/// - **Aggregate Roots**: Consistency boundaries that enforce invariants +/// - **Domain Events**: Records of significant domain occurrences +/// +/// These primitives are infrastructure-free and can be unit tested without +/// external dependencies. +library; + +export 'src/aggregate_root.dart'; +export 'src/domain_event.dart'; +export 'src/entity.dart'; +export 'src/guards.dart'; +export 'src/identity.dart'; +export 'src/value_object.dart'; diff --git a/packages/bounded/lib/src/aggregate_root.dart b/packages/bounded/lib/src/aggregate_root.dart new file mode 100644 index 0000000..b019de7 --- /dev/null +++ b/packages/bounded/lib/src/aggregate_root.dart @@ -0,0 +1,84 @@ +import 'domain_event.dart'; +import 'entity.dart'; +import 'identity.dart'; + +/// Base class for aggregate roots in a domain model. +/// +/// Aggregate roots are the entry points to aggregates—clusters of domain +/// objects that can be treated as a single unit for data changes. An +/// aggregate root is responsible for ensuring all invariants within the +/// aggregate are maintained. +/// +/// Aggregate roots can record domain events during state transitions. +/// These events represent facts that have occurred within the domain +/// and can be published or persisted by application layer code. +/// +/// An aggregate root is an [Entity] with event collection capabilities. +/// +/// Example: +/// ```dart +/// class Order extends AggregateRoot { +/// Order(super.id, this.customerId); +/// +/// final CustomerId customerId; +/// OrderStatus status = OrderStatus.pending; +/// +/// void place() { +/// if (status != OrderStatus.pending) { +/// throw StateError('Order can only be placed when pending'); +/// } +/// status = OrderStatus.placed; +/// recordEvent(OrderPlaced(id, DateTime.now())); +/// } +/// } +/// ``` +abstract class AggregateRoot with Entity { + /// Creates an aggregate root with the given [id]. + AggregateRoot(this._id); + + final ID _id; + + @override + ID get id => _id; + + final List _events = []; + + /// Records a domain event that occurred during a state transition. + /// + /// Events are stored internally and can be retrieved via [events] + /// for publishing or persistence by application layer code. + void recordEvent(DomainEvent event) { + _events.add(event); + } + + /// Returns a read-only view of domain events recorded by this aggregate. + /// + /// This list contains events that have been recorded but not yet cleared. + List get events => List.unmodifiable(_events); + + /// Clears all recorded domain events. + /// + /// This should be called after events have been published or persisted + /// to prevent them from being processed multiple times. + void clearEvents() { + _events.clear(); + } + + /// Returns all recorded domain events and clears them in a single operation. + /// + /// This is a convenience for the common application-layer flow: + /// 1) perform a domain operation + /// 2) publish/persist the resulting events + /// 3) clear the pending event buffer + /// + /// Events are returned in the order they were recorded. + List pullEvents() { + if (_events.isEmpty) { + return const []; + } + + final drained = List.unmodifiable(_events); + _events.clear(); + return drained; + } +} diff --git a/packages/bounded/lib/src/domain_event.dart b/packages/bounded/lib/src/domain_event.dart new file mode 100644 index 0000000..a60fd9a --- /dev/null +++ b/packages/bounded/lib/src/domain_event.dart @@ -0,0 +1,60 @@ +/// Represents something that happened in the domain. +/// +/// Domain events capture state changes and significant occurrences +/// within the domain model. They are immutable records of facts +/// and should be named in past tense (e.g., OrderPlaced, PaymentReceived). +/// +/// Domain events should be value objects with structural equality +/// based on their properties. +/// +/// This is an interface - you can implement it directly or use it +/// as a marker for your event hierarchy. +abstract interface class DomainEvent { + /// Creates a domain event. + const DomainEvent(); +} + +/// A domain event that can be uniquely identified. +/// +/// Most domain events are immutable value objects. This interface captures the +/// common requirement that an event instance can be correlated across logs, +/// message buses, or storage using a stable identifier. +abstract interface class IdentifiedDomainEvent { + /// The stable identifier for this event. + /// + /// The identifier type is intentionally generic so consumers can use a UUID, + /// ULID, database ID type, or other identifier primitive. + TId get id; +} + +/// A domain event that carries a timestamp describing when it occurred. +/// +/// Prefer representing [occurredOn] as an absolute point in time. +/// The producer of the event defines whether it uses UTC, local time, or +/// another convention. +abstract interface class TimestampedDomainEvent { + /// The point in time at which the event occurred. + /// + /// This value is used for ordering, auditing, and idempotency. + DateTime get occurredOn; +} + +/// A domain event that includes arbitrary metadata. +/// +/// Metadata is intended for cross-cutting concerns like correlation IDs, +/// request context, environment info, or source identifiers. +abstract interface class MetadataDomainEvent { + /// Additional, non-domain data associated with this event. + /// + /// Use simple JSON-compatible values where possible. + /// + /// `Object?` is used instead of `dynamic` to keep typing explicit while still + /// allowing `null` and heterogeneous values. + Map get metadata; +} + +/// A convenience domain event contract. +/// +/// This combines identification, timestamping, and metadata into a single +/// interface to keep event handling code consistent. +abstract interface class BoundedDomainEvent implements DomainEvent, IdentifiedDomainEvent, TimestampedDomainEvent, MetadataDomainEvent {} diff --git a/packages/bounded/lib/src/entity.dart b/packages/bounded/lib/src/entity.dart new file mode 100644 index 0000000..ab16ab5 --- /dev/null +++ b/packages/bounded/lib/src/entity.dart @@ -0,0 +1,33 @@ +import 'identity.dart'; + +/// Mixin for entities in a domain model. +/// +/// Entities are objects that have a distinct identity that runs through +/// time and different representations. Two entities are equal if they +/// have the same identity, regardless of their other attributes. +/// +/// Classes using this mixin must provide an [id] getter. +/// +/// Example: +/// ```dart +/// class User with Entity { +/// User(this.id, this.name); +/// +/// @override +/// final UserId id; +/// final String name; +/// } +/// ``` +mixin Entity { + /// The unique identifier of this entity. + ID get id; + + @override + bool operator ==(Object other) => identical(this, other) || other is Entity && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => Object.hash(runtimeType, id); + + @override + String toString() => '$runtimeType($id)'; +} diff --git a/packages/bounded/lib/src/guards.dart b/packages/bounded/lib/src/guards.dart new file mode 100644 index 0000000..dda0ea0 --- /dev/null +++ b/packages/bounded/lib/src/guards.dart @@ -0,0 +1,18 @@ +/// Guard helpers for enforcing domain invariants. +/// +/// These helpers are intentionally small and infrastructure-free. +/// They exist to make invariant enforcement explicit and consistent. +/// +/// Guard helpers for enforcing invariants. +/// +/// The helpers are static to avoid requiring instantiation. +abstract final class Guard { + /// Throws a [StateError] with [message] when [condition] is false. + /// + /// Use this in state transitions to prevent illegal states. + static void invariant(bool condition, String message) { + if (!condition) { + throw StateError(message); + } + } +} diff --git a/packages/bounded/lib/src/identity.dart b/packages/bounded/lib/src/identity.dart new file mode 100644 index 0000000..ce5f5a5 --- /dev/null +++ b/packages/bounded/lib/src/identity.dart @@ -0,0 +1,51 @@ +/// Base interface for domain identities. +/// +/// Identities uniquely identify entities and aggregates in a domain model. +/// They provide value equality based on their underlying representation. +abstract class Identity { + /// The underlying value of this identity. + T get value; + + @override + bool operator ==(Object other); + + @override + int get hashCode; + + @override + String toString(); +} + +/// A strongly-typed identity implementation. +/// +/// This class provides a simple, type-safe way to create identities +/// for domain entities and aggregates. Each concrete identity type +/// should extend this class with a specific type parameter to prevent +/// accidental mixing of identities across different domain types. +/// +/// Example: +/// ```dart +/// class UserId extends TypedIdentity { +/// const UserId(super.value); +/// } +/// +/// class OrderId extends TypedIdentity { +/// const OrderId(super.value); +/// } +/// ``` +abstract class TypedIdentity implements Identity { + /// Creates a typed identity with the given [value]. + const TypedIdentity(this.value); + + @override + final T value; + + @override + bool operator ==(Object other) => identical(this, other) || other is TypedIdentity && runtimeType == other.runtimeType && value == other.value; + + @override + int get hashCode => Object.hash(runtimeType, value); + + @override + String toString() => '$runtimeType($value)'; +} diff --git a/packages/bounded/lib/src/value_object.dart b/packages/bounded/lib/src/value_object.dart new file mode 100644 index 0000000..27f05ea --- /dev/null +++ b/packages/bounded/lib/src/value_object.dart @@ -0,0 +1,43 @@ +/// Mixin that provides structural equality for value objects. +/// +/// Value objects are immutable objects whose equality is determined +/// by their component values rather than their identity. Classes that +/// use this mixin must implement [props] to declare which components +/// participate in equality comparisons. +/// +/// Example: +/// ```dart +/// class Money with ValueObject { +/// const Money(this.amount, this.currency); +/// +/// final num amount; +/// final String currency; +/// +/// @override +/// List get props => [amount, currency]; +/// } +/// ``` +mixin ValueObject { + /// The list of properties that will be used to determine whether + /// two instances are equal. + List get props; + + @override + bool operator ==(Object other) => identical(this, other) || other is ValueObject && runtimeType == other.runtimeType && _equals(props, other.props); + + @override + int get hashCode => Object.hashAll(props); + + @override + String toString() { + return '$runtimeType(${props.join(', ')})'; + } + + bool _equals(List list1, List list2) { + if (list1.length != list2.length) return false; + for (var i = 0; i < list1.length; i++) { + if (list1[i] != list2[i]) return false; + } + return true; + } +} diff --git a/packages/bounded/pubspec.yaml b/packages/bounded/pubspec.yaml new file mode 100644 index 0000000..416c7d2 --- /dev/null +++ b/packages/bounded/pubspec.yaml @@ -0,0 +1,50 @@ +name: bounded +description: "A minimal, framework-agnostic common kernel for Domain-Driven Design (DDD)." +version: 0.0.1 +homepage: + +environment: + sdk: ^3.10.7 + +resolution: workspace + +dev_dependencies: + test: ^1.25.0 + lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/to/asset-from-package + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/to/font-from-package diff --git a/packages/bounded/test/aggregate_test.dart b/packages/bounded/test/aggregate_test.dart new file mode 100644 index 0000000..929a545 --- /dev/null +++ b/packages/bounded/test/aggregate_test.dart @@ -0,0 +1,164 @@ +import 'package:bounded/bounded.dart'; +import 'package:test/test.dart'; + +// Test implementations +class OrderId extends TypedIdentity { + const OrderId(super.value); +} + +class OrderPlaced with ValueObject implements DomainEvent { + const OrderPlaced(this.orderId, this.placedAt); + + final OrderId orderId; + final DateTime placedAt; + + @override + List get props => [orderId, placedAt]; +} + +class OrderShipped with ValueObject implements DomainEvent { + const OrderShipped(this.orderId); + + final OrderId orderId; + + @override + List get props => [orderId]; +} + +enum OrderStatus { pending, placed, shipped } + +class Order extends AggregateRoot { + Order(super.id); + + OrderStatus status = OrderStatus.pending; + + void place(DateTime at) { + if (status != OrderStatus.pending) { + throw StateError('Order can only be placed when pending'); + } + status = OrderStatus.placed; + recordEvent(OrderPlaced(id, at)); + } + + void ship() { + if (status != OrderStatus.placed) { + throw StateError('Order can only be shipped when placed'); + } + status = OrderStatus.shipped; + recordEvent(OrderShipped(id)); + } +} + +void main() { + group('AggregateRoot', () { + test('aggregate can record domain events during transitions', () { + final order = Order(const OrderId('order-123')); + final placedAt = DateTime(2026, 1, 21); + + order.place(placedAt); + + expect(order.events, hasLength(1)); + expect(order.events.first, isA()); + final event = order.events.first as OrderPlaced; + expect(event.orderId, equals(order.id)); + expect(event.placedAt, equals(placedAt)); + }); + + test('aggregate records multiple events', () { + final order = Order(const OrderId('order-123')); + + order.place(DateTime.now()); + order.ship(); + + expect(order.events, hasLength(2)); + expect(order.events[0], isA()); + expect(order.events[1], isA()); + }); + + test('events property returns read-only view', () { + final order = Order(const OrderId('order-123')); + order.place(DateTime.now()); + + final events = order.events; + expect(() => (events as List).add(OrderShipped(order.id)), throwsUnsupportedError); + }); + + test('clearEvents removes all recorded events', () { + final order = Order(const OrderId('order-123')); + order.place(DateTime.now()); + + expect(order.events, hasLength(1)); + + order.clearEvents(); + + expect(order.events, isEmpty); + }); + + test('pullEvents drains events in order and clears', () { + final order = Order(const OrderId('order-123')); + + order.place(DateTime(2026, 1, 21)); + order.ship(); + + final drained = order.pullEvents(); + + expect(drained, hasLength(2)); + expect(drained[0], isA()); + expect(drained[1], isA()); + expect(order.events, isEmpty); + }); + + test('pullEvents returns empty list when no events are recorded', () { + final order = Order(const OrderId('order-123')); + + final drained = order.pullEvents(); + + expect(drained, isEmpty); + expect(order.events, isEmpty); + }); + + test('aggregate starts with no events', () { + final order = Order(const OrderId('order-123')); + + expect(order.events, isEmpty); + }); + + test('aggregate enforces invariants without infrastructure', () { + final order = Order(const OrderId('order-123')); + + // Cannot ship without placing first + expect(() => order.ship(), throwsStateError); + }); + + test('aggregate identity-based equality inherited from Entity', () { + final id = const OrderId('order-123'); + final order1 = Order(id); + final order2 = Order(id); + + order1.place(DateTime.now()); + // order2 has different state but same identity + + expect(order1, equals(order2)); + }); + }); + + group('DomainEvent', () { + test('domain events are value objects with structural equality', () { + final orderId = const OrderId('order-123'); + final timestamp = DateTime(2026, 1, 21); + + final event1 = OrderPlaced(orderId, timestamp); + final event2 = OrderPlaced(orderId, timestamp); + + expect(event1, equals(event2)); + expect(event1.hashCode, equals(event2.hashCode)); + }); + + test('domain events with different values are not equal', () { + final event1 = OrderPlaced(const OrderId('order-123'), DateTime(2026, 1, 21)); + final event2 = OrderPlaced(const OrderId('order-123'), DateTime(2026, 1, 22)); + + expect(event1, isNot(equals(event2))); + }); + }); +} diff --git a/packages/bounded/test/domain_event_traits_test.dart b/packages/bounded/test/domain_event_traits_test.dart new file mode 100644 index 0000000..b9c193e --- /dev/null +++ b/packages/bounded/test/domain_event_traits_test.dart @@ -0,0 +1,45 @@ +import 'package:bounded/bounded.dart'; +import 'package:test/test.dart'; + +// Test implementations +class EventId extends TypedIdentity { + const EventId(super.value); +} + +class ExampleEvent with ValueObject implements BoundedDomainEvent { + const ExampleEvent({required this.id, required this.occurredOn, required this.metadata}); + + @override + final EventId id; + + @override + final DateTime occurredOn; + + @override + final Map metadata; + + @override + List get props => [id, occurredOn, metadata]; +} + +void main() { + group('DomainEvent traits', () { + test('BoundedDomainEvent composes all trait interfaces', () { + final event = ExampleEvent( + id: const EventId('evt-1'), + occurredOn: DateTime(2026, 1, 21), + metadata: const {'correlationId': 'c-1', 'attempt': 1}, + ); + + expect(event, isA()); + expect(event, isA>()); + expect(event, isA()); + expect(event, isA()); + + expect(event.id, equals(const EventId('evt-1'))); + expect(event.occurredOn, equals(DateTime(2026, 1, 21))); + expect(event.metadata['correlationId'], equals('c-1')); + expect(event.metadata['attempt'], equals(1)); + }); + }); +} diff --git a/packages/bounded/test/entity_test.dart b/packages/bounded/test/entity_test.dart new file mode 100644 index 0000000..c627a4d --- /dev/null +++ b/packages/bounded/test/entity_test.dart @@ -0,0 +1,49 @@ +import 'package:bounded/bounded.dart'; +import 'package:test/test.dart'; + +// Test implementations +class UserId extends TypedIdentity { + const UserId(super.value); +} + +class User with Entity { + User(this.id, this.name); + + @override + final UserId id; + final String name; +} + +void main() { + group('Entity', () { + test('two entities with same identity are equal', () { + final id = const UserId('user-123'); + final user1 = User(id, 'Alice'); + final user2 = User(id, 'Bob'); // Different name, same identity + + expect(user1, equals(user2)); + expect(user1.hashCode, equals(user2.hashCode)); + }); + + test('two entities with different identities are not equal', () { + final user1 = User(const UserId('user-123'), 'Alice'); + final user2 = User(const UserId('user-456'), 'Alice'); // Same name, different identity + + expect(user1, isNot(equals(user2))); + }); + + test('entity exposes its identity', () { + final id = const UserId('user-123'); + final user = User(id, 'Alice'); + + expect(user.id, equals(id)); + }); + + test('entity has string representation with identity', () { + final user = User(const UserId('user-123'), 'Alice'); + + expect(user.toString(), contains('User')); + expect(user.toString(), contains('UserId(user-123)')); + }); + }); +} diff --git a/packages/bounded/test/guards_test.dart b/packages/bounded/test/guards_test.dart new file mode 100644 index 0000000..ab0216a --- /dev/null +++ b/packages/bounded/test/guards_test.dart @@ -0,0 +1,19 @@ +import 'package:bounded/bounded.dart'; +import 'package:test/test.dart'; + +void main() { + group('Guard.invariant', () { + test('does nothing when condition holds', () { + expect(() => Guard.invariant(true, 'should not throw'), returnsNormally); + }); + + test('throws StateError when condition is false', () { + expect( + () => Guard.invariant(false, 'invariant violated'), + throwsA( + isA().having((e) => e.message, 'message', contains('invariant violated')), + ), + ); + }); + }); +} diff --git a/packages/bounded/test/identity_test.dart b/packages/bounded/test/identity_test.dart new file mode 100644 index 0000000..ebccb05 --- /dev/null +++ b/packages/bounded/test/identity_test.dart @@ -0,0 +1,49 @@ +import 'package:bounded/bounded.dart'; +import 'package:test/test.dart'; + +// Test identity implementations +class UserId extends TypedIdentity { + const UserId(super.value); +} + +class OrderId extends TypedIdentity { + const OrderId(super.value); +} + +void main() { + group('Identity', () { + test('two identities with same value are equal', () { + final id1 = const UserId('user-123'); + final id2 = const UserId('user-123'); + + expect(id1, equals(id2)); + expect(id1.hashCode, equals(id2.hashCode)); + }); + + test('two identities with different values are not equal', () { + final id1 = const UserId('user-123'); + final id2 = const UserId('user-456'); + + expect(id1, isNot(equals(id2))); + }); + + test('identities of different types are not equal even with same value', () { + final userId = const UserId('123'); + final orderId = const OrderId('123'); + + expect(userId, isNot(equals(orderId))); + }); + + test('identity has stable string representation', () { + final id = const UserId('user-123'); + + expect(id.toString(), equals('UserId(user-123)')); + }); + + test('identity exposes underlying value', () { + final id = const UserId('user-123'); + + expect(id.value, equals('user-123')); + }); + }); +} diff --git a/packages/bounded/test/value_object_test.dart b/packages/bounded/test/value_object_test.dart new file mode 100644 index 0000000..fde2a79 --- /dev/null +++ b/packages/bounded/test/value_object_test.dart @@ -0,0 +1,64 @@ +import 'package:bounded/bounded.dart'; +import 'package:test/test.dart'; + +// Test value object implementations +class Money with ValueObject { + const Money(this.amount, this.currency); + + final num amount; + final String currency; + + @override + List get props => [amount, currency]; +} + +class Address with ValueObject { + const Address(this.street, this.city); + + final String street; + final String city; + + @override + List get props => [street, city]; +} + +void main() { + group('ValueObject', () { + test('two value objects with same components are equal', () { + final money1 = const Money(100, 'USD'); + final money2 = const Money(100, 'USD'); + + expect(money1, equals(money2)); + expect(money1.hashCode, equals(money2.hashCode)); + }); + + test('two value objects with different components are not equal', () { + final money1 = const Money(100, 'USD'); + final money2 = const Money(100, 'EUR'); + + expect(money1, isNot(equals(money2))); + }); + + test('value objects of different types are not equal', () { + final money = const Money(100, 'USD'); + final address = const Address('100', 'USD'); // Same string values but different type + + expect(money, isNot(equals(address))); + }); + + test('value object equality uses only declared props', () { + final money1 = const Money(100.0, 'USD'); + final money2 = const Money(100, 'USD'); // Different numeric types but equal values + + expect(money1, equals(money2)); + }); + + test('value object has string representation with props', () { + final money = const Money(100, 'USD'); + + expect(money.toString(), contains('Money')); + expect(money.toString(), contains('100')); + expect(money.toString(), contains('USD')); + }); + }); +} diff --git a/packages/bounded_lints/CHANGELOG.md b/packages/bounded_lints/CHANGELOG.md new file mode 100644 index 0000000..30a64f1 --- /dev/null +++ b/packages/bounded_lints/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## [Unreleased] + +### Added + +- Initial DDD/domain modeling lints for `bounded` consumers. diff --git a/packages/bounded_lints/LICENSE b/packages/bounded_lints/LICENSE new file mode 100644 index 0000000..a5627b4 --- /dev/null +++ b/packages/bounded_lints/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Zooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/bounded_lints/README.md b/packages/bounded_lints/README.md new file mode 100644 index 0000000..28a525d --- /dev/null +++ b/packages/bounded_lints/README.md @@ -0,0 +1,43 @@ +# bounded_lints + +Analyzer lints for Domain-Driven Design (DDD) principles aligned with `bounded`. + +These lints are intentionally opinionated and focused on common domain modeling mistakes: +- Mutability in value objects and domain events +- Recording events from outside aggregate transitions + +## Install + +Add the following to your project: + +```yaml +dev_dependencies: + custom_lint: ^0.8.1 + bounded_lints: +``` + +Enable the analyzer plugin in your `analysis_options.yaml`: + +```yaml +analyzer: + plugins: + - custom_lint +``` + +Then run: + +- `dart run custom_lint` + +## Lints + +- `bounded_value_object_immutable`: warns when a `ValueObject` has setters or non-final instance fields. +- `bounded_domain_event_immutable`: warns when a `DomainEvent`/`BoundedDomainEvent` has setters or non-final instance fields. +- `bounded_prefer_const_domain_events`: warns when a domain event could be `const` but isn’t. +- `bounded_record_event_outside_aggregate`: warns when `recordEvent(...)` is called outside aggregate instance methods. + +## Ignoring + +Use standard ignore mechanisms: + +- File-wide: `// ignore_for_file: ` +- Line: `// ignore: ` diff --git a/packages/bounded_lints/analysis_options.yaml b/packages/bounded_lints/analysis_options.yaml new file mode 100644 index 0000000..8fe3fd5 --- /dev/null +++ b/packages/bounded_lints/analysis_options.yaml @@ -0,0 +1,8 @@ +include: ../../analysis_options.yaml + +analyzer: + exclude: + - example/** + errors: + deprecated_member_use: ignore + deprecated_member_use_from_same_package: ignore diff --git a/packages/bounded_lints/example/analysis_options.yaml b/packages/bounded_lints/example/analysis_options.yaml new file mode 100644 index 0000000..6a66cab --- /dev/null +++ b/packages/bounded_lints/example/analysis_options.yaml @@ -0,0 +1,5 @@ +include: ../../../analysis_options.yaml + +analyzer: + plugins: + - custom_lint diff --git a/packages/bounded_lints/example/lib/example.dart b/packages/bounded_lints/example/lib/example.dart new file mode 100644 index 0000000..a43da44 --- /dev/null +++ b/packages/bounded_lints/example/lib/example.dart @@ -0,0 +1,57 @@ +import 'package:bounded/bounded.dart'; + +class Price with ValueObject { + Price(this.amount); + + num amount; // expect: Value objects should be immutable + + @override + List get props => [amount]; +} + +class OrderId extends TypedIdentity { + const OrderId(super.value); +} + +class OrderPlaced implements BoundedDomainEvent { + OrderPlaced(this._id, this.occurredOn); + + final OrderId _id; + + @override + final DateTime occurredOn; + + String? note; // expect: bounded_domain_event_immutable + + @override + OrderId get id => _id; + + @override + Map get metadata => const {}; +} + +class PaymentReceived implements DomainEvent { + // expect: Prefer const constructors for domain events when possible + PaymentReceived(this.orderId, this.amount); + + final OrderId orderId; + final int amount; +} + +enum Status { pending, placed } + +class Order extends AggregateRoot { + Order(super.id); + + Status status = Status.pending; + + void place() { + recordEvent(OrderPlaced(id, DateTime.now())); + } +} + +class OrderService { + void recordFromOutside(Order order) { + order.recordEvent(OrderPlaced(order.id, DateTime.now())); // expect: Record domain events inside aggregate root instance methods + } +} diff --git a/packages/bounded_lints/example/pubspec.lock b/packages/bounded_lints/example/pubspec.lock new file mode 100644 index 0000000..a5c2896 --- /dev/null +++ b/packages/bounded_lints/example/pubspec.lock @@ -0,0 +1,371 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + url: "https://pub.dev" + source: hosted + version: "91.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: a40a0cee526a7e1f387c6847bd8a5ccbf510a75952ef8a28338e989558072cb0 + url: "https://pub.dev" + source: hosted + version: "8.4.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: "08cfefa90b4f4dd3b447bda831cecf644029f9f8e22820f6ee310213ebe2dd53" + url: "https://pub.dev" + source: hosted + version: "0.13.10" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + bounded: + dependency: "direct main" + description: + path: "../../bounded" + relative: true + source: path + version: "0.0.1" + bounded_lints: + dependency: "direct dev" + description: + path: ".." + relative: true + source: path + version: "0.0.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + ci: + dependency: transitive + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + custom_lint: + dependency: "direct dev" + description: + name: custom_lint + sha256: "751ee9440920f808266c3ec2553420dea56d3c7837dd2d62af76b11be3fcece5" + url: "https://pub.dev" + source: hosted + version: "0.8.1" + custom_lint_builder: + dependency: transitive + description: + name: custom_lint_builder + sha256: "1128db6f58e71d43842f3b9be7465c83f0c47f4dd8918f878dd6ad3b72a32072" + url: "https://pub.dev" + source: hosted + version: "0.8.1" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "85b339346154d5646952d44d682965dfe9e12cae5febd706f0db3aa5010d6423" + url: "https://pub.dev" + source: hosted + version: "0.8.1" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "91f2a81e9f0abb4b9f3bb529f78b6227ce6050300d1ae5b1e2c69c66c7a566d8" + url: "https://pub.dev" + source: hosted + version: "1.0.0+8.4.0" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + url: "https://pub.dev" + source: hosted + version: "3.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hotreloader: + dependency: transitive + description: + name: hotreloader + sha256: bc167a1163807b03bada490bfe2df25b0d744df359227880220a5cbd04e5734b + url: "https://pub.dev" + source: hosted + version: "4.3.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + lints: + dependency: "direct dev" + description: + name: lints + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + url: "https://pub.dev" + source: hosted + version: "0.12.18" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + url: "https://pub.dev" + source: hosted + version: "0.7.9" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.7 <4.0.0" diff --git a/packages/bounded_lints/example/pubspec.yaml b/packages/bounded_lints/example/pubspec.yaml new file mode 100644 index 0000000..5d87e0e --- /dev/null +++ b/packages/bounded_lints/example/pubspec.yaml @@ -0,0 +1,15 @@ +name: bounded_lints_example +publish_to: none + +environment: + sdk: ">=3.8.1 <4.0.0" + +dependencies: + bounded: + path: ../../bounded + +dev_dependencies: + custom_lint: ^0.8.1 + lints: ^6.0.0 + bounded_lints: + path: ../ \ No newline at end of file diff --git a/packages/bounded_lints/lib/bounded_lints.dart b/packages/bounded_lints/lib/bounded_lints.dart new file mode 100644 index 0000000..7800867 --- /dev/null +++ b/packages/bounded_lints/lib/bounded_lints.dart @@ -0,0 +1,20 @@ +import 'package:custom_lint_builder/custom_lint_builder.dart'; + +import 'src/rules/domain_event_immutable_rule.dart'; +import 'src/rules/prefer_const_domain_events_rule.dart'; +import 'src/rules/record_event_outside_aggregate_rule.dart'; +import 'src/rules/value_object_immutable_rule.dart'; + +PluginBase createPlugin() => _BoundedLintsPlugin(); + +class _BoundedLintsPlugin extends PluginBase { + @override + List getLintRules(CustomLintConfigs configs) { + return [ + ValueObjectImmutableRule(), + DomainEventImmutableRule(), + PreferConstDomainEventsRule(), + RecordEventOutsideAggregateRule(), + ]; + } +} diff --git a/packages/bounded_lints/lib/src/rules/_type_checks.dart b/packages/bounded_lints/lib/src/rules/_type_checks.dart new file mode 100644 index 0000000..9b1f5a7 --- /dev/null +++ b/packages/bounded_lints/lib/src/rules/_type_checks.dart @@ -0,0 +1,22 @@ +import 'package:analyzer/dart/element/element.dart'; + +bool hasSupertypeNamed(InterfaceElement? element, Set names) { + if (element == null) return false; + + for (final supertype in element.allSupertypes) { + final name = supertype.element.name; + if (names.contains(name)) return true; + } + + return false; +} + +bool mixesInNamed(InterfaceElement? element, String name) { + if (element == null) return false; + + for (final mixinType in element.mixins) { + if (mixinType.element.name == name) return true; + } + + return false; +} diff --git a/packages/bounded_lints/lib/src/rules/domain_event_immutable_rule.dart b/packages/bounded_lints/lib/src/rules/domain_event_immutable_rule.dart new file mode 100644 index 0000000..ab780a0 --- /dev/null +++ b/packages/bounded_lints/lib/src/rules/domain_event_immutable_rule.dart @@ -0,0 +1,43 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/error/error.dart' hide LintCode; +import 'package:analyzer/error/listener.dart'; +import 'package:custom_lint_builder/custom_lint_builder.dart'; + +import '_type_checks.dart'; + +class DomainEventImmutableRule extends DartLintRule { + DomainEventImmutableRule() : super(code: _code); + + static const _code = LintCode( + name: 'bounded_domain_event_immutable', + problemMessage: 'Domain events should be immutable.', + errorSeverity: DiagnosticSeverity.WARNING, + ); + + @override + void run(CustomLintResolver resolver, DiagnosticReporter reporter, CustomLintContext context) { + context.registry.addClassDeclaration((node) { + final element = node.declaredFragment?.element; + final isDomainEvent = hasSupertypeNamed(element, {'DomainEvent', 'BoundedDomainEvent'}); + if (!isDomainEvent) return; + + for (final member in node.members) { + if (member is FieldDeclaration) { + if (member.isStatic) continue; + if (member.fields.isFinal) continue; + + for (final variable in member.fields.variables) { + reporter.atToken(variable.name, code); + } + } + + if (member is MethodDeclaration) { + if (member.isStatic) continue; + if (!member.isSetter) continue; + + reporter.atToken(member.name, code); + } + } + }); + } +} diff --git a/packages/bounded_lints/lib/src/rules/prefer_const_domain_events_rule.dart b/packages/bounded_lints/lib/src/rules/prefer_const_domain_events_rule.dart new file mode 100644 index 0000000..7a44156 --- /dev/null +++ b/packages/bounded_lints/lib/src/rules/prefer_const_domain_events_rule.dart @@ -0,0 +1,58 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/error/error.dart' hide LintCode; +import 'package:analyzer/error/listener.dart'; +import 'package:custom_lint_builder/custom_lint_builder.dart'; + +import '_type_checks.dart'; + +class PreferConstDomainEventsRule extends DartLintRule { + PreferConstDomainEventsRule() : super(code: _code); + + static const _code = LintCode( + name: 'bounded_prefer_const_domain_events', + problemMessage: 'Prefer const constructors for domain events when possible.', + errorSeverity: DiagnosticSeverity.WARNING, + ); + + @override + void run(CustomLintResolver resolver, DiagnosticReporter reporter, CustomLintContext context) { + context.registry.addClassDeclaration((node) { + final element = node.declaredFragment?.element; + final isDomainEvent = hasSupertypeNamed(element, {'DomainEvent', 'BoundedDomainEvent'}); + if (!isDomainEvent) return; + + var hasConstCtor = false; + var hasNonConstGenerativeCtor = false; + + for (final member in node.members) { + if (member is ConstructorDeclaration) { + if (member.factoryKeyword != null) continue; + if (member.constKeyword != null) { + hasConstCtor = true; + } else { + hasNonConstGenerativeCtor = true; + } + } + } + + if (hasConstCtor) return; + if (!hasNonConstGenerativeCtor) return; + + final hasMutableFieldOrSetter = node.members.any((member) { + if (member is FieldDeclaration) { + if (member.isStatic) return false; + return !member.fields.isFinal; + } + if (member is MethodDeclaration) { + if (member.isStatic) return false; + return member.isSetter; + } + return false; + }); + + if (hasMutableFieldOrSetter) return; + + reporter.atToken(node.name, code); + }); + } +} diff --git a/packages/bounded_lints/lib/src/rules/record_event_outside_aggregate_rule.dart b/packages/bounded_lints/lib/src/rules/record_event_outside_aggregate_rule.dart new file mode 100644 index 0000000..b1a38fe --- /dev/null +++ b/packages/bounded_lints/lib/src/rules/record_event_outside_aggregate_rule.dart @@ -0,0 +1,38 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/error/error.dart' hide LintCode; +import 'package:analyzer/error/listener.dart'; +import 'package:custom_lint_builder/custom_lint_builder.dart'; + +import '_type_checks.dart'; + +class RecordEventOutsideAggregateRule extends DartLintRule { + RecordEventOutsideAggregateRule() : super(code: _code); + + static const _code = LintCode( + name: 'bounded_record_event_outside_aggregate', + problemMessage: 'Record domain events inside aggregate root instance methods.', + errorSeverity: ErrorSeverity.WARNING, + ); + + @override + void run(CustomLintResolver resolver, ErrorReporter reporter, CustomLintContext context) { + context.registry.addMethodInvocation((node) { + if (node.methodName.name != 'recordEvent') return; + + final enclosingClass = node.thisOrAncestorOfType(); + final enclosingMethod = node.thisOrAncestorOfType(); + + final isInsideAggregateType = hasSupertypeNamed(enclosingClass?.declaredFragment?.element, {'AggregateRoot'}); + final isInInstanceMethod = enclosingMethod != null && !enclosingMethod.isStatic; + + final target = node.target; + final isThisCall = target == null || target is ThisExpression; + + if (isInsideAggregateType && isInInstanceMethod && isThisCall) { + return; + } + + reporter.atNode(node.methodName, code); + }); + } +} diff --git a/packages/bounded_lints/lib/src/rules/value_object_immutable_rule.dart b/packages/bounded_lints/lib/src/rules/value_object_immutable_rule.dart new file mode 100644 index 0000000..27d7e40 --- /dev/null +++ b/packages/bounded_lints/lib/src/rules/value_object_immutable_rule.dart @@ -0,0 +1,45 @@ +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/error/error.dart' hide LintCode; +import 'package:analyzer/error/listener.dart'; +import 'package:custom_lint_builder/custom_lint_builder.dart'; + +import '_type_checks.dart'; + +class ValueObjectImmutableRule extends DartLintRule { + ValueObjectImmutableRule() + : super( + code: _code, + ); + + static const _code = LintCode( + name: 'bounded_value_object_immutable', + problemMessage: 'Value objects should be immutable.', + errorSeverity: ErrorSeverity.WARNING, + ); + + @override + void run(CustomLintResolver resolver, ErrorReporter reporter, CustomLintContext context) { + context.registry.addClassDeclaration((node) { + final element = node.declaredFragment?.element; + if (!mixesInNamed(element, 'ValueObject')) return; + + for (final member in node.members) { + if (member is FieldDeclaration) { + if (member.isStatic) continue; + if (member.fields.isFinal) continue; + + for (final variable in member.fields.variables) { + reporter.atToken(variable.name, code); + } + } + + if (member is MethodDeclaration) { + if (member.isStatic) continue; + if (!member.isSetter) continue; + + reporter.atToken(member.name, code); + } + } + }); + } +} diff --git a/packages/bounded_lints/pubspec.yaml b/packages/bounded_lints/pubspec.yaml new file mode 100644 index 0000000..f1172b1 --- /dev/null +++ b/packages/bounded_lints/pubspec.yaml @@ -0,0 +1,19 @@ +name: bounded_lints +description: Custom lints for Domain-Driven Design principles aligned with bounded. +version: 0.0.1 +homepage: + +environment: + sdk: ">=3.8.1 <4.0.0" + +resolution: workspace + +dependencies: + analyzer: ^8.0.0 + custom_lint_builder: ^0.8.1 + +dev_dependencies: + path: ^1.9.1 + test: ^1.25.0 + custom_lint: ^0.8.1 + lints: ^6.0.0 diff --git a/packages/bounded_lints/test/smoke_test.dart b/packages/bounded_lints/test/smoke_test.dart new file mode 100644 index 0000000..9cdbfcf --- /dev/null +++ b/packages/bounded_lints/test/smoke_test.dart @@ -0,0 +1,41 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +void main() { + test('custom_lint reports bounded lints', () async { + final exampleDir = p.normalize(p.join(Directory.current.path, 'example')); + + final pubGetResult = await Process.run( + Platform.resolvedExecutable, + const ['pub', 'get'], + workingDirectory: exampleDir, + ); + + expect( + pubGetResult.exitCode, + 0, + reason: 'Expected `dart pub get` to succeed in the example package. stdout:\n${pubGetResult.stdout}\nstderr:\n${pubGetResult.stderr}', + ); + + final result = await Process.run( + Platform.resolvedExecutable, + const ['run', 'custom_lint'], + workingDirectory: exampleDir, + ); + + final stdoutText = result.stdout.toString(); + final stderrText = result.stderr.toString(); + final combinedOutput = '$stdoutText\n$stderrText'; + + expect( + combinedOutput, + contains('bounded_value_object_immutable'), + reason: 'Expected lints to be reported. Output:\n$combinedOutput', + ); + expect(combinedOutput, contains('bounded_domain_event_immutable')); + expect(combinedOutput, contains('bounded_prefer_const_domain_events')); + expect(combinedOutput, contains('bounded_record_event_outside_aggregate')); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..2c98b25 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,29 @@ +name: bounded_workspace +publish_to: none + +environment: + sdk: ">=3.8.1 <4.0.0" + +workspace: + - packages/bounded + - packages/bounded_lints + +dev_dependencies: + lints: ^6.0.0 + melos: ^7.3.0 + +melos: + command: + bootstrap: + runPubGetInParallel: true + + scripts: + analyze: + run: dart run melos exec -- "dart analyze" + description: Run static analysis for all packages. + test: + run: dart run melos exec --fail-fast --ignore="bounded_lints_example" -- "dart test" + description: Run unit tests for all packages. + format: + run: dart format . + description: Format all Dart code in the workspace.