From 0144777b877d0351fe2d6327ec0bea6b31ac6326 Mon Sep 17 00:00:00 2001 From: Murad Akhundov Date: Mon, 26 Jan 2026 10:55:26 +0100 Subject: [PATCH] docs: Add new example docs pages and example projects (layered, hexagonal, legacy migration) - add examples section in docs with mermaid support - Add MkDocs nav entries and snippet base_path config - Enable Mermaid diagrams via superfences, mermaid.js, and extra CSS - Update snapshot storage layout docs and troubleshooting paths --- docs/configuration.md | 27 +-- docs/examples/hexagonal-app.md | 1 + docs/examples/index.md | 1 + docs/examples/legacy-migration.md | 1 + docs/examples/simple-layered-app.md | 1 + docs/stylesheets/extra.css | 11 + docs/troubleshooting.md | 8 +- examples/README.md | 77 ++---- examples/hexagonal-app/README.md | 95 ++++++++ examples/hexagonal-app/architecture.yml | 50 ++++ examples/hexagonal-app/rules.pacta.yml | 221 ++++++++++++++++++ examples/hexagonal-app/src/__init__.py | 1 + .../hexagonal-app/src/adapters/__init__.py | 1 + .../src/adapters/primary/__init__.py | 4 + .../src/adapters/primary/api_controller.py | 60 +++++ .../src/adapters/secondary/__init__.py | 4 + .../secondary/postgres_product_repository.py | 44 ++++ examples/hexagonal-app/src/domain/__init__.py | 5 + examples/hexagonal-app/src/domain/product.py | 22 ++ .../src/domain/product_service.py | 30 +++ examples/hexagonal-app/src/ports/__init__.py | 1 + .../src/ports/inbound/__init__.py | 4 + .../src/ports/inbound/catalog_use_case.py | 27 +++ .../src/ports/outbound/__init__.py | 4 + .../src/ports/outbound/product_repository.py | 32 +++ examples/legacy-migration/README.md | 139 +++++++++++ examples/legacy-migration/architecture.yml | 42 ++++ examples/legacy-migration/rules.pacta.yml | 153 ++++++++++++ examples/legacy-migration/src/__init__.py | 1 + examples/legacy-migration/src/api/__init__.py | 4 + .../src/api/user_controller.py | 20 ++ .../legacy-migration/src/data/__init__.py | 4 + .../src/data/legacy_adapter.py | 35 +++ .../src/data/user_repository.py | 19 ++ .../legacy-migration/src/legacy/__init__.py | 5 + .../src/legacy/old_api_handler.py | 34 +++ .../src/legacy/old_user_handler.py | 49 ++++ .../legacy-migration/src/services/__init__.py | 4 + .../src/services/user_service.py | 21 ++ examples/simple-layered-app/README.md | 78 +++++++ mkdocs.yml | 20 +- 41 files changed, 1285 insertions(+), 75 deletions(-) create mode 100644 docs/examples/hexagonal-app.md create mode 100644 docs/examples/index.md create mode 100644 docs/examples/legacy-migration.md create mode 100644 docs/examples/simple-layered-app.md create mode 100644 docs/stylesheets/extra.css create mode 100644 examples/hexagonal-app/README.md create mode 100644 examples/hexagonal-app/architecture.yml create mode 100644 examples/hexagonal-app/rules.pacta.yml create mode 100644 examples/hexagonal-app/src/__init__.py create mode 100644 examples/hexagonal-app/src/adapters/__init__.py create mode 100644 examples/hexagonal-app/src/adapters/primary/__init__.py create mode 100644 examples/hexagonal-app/src/adapters/primary/api_controller.py create mode 100644 examples/hexagonal-app/src/adapters/secondary/__init__.py create mode 100644 examples/hexagonal-app/src/adapters/secondary/postgres_product_repository.py create mode 100644 examples/hexagonal-app/src/domain/__init__.py create mode 100644 examples/hexagonal-app/src/domain/product.py create mode 100644 examples/hexagonal-app/src/domain/product_service.py create mode 100644 examples/hexagonal-app/src/ports/__init__.py create mode 100644 examples/hexagonal-app/src/ports/inbound/__init__.py create mode 100644 examples/hexagonal-app/src/ports/inbound/catalog_use_case.py create mode 100644 examples/hexagonal-app/src/ports/outbound/__init__.py create mode 100644 examples/hexagonal-app/src/ports/outbound/product_repository.py create mode 100644 examples/legacy-migration/README.md create mode 100644 examples/legacy-migration/architecture.yml create mode 100644 examples/legacy-migration/rules.pacta.yml create mode 100644 examples/legacy-migration/src/__init__.py create mode 100644 examples/legacy-migration/src/api/__init__.py create mode 100644 examples/legacy-migration/src/api/user_controller.py create mode 100644 examples/legacy-migration/src/data/__init__.py create mode 100644 examples/legacy-migration/src/data/legacy_adapter.py create mode 100644 examples/legacy-migration/src/data/user_repository.py create mode 100644 examples/legacy-migration/src/legacy/__init__.py create mode 100644 examples/legacy-migration/src/legacy/old_api_handler.py create mode 100644 examples/legacy-migration/src/legacy/old_user_handler.py create mode 100644 examples/legacy-migration/src/services/__init__.py create mode 100644 examples/legacy-migration/src/services/user_service.py create mode 100644 examples/simple-layered-app/README.md diff --git a/docs/configuration.md b/docs/configuration.md index ad6526b..4173dd4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -270,15 +270,15 @@ Pacta stores data in a `.pacta/` directory at your repository root. ``` .pacta/ -├── snapshots/ # Content-addressed snapshot storage -│ ├── ab/ # First 2 chars of hash -│ │ └── cdef1234... # Snapshot data (JSON) -│ └── ... -├── refs/ # Named references to snapshots -│ ├── latest # Most recent snapshot -│ ├── baseline # Baseline for comparison -│ └── ... -└── config.json # Optional local configuration +└── snapshots/ + ├── objects/ # Content-addressed snapshot storage + │ ├── a1b2c3d4.json # 8-char hash prefix filename + │ ├── e5f6a7b8.json + │ └── ... + └── refs/ # Named references to snapshots + ├── latest # Text file containing hash of most recent snapshot + ├── baseline # Text file containing hash (created with --save-ref) + └── ... ``` ### Snapshots @@ -313,12 +313,3 @@ Add to `.gitignore` only if you don't need persistent baselines: # Ignore Pacta data (not recommended) .pacta/ ``` - ---- - -## Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `PACTA_NO_COLOR` | Disable colored output | `false` | -| `PACTA_DATA_DIR` | Override `.pacta/` location | `.pacta/` | diff --git a/docs/examples/hexagonal-app.md b/docs/examples/hexagonal-app.md new file mode 100644 index 0000000..3b4dee0 --- /dev/null +++ b/docs/examples/hexagonal-app.md @@ -0,0 +1 @@ +--8<-- "examples/hexagonal-app/README.md" diff --git a/docs/examples/index.md b/docs/examples/index.md new file mode 100644 index 0000000..b7b7eb2 --- /dev/null +++ b/docs/examples/index.md @@ -0,0 +1 @@ +--8<-- "examples/README.md" diff --git a/docs/examples/legacy-migration.md b/docs/examples/legacy-migration.md new file mode 100644 index 0000000..dfaa98d --- /dev/null +++ b/docs/examples/legacy-migration.md @@ -0,0 +1 @@ +--8<-- "examples/legacy-migration/README.md" diff --git a/docs/examples/simple-layered-app.md b/docs/examples/simple-layered-app.md new file mode 100644 index 0000000..9dd9d48 --- /dev/null +++ b/docs/examples/simple-layered-app.md @@ -0,0 +1 @@ +--8<-- "examples/simple-layered-app/README.md" diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..2995dbb --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,11 @@ +/* Mermaid diagram styling */ +.mermaid { + text-align: center; + margin: 1rem 0; +} + +/* Ensure mermaid diagrams are responsive */ +.mermaid svg { + max-width: 100%; + height: auto; +} diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 826f99e..d2dfbb8 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -119,7 +119,8 @@ Error: Baseline 'baseline' not found 2. Check that `.pacta/` directory exists and contains snapshots: ```bash - ls -la .pacta/snapshots/ + ls -la .pacta/snapshots/objects/ + ls -la .pacta/snapshots/refs/ ``` 3. If using CI, ensure the `.pacta/` directory is cached or committed @@ -191,7 +192,10 @@ Coming soon: ### How do baselines work? -Baselines are content-addressed snapshots of your architecture at a point in time. +Baselines are content-addressed snapshots of your architecture at a point in time. They're stored in `.pacta/snapshots/`: + +- **Objects** (`.pacta/snapshots/objects/`) - Immutable snapshot files named by 8-char hash +- **Refs** (`.pacta/snapshots/refs/`) - Named pointers (like `baseline`, `latest`) to object hashes 1. **Create baseline:** Saves current violations with a reference name ```bash diff --git a/examples/README.md b/examples/README.md index 63654ee..d3834e1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,64 +1,35 @@ -# Pacta Examples +# Examples -## simple-layered-app +Pacta includes several example projects demonstrating different architectural patterns and use cases. -A Python application demonstrating clean architecture with four layers: +## Available Examples -``` -src/ -├── ui/ # Controllers, API endpoints -├── application/ # Use cases, services -├── domain/ # Business logic, models -└── infra/ # Repositories, database -``` - -### Files - -- `architecture.yml` - Defines layers and their file patterns -- `rules.pacta.yml` - Architectural rules (e.g., domain cannot depend on infra) - -### Try it +| Example | Description | Best For | +|---------|-------------|----------| +| [Simple Layered App](simple-layered-app.md) | Classic N-tier architecture | Teams familiar with layered architecture | +| [Hexagonal Architecture](hexagonal-app.md) | Ports and Adapters pattern | Domain-driven design, high testability | +| [Legacy Migration](legacy-migration.md) | Baseline workflow for brownfield | Existing codebases, incremental adoption | -```bash -cd simple-layered-app - -# Scan for violations -pacta scan src \ - --model architecture.yml \ - --rules rules.pacta.yml +## Quick Start -# Quiet mode (summary only) -pacta scan src \ - --model architecture.yml \ - --rules rules.pacta.yml -q +Each example includes: -# Verbose mode (all details) -pacta scan src \ - --model architecture.yml \ - --rules rules.pacta.yml -v +- `architecture.yml` - System and layer definitions +- `rules.pacta.yml` - Architectural constraints +- `src/` - Sample Python code demonstrating the architecture -# Save a baseline -pacta scan src \ - --model architecture.yml \ - --rules rules.pacta.yml \ - --save-ref baseline +To run any example: -# Compare against baseline -pacta scan src \ - --model architecture.yml \ - --rules rules.pacta.yml \ - --baseline baseline +```bash +cd examples/ +pacta scan . --model architecture.yml --rules rules.pacta.yml +``` -# Save architecture snapshot (without running rules) -pacta snapshot save src \ - --model architecture.yml \ - --ref v1 +## Creating Your Own -# Save another snapshot -pacta snapshot save src \ - --model architecture.yml \ - --ref v2 +1. Copy the example closest to your needs +2. Modify `architecture.yml` to match your directory structure +3. Adjust `rules.pacta.yml` for your constraints +4. Run `pacta scan` and iterate -# Compare two snapshots -pacta diff src --from v1 --to v2 -``` +See the [Configuration Reference](../configuration.md) for full schema documentation. diff --git a/examples/hexagonal-app/README.md b/examples/hexagonal-app/README.md new file mode 100644 index 0000000..24354dc --- /dev/null +++ b/examples/hexagonal-app/README.md @@ -0,0 +1,95 @@ +# Hexagonal Architecture Example + +This example demonstrates how to use Pacta to enforce [Hexagonal Architecture](https://alistair.cockburn.us/hexagonal-architecture/) (also known as Ports and Adapters). + +## Architecture Overview + +```mermaid +flowchart TB + subgraph Driving["Driving Side (Primary)"] + PA[Primary Adapters
Controllers, CLI, Event Handlers] + end + + subgraph Application["Application Core"] + IP[Inbound Ports
Use Case Interfaces] + subgraph Domain["DOMAIN"] + D[Entities
Domain Services] + end + OP[Outbound Ports
Repository Interfaces] + end + + subgraph Driven["Driven Side (Secondary)"] + SA[Secondary Adapters
Database, APIs, Queues] + end + + PA -->|uses| IP + IP -->|calls| D + D -->|uses| OP + SA -.->|implements| OP + + style Domain fill:#e3f2fd,stroke:#1565c0,stroke-width:2px + style D fill:#bbdefb,stroke:#1565c0 + style IP fill:#fff8e1,stroke:#f57f17 + style OP fill:#fff8e1,stroke:#f57f17 + style PA fill:#f3e5f5,stroke:#7b1fa2 + style SA fill:#e8f5e9,stroke:#2e7d32 +``` + +## Directory Structure + +``` +src/ +├── domain/ # Core business logic (center of hexagon) +│ ├── product.py # Domain entity +│ └── product_service.py # Domain service +│ +├── ports/ +│ ├── inbound/ # Driving ports (use case interfaces) +│ │ └── catalog_use_case.py +│ └── outbound/ # Driven ports (repository interfaces) +│ └── product_repository.py +│ +└── adapters/ + ├── primary/ # Driving adapters (controllers, CLI) + │ └── api_controller.py + └── secondary/ # Driven adapters (database, APIs) + └── postgres_product_repository.py +``` + +## Key Rules + +| Rule | Description | +|------|-------------| +| Domain → Adapters | **Forbidden** - Domain must not know about adapters | +| Domain → Outbound Ports | **Allowed** - Domain uses repository interfaces | +| Ports → Adapters | **Forbidden** - Ports are interfaces, adapters implement them | +| Primary Adapters → Domain | **Warning** - Should go through inbound ports | +| Secondary Adapters → Outbound Ports | **Allowed** - Implements the interface | +| Adapters → Adapters | **Forbidden** - Adapters should be independent | + +## Usage + +```bash +# Run architecture check +pacta scan . --model architecture.yml --rules rules.pacta.yml + +# Expected output (clean architecture): +# ✓ 0 violations +``` + +## Dependency Flow + +Dependencies always point **inward** toward the domain: + +```mermaid +flowchart LR + PA[Primary
Adapters] --> IP[Inbound
Ports] --> D((DOMAIN)) + SA[Secondary
Adapters] --> OP[Outbound
Ports] --> D + + style D fill:#e3f2fd,stroke:#1565c0,stroke-width:2px +``` + +This ensures: +- Domain is isolated and testable +- Adapters can be swapped without changing business logic +- The application is framework-agnostic diff --git a/examples/hexagonal-app/architecture.yml b/examples/hexagonal-app/architecture.yml new file mode 100644 index 0000000..c32549d --- /dev/null +++ b/examples/hexagonal-app/architecture.yml @@ -0,0 +1,50 @@ +version: 1 +system: + id: hexagonal-app + name: Hexagonal Architecture Example + +containers: + main-app: + name: Main Application + description: | + Demonstrates Hexagonal Architecture (Ports and Adapters). + The domain is at the center, ports define boundaries, + and adapters connect to the outside world. + code: + roots: + - src + layers: + # Core business logic - the heart of the hexagon + domain: + name: Domain + description: Core business logic, entities, and domain services + patterns: + - src/domain/** + + # Ports - interfaces that define how the domain communicates + ports-inbound: + name: Inbound Ports + description: Use case interfaces (driving/primary ports) + patterns: + - src/ports/inbound/** + + ports-outbound: + name: Outbound Ports + description: Repository and external service interfaces (driven/secondary ports) + patterns: + - src/ports/outbound/** + + # Adapters - implementations that connect to the outside world + adapters-primary: + name: Primary Adapters + description: Controllers, CLI, event handlers (driving adapters) + patterns: + - src/adapters/primary/** + + adapters-secondary: + name: Secondary Adapters + description: Repository implementations, external API clients (driven adapters) + patterns: + - src/adapters/secondary/** + +contexts: {} diff --git a/examples/hexagonal-app/rules.pacta.yml b/examples/hexagonal-app/rules.pacta.yml new file mode 100644 index 0000000..8e43d2b --- /dev/null +++ b/examples/hexagonal-app/rules.pacta.yml @@ -0,0 +1,221 @@ +# Hexagonal Architecture Rules +# +# Key principles: +# 1. Domain is at the center and depends on nothing external +# 2. Ports define boundaries (interfaces) - domain can use outbound ports +# 3. Adapters implement ports and depend inward toward the domain +# 4. Dependencies always point inward (toward the domain) + +# DOMAIN RULES - The domain must remain pure + +# Domain cannot depend on primary adapters +rule: + id: no_domain_to_primary_adapters + name: Domain must not depend on Primary Adapters + description: | + The domain is the core of the hexagon and must not know about + the outside world. Adapters are implementation details. + severity: error + target: dependency + when: + all: + - from.layer == domain + - to.layer == adapters-primary + action: forbid + message: Domain cannot depend on primary adapters + suggestion: Define an interface (port) in ports/outbound and implement it in adapters + +# Domain cannot depend on secondary adapters +rule: + id: no_domain_to_secondary_adapters + name: Domain must not depend on Secondary Adapters + severity: error + target: dependency + when: + all: + - from.layer == domain + - to.layer == adapters-secondary + action: forbid + message: Domain cannot depend on secondary adapters + suggestion: Use outbound ports instead of concrete adapter implementations + +# Domain cannot depend on inbound ports (use cases call domain, not vice versa) +rule: + id: no_domain_to_inbound_ports + name: Domain must not depend on Inbound Ports + description: Inbound ports define use cases that call the domain, not the other way around + severity: error + target: dependency + when: + all: + - from.layer == domain + - to.layer == ports-inbound + action: forbid + message: Domain cannot depend on inbound ports + suggestion: Inbound ports should call domain services, not be called by them + +# Domain CAN depend on outbound ports (this is how domain accesses external services) +rule: + id: domain_uses_outbound_ports + name: Domain can use Outbound Ports + description: Domain uses outbound port interfaces to access repositories and external services + severity: info + target: dependency + when: + all: + - from.layer == domain + - to.layer == ports-outbound + action: allow + message: Domain correctly using outbound port interface + +# PORT RULES - Ports are interfaces, they shouldn't depend on adapters + +# Inbound ports cannot depend on adapters +rule: + id: no_inbound_ports_to_adapters_primary + name: Inbound Ports must not depend on Primary Adapters + severity: error + target: dependency + when: + all: + - from.layer == ports-inbound + - to.layer == adapters-primary + action: forbid + message: Inbound ports cannot depend on adapters + +rule: + id: no_inbound_ports_to_adapters_secondary + name: Inbound Ports must not depend on Secondary Adapters + severity: error + target: dependency + when: + all: + - from.layer == ports-inbound + - to.layer == adapters-secondary + action: forbid + message: Inbound ports cannot depend on adapters + +# Outbound ports cannot depend on adapters +rule: + id: no_outbound_ports_to_adapters_primary + name: Outbound Ports must not depend on Primary Adapters + severity: error + target: dependency + when: + all: + - from.layer == ports-outbound + - to.layer == adapters-primary + action: forbid + message: Outbound ports cannot depend on adapters + +rule: + id: no_outbound_ports_to_adapters_secondary + name: Outbound Ports must not depend on Secondary Adapters + severity: error + target: dependency + when: + all: + - from.layer == ports-outbound + - to.layer == adapters-secondary + action: forbid + message: Outbound ports cannot depend on adapters + +# Inbound ports can depend on domain (use cases orchestrate domain logic) +rule: + id: inbound_ports_use_domain + name: Inbound Ports can use Domain + description: Use case interfaces can reference domain types + severity: info + target: dependency + when: + all: + - from.layer == ports-inbound + - to.layer == domain + action: allow + message: Inbound port correctly referencing domain types + +# Outbound ports can depend on domain (repository interfaces use domain entities) +rule: + id: outbound_ports_use_domain + name: Outbound Ports can use Domain + description: Repository interfaces reference domain entities + severity: info + target: dependency + when: + all: + - from.layer == ports-outbound + - to.layer == domain + action: allow + message: Outbound port correctly referencing domain types + +# ADAPTER RULES - Adapters connect the outside world to ports + +# Primary adapters should use inbound ports (not call domain directly) +rule: + id: primary_adapters_use_inbound_ports + name: Primary Adapters should use Inbound Ports + description: Controllers and CLI should call use case interfaces, not domain directly + severity: warning + target: dependency + when: + all: + - from.layer == adapters-primary + - to.layer == domain + action: forbid + message: Primary adapters should use inbound ports, not domain directly + suggestion: Call the use case interface in ports/inbound instead + +# Secondary adapters implement outbound ports (can depend on them) +rule: + id: secondary_adapters_implement_outbound_ports + name: Secondary Adapters implement Outbound Ports + description: Repository implementations depend on their port interfaces + severity: info + target: dependency + when: + all: + - from.layer == adapters-secondary + - to.layer == ports-outbound + action: allow + message: Secondary adapter correctly implementing outbound port + +# Secondary adapters can use domain (they work with domain entities) +rule: + id: secondary_adapters_use_domain + name: Secondary Adapters can use Domain + description: Repository implementations work with domain entities + severity: info + target: dependency + when: + all: + - from.layer == adapters-secondary + - to.layer == domain + action: allow + message: Secondary adapter correctly using domain entities + +# Adapters should not depend on each other +rule: + id: no_primary_to_secondary + name: Primary Adapters should not depend on Secondary Adapters + severity: warning + target: dependency + when: + all: + - from.layer == adapters-primary + - to.layer == adapters-secondary + action: forbid + message: Primary adapters should not depend on secondary adapters + suggestion: Communicate through ports and domain instead + +rule: + id: no_secondary_to_primary + name: Secondary Adapters should not depend on Primary Adapters + severity: warning + target: dependency + when: + all: + - from.layer == adapters-secondary + - to.layer == adapters-primary + action: forbid + message: Secondary adapters should not depend on primary adapters + suggestion: Communicate through ports and domain instead diff --git a/examples/hexagonal-app/src/__init__.py b/examples/hexagonal-app/src/__init__.py new file mode 100644 index 0000000..14360d2 --- /dev/null +++ b/examples/hexagonal-app/src/__init__.py @@ -0,0 +1 @@ +# Hexagonal Architecture Example diff --git a/examples/hexagonal-app/src/adapters/__init__.py b/examples/hexagonal-app/src/adapters/__init__.py new file mode 100644 index 0000000..40e0183 --- /dev/null +++ b/examples/hexagonal-app/src/adapters/__init__.py @@ -0,0 +1 @@ +# Adapters - Connect the outside world to the application diff --git a/examples/hexagonal-app/src/adapters/primary/__init__.py b/examples/hexagonal-app/src/adapters/primary/__init__.py new file mode 100644 index 0000000..2eb9eeb --- /dev/null +++ b/examples/hexagonal-app/src/adapters/primary/__init__.py @@ -0,0 +1,4 @@ +# Primary Adapters - Controllers, CLI, event handlers (driving adapters) +from .api_controller import ProductController + +__all__ = ["ProductController"] diff --git a/examples/hexagonal-app/src/adapters/primary/api_controller.py b/examples/hexagonal-app/src/adapters/primary/api_controller.py new file mode 100644 index 0000000..ba92699 --- /dev/null +++ b/examples/hexagonal-app/src/adapters/primary/api_controller.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass +from decimal import Decimal + +from src.ports.inbound.catalog_use_case import CatalogUseCase + + +@dataclass +class ProductDTO: + """Data Transfer Object for API responses.""" + + id: str + name: str + price: str + available: bool + + +class ProductController: + """Primary adapter - REST API controller. + + This adapter receives HTTP requests and translates them to + use case calls via the inbound port (CatalogUseCase). + """ + + def __init__(self, catalog: CatalogUseCase) -> None: + self._catalog = catalog + + def get_product(self, product_id: str) -> ProductDTO | None: + """GET /products/{id}""" + product = self._catalog.get_product(product_id) + if not product: + return None + return ProductDTO( + id=product.id, + name=product.name, + price=str(product.price), + available=product.is_available(), + ) + + def list_products(self) -> list[ProductDTO]: + """GET /products""" + products = self._catalog.list_available_products() + return [ + ProductDTO( + id=p.id, + name=p.name, + price=str(p.price), + available=p.is_available(), + ) + for p in products + ] + + def update_price(self, product_id: str, new_price: str) -> ProductDTO: + """PATCH /products/{id}/price""" + product = self._catalog.update_product_price(product_id, Decimal(new_price)) + return ProductDTO( + id=product.id, + name=product.name, + price=str(product.price), + available=product.is_available(), + ) diff --git a/examples/hexagonal-app/src/adapters/secondary/__init__.py b/examples/hexagonal-app/src/adapters/secondary/__init__.py new file mode 100644 index 0000000..a9b07af --- /dev/null +++ b/examples/hexagonal-app/src/adapters/secondary/__init__.py @@ -0,0 +1,4 @@ +# Secondary Adapters - Repository implementations, external API clients (driven adapters) +from .postgres_product_repository import PostgresProductRepository + +__all__ = ["PostgresProductRepository"] diff --git a/examples/hexagonal-app/src/adapters/secondary/postgres_product_repository.py b/examples/hexagonal-app/src/adapters/secondary/postgres_product_repository.py new file mode 100644 index 0000000..473b2e4 --- /dev/null +++ b/examples/hexagonal-app/src/adapters/secondary/postgres_product_repository.py @@ -0,0 +1,44 @@ +from decimal import Decimal + +from src.domain.product import Product +from src.ports.outbound.product_repository import ProductRepository + + +class PostgresProductRepository(ProductRepository): + """Secondary adapter - PostgreSQL implementation of ProductRepository. + + This adapter implements the outbound port (ProductRepository) and + handles the actual database operations. + """ + + def __init__(self, connection_string: str) -> None: + self._connection_string = connection_string + # In a real app, this would initialize a database connection + self._products: dict[str, Product] = {} + + def find_by_id(self, product_id: str) -> Product | None: + """Find a product by ID from the database.""" + # Simulated database query + return self._products.get(product_id) + + def find_all(self) -> list[Product]: + """Find all products from the database.""" + return list(self._products.values()) + + def find_available(self) -> list[Product]: + """Find all available products from the database.""" + return [p for p in self._products.values() if p.is_available()] + + def save(self, product: Product) -> Product: + """Save a product to the database.""" + # Simulated database save + self._products[product.id] = product + return product + + def seed_data(self) -> None: + """Seed initial data for testing.""" + self._products = { + "1": Product(id="1", name="Widget", price=Decimal("19.99"), stock=100), + "2": Product(id="2", name="Gadget", price=Decimal("49.99"), stock=50), + "3": Product(id="3", name="Gizmo", price=Decimal("29.99"), stock=0), + } diff --git a/examples/hexagonal-app/src/domain/__init__.py b/examples/hexagonal-app/src/domain/__init__.py new file mode 100644 index 0000000..30b9f7f --- /dev/null +++ b/examples/hexagonal-app/src/domain/__init__.py @@ -0,0 +1,5 @@ +# Domain layer - Core business logic +from .product import Product +from .product_service import ProductService + +__all__ = ["Product", "ProductService"] diff --git a/examples/hexagonal-app/src/domain/product.py b/examples/hexagonal-app/src/domain/product.py new file mode 100644 index 0000000..9675b6a --- /dev/null +++ b/examples/hexagonal-app/src/domain/product.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass +from decimal import Decimal + + +@dataclass +class Product: + """Domain entity representing a product.""" + + id: str + name: str + price: Decimal + stock: int + + def is_available(self) -> bool: + """Check if product is in stock.""" + return self.stock > 0 + + def reduce_stock(self, quantity: int) -> None: + """Reduce stock after a purchase.""" + if quantity > self.stock: + raise ValueError(f"Insufficient stock: requested {quantity}, available {self.stock}") + self.stock -= quantity diff --git a/examples/hexagonal-app/src/domain/product_service.py b/examples/hexagonal-app/src/domain/product_service.py new file mode 100644 index 0000000..3054735 --- /dev/null +++ b/examples/hexagonal-app/src/domain/product_service.py @@ -0,0 +1,30 @@ +from decimal import Decimal + +from src.domain.product import Product +from src.ports.outbound.product_repository import ProductRepository + + +class ProductService: + """Domain service for product operations. + + Uses outbound port (ProductRepository) via dependency injection. + """ + + def __init__(self, repository: ProductRepository) -> None: + self._repository = repository + + def get_product(self, product_id: str) -> Product | None: + """Get a product by ID.""" + return self._repository.find_by_id(product_id) + + def update_price(self, product_id: str, new_price: Decimal) -> Product: + """Update product price with business validation.""" + if new_price <= 0: + raise ValueError("Price must be positive") + + product = self._repository.find_by_id(product_id) + if not product: + raise ValueError(f"Product not found: {product_id}") + + product.price = new_price + return self._repository.save(product) diff --git a/examples/hexagonal-app/src/ports/__init__.py b/examples/hexagonal-app/src/ports/__init__.py new file mode 100644 index 0000000..4b48578 --- /dev/null +++ b/examples/hexagonal-app/src/ports/__init__.py @@ -0,0 +1 @@ +# Ports - Interfaces defining boundaries diff --git a/examples/hexagonal-app/src/ports/inbound/__init__.py b/examples/hexagonal-app/src/ports/inbound/__init__.py new file mode 100644 index 0000000..b2e0632 --- /dev/null +++ b/examples/hexagonal-app/src/ports/inbound/__init__.py @@ -0,0 +1,4 @@ +# Inbound Ports - Use case interfaces (driving/primary ports) +from .catalog_use_case import CatalogUseCase + +__all__ = ["CatalogUseCase"] diff --git a/examples/hexagonal-app/src/ports/inbound/catalog_use_case.py b/examples/hexagonal-app/src/ports/inbound/catalog_use_case.py new file mode 100644 index 0000000..0c337f9 --- /dev/null +++ b/examples/hexagonal-app/src/ports/inbound/catalog_use_case.py @@ -0,0 +1,27 @@ +from abc import ABC, abstractmethod +from decimal import Decimal + +from src.domain.product import Product + + +class CatalogUseCase(ABC): + """Inbound port defining catalog operations. + + This is the use case interface that primary adapters (controllers, CLI) + will use to interact with the application. + """ + + @abstractmethod + def get_product(self, product_id: str) -> Product | None: + """Get a product by its ID.""" + ... + + @abstractmethod + def list_available_products(self) -> list[Product]: + """List all products that are in stock.""" + ... + + @abstractmethod + def update_product_price(self, product_id: str, new_price: Decimal) -> Product: + """Update the price of a product.""" + ... diff --git a/examples/hexagonal-app/src/ports/outbound/__init__.py b/examples/hexagonal-app/src/ports/outbound/__init__.py new file mode 100644 index 0000000..952595c --- /dev/null +++ b/examples/hexagonal-app/src/ports/outbound/__init__.py @@ -0,0 +1,4 @@ +# Outbound Ports - Repository and external service interfaces (driven/secondary ports) +from .product_repository import ProductRepository + +__all__ = ["ProductRepository"] diff --git a/examples/hexagonal-app/src/ports/outbound/product_repository.py b/examples/hexagonal-app/src/ports/outbound/product_repository.py new file mode 100644 index 0000000..0aac31f --- /dev/null +++ b/examples/hexagonal-app/src/ports/outbound/product_repository.py @@ -0,0 +1,32 @@ +from abc import ABC, abstractmethod + +from src.domain.product import Product + + +class ProductRepository(ABC): + """Outbound port for product persistence. + + This interface is defined in the ports layer and implemented + by secondary adapters (e.g., PostgresProductRepository). + The domain uses this interface without knowing the implementation. + """ + + @abstractmethod + def find_by_id(self, product_id: str) -> Product | None: + """Find a product by its ID.""" + ... + + @abstractmethod + def find_all(self) -> list[Product]: + """Find all products.""" + ... + + @abstractmethod + def find_available(self) -> list[Product]: + """Find all products that are in stock.""" + ... + + @abstractmethod + def save(self, product: Product) -> Product: + """Save a product (create or update).""" + ... diff --git a/examples/legacy-migration/README.md b/examples/legacy-migration/README.md new file mode 100644 index 0000000..c94b95f --- /dev/null +++ b/examples/legacy-migration/README.md @@ -0,0 +1,139 @@ +# Legacy Migration Example + +This example demonstrates how to use Pacta's **baseline mode** to gradually migrate a brownfield codebase to clean architecture without blocking development. + +## The Problem + +You have a legacy codebase with architectural violations. You want to: + +1. **Stop the bleeding** - Prevent new violations +2. **Track progress** - See violations decrease over time +3. **Not block development** - Existing violations shouldn't fail CI + +## The Solution: Baseline Mode + +Pacta's baseline mode lets you "snapshot" current violations and only fail on **new** violations. + +## Architecture + +```mermaid +flowchart TB + subgraph Clean["Clean Code (New Architecture)"] + API[API Layer
Controllers] + SVC[Service Layer
Business Logic] + DATA[Data Layer
Repositories] + end + + subgraph Legacy["Legacy Code (Tracked via Baseline)"] + LEG[Legacy Modules
Being Migrated] + end + + API -->|allowed| SVC + SVC -->|allowed| DATA + LEG -.->|allowed
migration| SVC + LEG -.->|allowed
migration| DATA + DATA x--x|forbidden| API + SVC x--x|forbidden| API + API x--x|forbidden| LEG + SVC x--x|forbidden| LEG + + style Clean fill:#e8f5e9,stroke:#2e7d32 + style Legacy fill:#fff3e0,stroke:#ef6c00 + style API fill:#c8e6c9,stroke:#2e7d32 + style SVC fill:#c8e6c9,stroke:#2e7d32 + style DATA fill:#c8e6c9,stroke:#2e7d32 + style LEG fill:#ffe0b2,stroke:#ef6c00 +``` + +## Directory Structure + +``` +src/ +├── api/ # Clean code - new architecture +│ └── user_controller.py +├── services/ # Clean code - business logic +│ └── user_service.py +├── data/ # Clean code - repositories +│ └── user_repository.py +└── legacy/ # Legacy code - violations tracked via baseline + └── old_user_handler.py +``` + +## Workflow + +### Step 1: Run initial scan (see current violations) + +```bash +pacta scan . --model architecture.yml --rules rules.pacta.yml +``` + +This shows all violations including legacy ones. + +### Step 2: Create baseline + +```bash +pacta scan . --model architecture.yml --rules rules.pacta.yml --save-ref baseline +``` + +This saves the current state. Future scans will compare against this. + +### Step 3: Use baseline in CI + +```bash +pacta scan . --model architecture.yml --rules rules.pacta.yml --baseline baseline +``` + +Now only **new** violations will fail the build. + +### Step 4: Track progress over time + +```bash +# View history +pacta history show . --last 10 + +# View violation trends +pacta history trends . --metric violations +``` + +## Example Output + +**Without baseline** (all violations): +``` +✗ 5 violations (3 error, 2 warning) +``` + +**With baseline** (only new violations): +``` +✗ 5 violations (3 error, 2 warning) [1 new, 4 existing] + + ✗ ERROR [no_new_to_legacy] New code must not depend on Legacy + status: new ← This is NEW, will fail CI + + ✗ ERROR [no_service_to_api] Services must not depend on API + status: existing ← This is EXISTING, won't fail CI +``` + +## Migration Strategy + +1. **Week 1**: Create baseline with current violations +2. **Ongoing**: All new code must follow rules (CI enforces) +3. **Sprint work**: Gradually fix legacy violations +4. **Track**: Use `pacta history trends` to show progress + +## Key Rules + +| Rule | New Code | Legacy Code | +|------|----------|-------------| +| Services → API | ❌ Forbidden | 📊 Tracked via baseline | +| Data → API | ❌ Forbidden | 📊 Tracked via baseline | +| Data → Services | ❌ Forbidden | 📊 Tracked via baseline | +| New → Legacy | ❌ Forbidden | N/A | +| Legacy → New | ✅ Allowed | ✅ Allowed (migration) | + +## Tips + +1. **Start strict**: Define rules for your target architecture, not current state +2. **Baseline early**: Create baseline before enforcing in CI +3. **Small fixes**: Fix 1-2 violations per sprint +4. **Celebrate progress**: Use trends to show improvement over time +5. **Prevent new debt**: CI should always fail on new violations diff --git a/examples/legacy-migration/architecture.yml b/examples/legacy-migration/architecture.yml new file mode 100644 index 0000000..cee6c48 --- /dev/null +++ b/examples/legacy-migration/architecture.yml @@ -0,0 +1,42 @@ +version: 1 +system: + id: legacy-migration + name: Legacy Migration Example + +containers: + main-app: + name: Main Application + description: | + A brownfield application being gradually migrated to clean architecture. + Contains both legacy code (violations allowed) and new code (must be clean). + code: + roots: + - src + layers: + # New clean architecture layers + api: + name: API Layer + description: REST controllers and request/response handling + patterns: + - src/api/** + + services: + name: Service Layer + description: Business logic and use cases + patterns: + - src/services/** + + data: + name: Data Layer + description: Repositories and data access + patterns: + - src/data/** + + # Legacy code - will have violations but tracked via baseline + legacy: + name: Legacy Code + description: Old code being gradually refactored + patterns: + - src/legacy/** + +contexts: {} diff --git a/examples/legacy-migration/rules.pacta.yml b/examples/legacy-migration/rules.pacta.yml new file mode 100644 index 0000000..62e3b3b --- /dev/null +++ b/examples/legacy-migration/rules.pacta.yml @@ -0,0 +1,153 @@ +# Legacy Migration Rules +# +# These rules define the target architecture for new code. +# Existing violations in legacy code are tracked via baseline. +# +# Strategy: +# 1. Create baseline with current violations +# 2. New code must follow rules (CI fails on new violations) +# 3. Gradually fix legacy violations over time +# 4. Track progress with `pacta history trends` + +# CLEAN CODE RULES - Apply to all code + +# Services should not depend on API layer +rule: + id: no_service_to_api + name: Services must not depend on API + description: | + Service layer contains business logic and should not know about + HTTP concerns like controllers, request/response objects. + severity: error + target: dependency + when: + all: + - from.layer == services + - to.layer == api + action: forbid + message: Service layer must not depend on API layer + suggestion: Keep business logic independent of presentation + +# Data layer should not depend on API +rule: + id: no_data_to_api + name: Data layer must not depend on API + description: Data access should not know about HTTP concerns + severity: error + target: dependency + when: + all: + - from.layer == data + - to.layer == api + action: forbid + message: Data layer must not depend on API layer + +# Data layer should not depend on Services (inverted dependency) +rule: + id: no_data_to_services + name: Data layer must not depend on Services + description: Repositories should not call business logic + severity: error + target: dependency + when: + all: + - from.layer == data + - to.layer == services + action: forbid + message: Data layer must not depend on Service layer + suggestion: Data layer should only be called by services, not call them + +# LEGACY ISOLATION - Prevent new code from depending on legacy + +# New code (API) should not import from legacy +rule: + id: no_api_to_legacy + name: API must not depend on Legacy + severity: error + target: dependency + when: + all: + - from.layer == api + - to.layer == legacy + action: forbid + message: API must not import from legacy modules + +# New code (Services) should not import from legacy +rule: + id: no_services_to_legacy + name: Services must not depend on Legacy + severity: error + target: dependency + when: + all: + - from.layer == services + - to.layer == legacy + action: forbid + message: Services must not import from legacy modules + +# New code (Data) should not import from legacy +rule: + id: no_data_to_legacy + name: Data must not depend on Legacy + severity: error + target: dependency + when: + all: + - from.layer == data + - to.layer == legacy + action: forbid + message: Data layer must not import from legacy modules + +# ALLOWED DEPENDENCIES + +# API can depend on Services (normal flow) +rule: + id: api_uses_services + name: API can use Services + severity: info + target: dependency + when: + all: + - from.layer == api + - to.layer == services + action: allow + message: API correctly calling service layer + +# Services can depend on Data (normal flow) +rule: + id: services_use_data + name: Services can use Data + severity: info + target: dependency + when: + all: + - from.layer == services + - to.layer == data + action: allow + message: Services correctly using data layer + +# Legacy can depend on new code (it's being migrated) +rule: + id: legacy_uses_services + name: Legacy can use Services + description: Legacy code can import from new modules during migration + severity: info + target: dependency + when: + all: + - from.layer == legacy + - to.layer == services + action: allow + message: Legacy code using new services (migration in progress) + +rule: + id: legacy_uses_data + name: Legacy can use Data + severity: info + target: dependency + when: + all: + - from.layer == legacy + - to.layer == data + action: allow + message: Legacy code using new data layer (migration in progress) diff --git a/examples/legacy-migration/src/__init__.py b/examples/legacy-migration/src/__init__.py new file mode 100644 index 0000000..c6baa85 --- /dev/null +++ b/examples/legacy-migration/src/__init__.py @@ -0,0 +1 @@ +# Legacy Migration Example diff --git a/examples/legacy-migration/src/api/__init__.py b/examples/legacy-migration/src/api/__init__.py new file mode 100644 index 0000000..e46ed6d --- /dev/null +++ b/examples/legacy-migration/src/api/__init__.py @@ -0,0 +1,4 @@ +# API Layer - Controllers and HTTP handling +from .user_controller import UserController + +__all__ = ["UserController"] diff --git a/examples/legacy-migration/src/api/user_controller.py b/examples/legacy-migration/src/api/user_controller.py new file mode 100644 index 0000000..2a60609 --- /dev/null +++ b/examples/legacy-migration/src/api/user_controller.py @@ -0,0 +1,20 @@ +from src.services.user_service import UserService + + +class UserController: + """Clean API controller following proper layering.""" + + def __init__(self, user_service: UserService) -> None: + self._user_service = user_service + + def get_user(self, user_id: str) -> dict: + """GET /users/{id}""" + user = self._user_service.get_user(user_id) + if not user: + return {"error": "User not found"} + return {"id": user["id"], "name": user["name"], "email": user["email"]} + + def create_user(self, name: str, email: str) -> dict: + """POST /users""" + user = self._user_service.create_user(name, email) + return {"id": user["id"], "name": user["name"]} diff --git a/examples/legacy-migration/src/data/__init__.py b/examples/legacy-migration/src/data/__init__.py new file mode 100644 index 0000000..565bb79 --- /dev/null +++ b/examples/legacy-migration/src/data/__init__.py @@ -0,0 +1,4 @@ +# Data Layer - Repositories and data access +from .user_repository import UserRepository + +__all__ = ["UserRepository"] diff --git a/examples/legacy-migration/src/data/legacy_adapter.py b/examples/legacy-migration/src/data/legacy_adapter.py new file mode 100644 index 0000000..2df84cc --- /dev/null +++ b/examples/legacy-migration/src/data/legacy_adapter.py @@ -0,0 +1,35 @@ +"""Legacy data adapter with architectural violation. + +This file demonstrates a violation that should be tracked via baseline: +- Data layer importing from API layer (wrong direction) + +This represents legacy code that was placed in the wrong layer +before architectural guidelines were established. +""" + +# VIOLATION: Data layer should not depend on API layer +# This import creates a wrong-direction dependency +from src.api.user_controller import UserController + + +class LegacyDataAdapter: + """Legacy adapter that violates layering rules. + + This was written before clean architecture was adopted. + It should be refactored to remove the API dependency. + + To fix: + 1. Move any shared types to a common/models module + 2. Remove direct dependency on controller + 3. Use dependency injection instead + """ + + def __init__(self, controller: UserController) -> None: + # Bad: Data layer should not know about controllers + self._controller = controller + + def sync_from_api(self) -> None: + """Legacy sync method - should be refactored.""" + # This violates the principle that data layer + # should not depend on presentation layer + pass diff --git a/examples/legacy-migration/src/data/user_repository.py b/examples/legacy-migration/src/data/user_repository.py new file mode 100644 index 0000000..4cd681f --- /dev/null +++ b/examples/legacy-migration/src/data/user_repository.py @@ -0,0 +1,19 @@ +import uuid + + +class UserRepository: + """Clean repository implementation.""" + + def __init__(self) -> None: + self._users: dict[str, dict] = {} + + def find_by_id(self, user_id: str) -> dict | None: + """Find user by ID.""" + return self._users.get(user_id) + + def save(self, user: dict) -> dict: + """Save a user.""" + if "id" not in user: + user["id"] = str(uuid.uuid4()) + self._users[user["id"]] = user + return user diff --git a/examples/legacy-migration/src/legacy/__init__.py b/examples/legacy-migration/src/legacy/__init__.py new file mode 100644 index 0000000..800297d --- /dev/null +++ b/examples/legacy-migration/src/legacy/__init__.py @@ -0,0 +1,5 @@ +# Legacy Code - Being gradually refactored +# This module contains architectural violations that are tracked via baseline +from .old_user_handler import OldUserHandler + +__all__ = ["OldUserHandler"] diff --git a/examples/legacy-migration/src/legacy/old_api_handler.py b/examples/legacy-migration/src/legacy/old_api_handler.py new file mode 100644 index 0000000..01d7eed --- /dev/null +++ b/examples/legacy-migration/src/legacy/old_api_handler.py @@ -0,0 +1,34 @@ +"""Legacy API handler with architectural violations. + +This file demonstrates violations that would be tracked via baseline: +- Data layer importing from API (wrong direction) +- Direct coupling between layers +""" + +# VIOLATION: This legacy code imports directly from API layer +# In clean architecture, data should not depend on API +from src.api.user_controller import UserController + +# VIOLATION: Legacy code with tight coupling +from src.data.user_repository import UserRepository + + +class OldApiHandler: + """Legacy handler that violates layering rules. + + This represents old code that was written before architectural + guidelines were established. These violations are tracked via + baseline so they don't block CI, but new code must not add + similar violations. + """ + + def __init__(self) -> None: + self._repo = UserRepository() + # Bad: Legacy code creating controller directly + self._controller = UserController(None) # type: ignore + + def legacy_endpoint(self, user_id: str) -> dict: + """Legacy endpoint mixing concerns.""" + # Direct repository access bypassing service layer + user = self._repo.find_by_id(user_id) + return user or {"error": "Not found"} diff --git a/examples/legacy-migration/src/legacy/old_user_handler.py b/examples/legacy-migration/src/legacy/old_user_handler.py new file mode 100644 index 0000000..5a20609 --- /dev/null +++ b/examples/legacy-migration/src/legacy/old_user_handler.py @@ -0,0 +1,49 @@ +"""Legacy user handler - gradually being migrated. + +This file contains architectural violations that are tracked via baseline. +New code should not depend on this module. +""" + +# GOOD: Legacy code using new services (migration in progress) +from src.services.user_service import UserService + +# GOOD: Legacy code using new data layer +from src.data.user_repository import UserRepository + + +class OldUserHandler: + """Legacy handler being migrated to clean architecture. + + This class mixes concerns and will be refactored into: + - UserController (API layer) + - UserService (Service layer) + """ + + def __init__(self) -> None: + # Legacy: direct instantiation instead of dependency injection + self._repository = UserRepository() + self._service = UserService(self._repository) + + def handle_request(self, action: str, data: dict) -> dict: + """Handle user requests (legacy catch-all method).""" + if action == "get": + return self._get_user(data.get("id", "")) + elif action == "create": + return self._create_user(data) + else: + return {"error": f"Unknown action: {action}"} + + def _get_user(self, user_id: str) -> dict: + """Get user - delegates to new service.""" + user = self._service.get_user(user_id) + return user or {"error": "Not found"} + + def _create_user(self, data: dict) -> dict: + """Create user - delegates to new service.""" + try: + return self._service.create_user( + name=data.get("name", ""), + email=data.get("email", ""), + ) + except ValueError as e: + return {"error": str(e)} diff --git a/examples/legacy-migration/src/services/__init__.py b/examples/legacy-migration/src/services/__init__.py new file mode 100644 index 0000000..1dcc017 --- /dev/null +++ b/examples/legacy-migration/src/services/__init__.py @@ -0,0 +1,4 @@ +# Service Layer - Business logic +from .user_service import UserService + +__all__ = ["UserService"] diff --git a/examples/legacy-migration/src/services/user_service.py b/examples/legacy-migration/src/services/user_service.py new file mode 100644 index 0000000..7553152 --- /dev/null +++ b/examples/legacy-migration/src/services/user_service.py @@ -0,0 +1,21 @@ +from src.data.user_repository import UserRepository + + +class UserService: + """Clean service with proper dependencies.""" + + def __init__(self, repository: UserRepository) -> None: + self._repository = repository + + def get_user(self, user_id: str) -> dict | None: + """Get user by ID with business logic.""" + return self._repository.find_by_id(user_id) + + def create_user(self, name: str, email: str) -> dict: + """Create a new user with validation.""" + if not name or not email: + raise ValueError("Name and email are required") + if "@" not in email: + raise ValueError("Invalid email format") + + return self._repository.save({"name": name, "email": email}) diff --git a/examples/simple-layered-app/README.md b/examples/simple-layered-app/README.md new file mode 100644 index 0000000..4d0d07d --- /dev/null +++ b/examples/simple-layered-app/README.md @@ -0,0 +1,78 @@ +# Simple Layered App + +A classic N-tier layered architecture with four layers demonstrating clean architecture principles. + +## Architecture + +```mermaid +flowchart TB + subgraph Presentation["Presentation Layer"] + UI[UI / Controllers] + end + + subgraph Business["Business Layer"] + APP[Application Services] + DOM[Domain Models] + end + + subgraph Data["Data Layer"] + INFRA[Infrastructure
Repositories, External Services] + end + + UI -->|calls| APP + APP -->|uses| DOM + APP -->|uses| INFRA + INFRA -.->|implements| DOM + + UI x--x|forbidden| DOM + UI x--x|forbidden| INFRA + DOM x--x|forbidden| INFRA + + style DOM fill:#e3f2fd,stroke:#1565c0,stroke-width:2px + style APP fill:#fff8e1,stroke:#f57f17 + style UI fill:#f3e5f5,stroke:#7b1fa2 + style INFRA fill:#e8f5e9,stroke:#2e7d32 +``` + +## Directory Structure + +``` +src/ +├── ui/ # Controllers, API endpoints +├── application/ # Use cases, services +├── domain/ # Business logic, models +└── infra/ # Repositories, database +``` + +## Layer Rules + +| Rule | Description | Severity | +|------|-------------|----------| +| Domain → Infrastructure | Forbidden | Error | +| Domain → Application | Forbidden | Error | +| Domain → UI | Forbidden | Error | +| UI → Infrastructure | Forbidden | Warning | +| Infrastructure → Domain | Allowed (implements interfaces) | Info | +| Application → Domain + Infrastructure | Allowed | Info | + +## Usage + +```bash +cd examples/simple-layered-app + +# Run architecture check +pacta scan . --model architecture.yml --rules rules.pacta.yml + +# Save baseline +pacta scan . --model architecture.yml --rules rules.pacta.yml --save-ref baseline + +# Compare against baseline +pacta scan . --model architecture.yml --rules rules.pacta.yml --baseline baseline +``` + +## Key Principles + +1. **Dependency Inversion**: Domain doesn't depend on infrastructure details +2. **Separation of Concerns**: Each layer has a single responsibility +3. **Testability**: Domain can be tested without infrastructure +4. **Flexibility**: Infrastructure can be swapped without changing domain diff --git a/mkdocs.yml b/mkdocs.yml index f2c0777..604c83d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -26,6 +26,11 @@ nav: - CLI Reference: cli.md - Architecture Model: architecture.md - Rules DSL: rules.md + - Examples: + - Overview: examples/index.md + - Simple Layered App: examples/simple-layered-app.md + - Hexagonal Architecture: examples/hexagonal-app.md + - Legacy Migration: examples/legacy-migration.md - Integration: - CI/CD: ci-integration.md - Reference: @@ -35,13 +40,24 @@ nav: markdown_extensions: - admonition - - pymdownx.snippets + - pymdownx.snippets: + base_path: ['.'] - pymdownx.highlight: anchor_linenums: true - - pymdownx.superfences + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.tabbed: alternate_style: true - pymdownx.details - attr_list - md_in_html - tables + +extra_javascript: + - https://unpkg.com/mermaid@10/dist/mermaid.min.js + +extra_css: + - stylesheets/extra.css