architecture-patterns

Implement proven backend architecture patterns for maintainable, testable, and scalable systems. Covers three core patterns: Clean Architecture (layered dependency inward), Hexagonal Architecture (ports and adapters), and Domain-Driven Design (bounded contexts, aggregates, value objects) Includes complete directory structures, code examples, and implementation patterns for Python backends using FastAPI, asyncpg, and similar frameworks Demonstrates practical separation of concerns: domain entities, use cases, adapters, repositories, and controllers with clear dependency flows Provides tactical DDD patterns including value objects, entities, aggregates, domain events, and repositories for modeling complex business logic

INSTALLATION
npx skills add https://github.com/wshobson/agents --skill architecture-patterns
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

$2a

Layers (dependency flows inward):

  • Entities: Core business models, no framework imports
  • Use Cases: Application business rules, orchestrate entities
  • Interface Adapters: Controllers, presenters, gateways — translate between use cases and external formats
  • Frameworks & Drivers: UI, database, external services — all at the outermost ring

Key Principles:

  • Dependencies point inward only; inner layers know nothing about outer layers
  • Business logic is independent of frameworks, databases, and delivery mechanisms
  • Every layer boundary is crossed via an abstract interface
  • Testable without UI, database, or external services

2. Hexagonal Architecture (Ports and Adapters)

Components:

  • Domain Core: Business logic lives here, framework-free
  • Ports: Abstract interfaces that define how the core interacts with the outside world (driving and driven)
  • Adapters: Concrete implementations of ports (PostgreSQL adapter, Stripe adapter, REST adapter)

Benefits:

  • Swap implementations without touching the core (e.g., replace PostgreSQL with DynamoDB)
  • Use in-memory adapters in tests — no Docker required
  • Technology decisions deferred to the edges

3. Domain-Driven Design (DDD)

Strategic Patterns:

  • Bounded Contexts: Isolate a coherent model for one subdomain; avoid sharing a single model across the whole system
  • Context Mapping: Define how contexts relate (Anti-Corruption Layer, Shared Kernel, Open Host Service)
  • Ubiquitous Language: Every term in code matches the term used by domain experts

Tactical Patterns:

  • Entities: Objects with stable identity that change over time
  • Value Objects: Immutable objects identified by their attributes (Email, Money, Address)
  • Aggregates: Consistency boundaries; only the root is accessible from outside
  • Repositories: Persist and reconstitute aggregates; abstract over the storage mechanism
  • Domain Events: Capture things that happened inside the domain; used for cross-aggregate coordination

Clean Architecture — Directory Structure

app/

├── domain/           # Entities, value objects, interfaces

│   ├── entities/

│   │   ├── user.py

│   │   └── order.py

│   ├── value_objects/

│   │   ├── email.py

│   │   └── money.py

│   └── interfaces/   # Abstract ports (no implementations)

│       ├── user_repository.py

│       └── payment_gateway.py

├── use_cases/        # Application business rules

│   ├── create_user.py

│   ├── process_order.py

│   └── send_notification.py

├── adapters/         # Concrete implementations

│   ├── repositories/

│   │   ├── postgres_user_repository.py

│   │   └── redis_cache_repository.py

│   ├── controllers/

│   │   └── user_controller.py

│   └── gateways/

│       ├── stripe_payment_gateway.py

│       └── sendgrid_email_gateway.py

└── infrastructure/   # Framework wiring, config, DI container

    ├── database.py

    ├── config.py

    └── logging.py

Dependency rule in one sentence: every import statement in domain/ and use_cases/ must point only toward domain/; nothing in those layers may import from adapters/ or infrastructure/.

Clean Architecture — Core Implementation

# domain/entities/user.py

from dataclasses import dataclass

from datetime import datetime

@dataclass

class User:

    """Core user entity — no framework dependencies."""

    id: str

    email: str

    name: str

    created_at: datetime

    is_active: bool = True

    def deactivate(self):

        self.is_active = False

    def can_place_order(self) -> bool:

        return self.is_active

# domain/interfaces/user_repository.py

from abc import ABC, abstractmethod

from typing import Optional

from domain.entities.user import User

class IUserRepository(ABC):

    """Port: defines contract, no implementation details."""

    @abstractmethod

    async def find_by_id(self, user_id: str) -> Optional[User]: ...

    @abstractmethod

    async def find_by_email(self, email: str) -> Optional[User]: ...

    @abstractmethod

    async def save(self, user: User) -> User: ...

    @abstractmethod

    async def delete(self, user_id: str) -> bool: ...

# use_cases/create_user.py

from dataclasses import dataclass

from datetime import datetime

from typing import Optional

import uuid

from domain.entities.user import User

from domain.interfaces.user_repository import IUserRepository

@dataclass

class CreateUserRequest:

    email: str

    name: str

@dataclass

class CreateUserResponse:

    user: Optional[User]

    success: bool

    error: Optional[str] = None

class CreateUserUseCase:

    """Use case: orchestrates business logic, no HTTP or DB details."""

    def __init__(self, user_repository: IUserRepository):

        self.user_repository = user_repository

    async def execute(self, request: CreateUserRequest) -> CreateUserResponse:

        existing = await self.user_repository.find_by_email(request.email)

        if existing:

            return CreateUserResponse(user=None, success=False, error="Email already exists")

        user = User(

            id=str(uuid.uuid4()),

            email=request.email,

            name=request.name,

            created_at=datetime.now(),

        )

        saved_user = await self.user_repository.save(user)

        return CreateUserResponse(user=saved_user, success=True)

# adapters/repositories/postgres_user_repository.py

from domain.interfaces.user_repository import IUserRepository

from domain.entities.user import User

from typing import Optional

import asyncpg

class PostgresUserRepository(IUserRepository):

    """Adapter: PostgreSQL implementation of the user port."""

    def __init__(self, pool: asyncpg.Pool):

        self.pool = pool

    async def find_by_id(self, user_id: str) -> Optional[User]:

        async with self.pool.acquire() as conn:

            row = await conn.fetchrow("SELECT * FROM users WHERE id = $1", user_id)

            return self._to_entity(row) if row else None

    async def find_by_email(self, email: str) -> Optional[User]:

        async with self.pool.acquire() as conn:

            row = await conn.fetchrow("SELECT * FROM users WHERE email = $1", email)

            return self._to_entity(row) if row else None

    async def save(self, user: User) -> User:

        async with self.pool.acquire() as conn:

            await conn.execute(

                """

                INSERT INTO users (id, email, name, created_at, is_active)

                VALUES ($1, $2, $3, $4, $5)

                ON CONFLICT (id) DO UPDATE

                SET email = $2, name = $3, is_active = $5

                """,

                user.id, user.email, user.name, user.created_at, user.is_active,

            )

        return user

    async def delete(self, user_id: str) -> bool:

        async with self.pool.acquire() as conn:

            result = await conn.execute("DELETE FROM users WHERE id = $1", user_id)

            return result == "DELETE 1"

    def _to_entity(self, row) -> User:

        return User(

            id=row["id"], email=row["email"], name=row["name"],

            created_at=row["created_at"], is_active=row["is_active"],

        )

# adapters/controllers/user_controller.py

from fastapi import APIRouter, Depends, HTTPException

from pydantic import BaseModel

from use_cases.create_user import CreateUserUseCase, CreateUserRequest

router = APIRouter()

class CreateUserDTO(BaseModel):

    email: str

    name: str

@router.post("/users")

async def create_user(

    dto: CreateUserDTO,

    use_case: CreateUserUseCase = Depends(get_create_user_use_case),

):

    """Controller handles HTTP only — no business logic lives here."""

    response = await use_case.execute(CreateUserRequest(email=dto.email, name=dto.name))

    if not response.success:

        raise HTTPException(status_code=400, detail=response.error)

    return {"user": response.user}

Hexagonal Architecture — Ports and Adapters

# Core domain service — no infrastructure dependencies

class OrderService:

    def __init__(

        self,

        order_repository: OrderRepositoryPort,

        payment_gateway: PaymentGatewayPort,

        notification_service: NotificationPort,

    ):

        self.orders = order_repository

        self.payments = payment_gateway

        self.notifications = notification_service

    async def place_order(self, order: Order) -> OrderResult:

        if not order.is_valid():

            return OrderResult(success=False, error="Invalid order")

        payment = await self.payments.charge(amount=order.total, customer=order.customer_id)

        if not payment.success:

            return OrderResult(success=False, error="Payment failed")

        order.mark_as_paid()

        saved_order = await self.orders.save(order)

        await self.notifications.send(

            to=order.customer_email,

            subject="Order confirmed",

            body=f"Order {order.id} confirmed",

        )

        return OrderResult(success=True, order=saved_order)

# Ports (driving and driven interfaces)

class OrderRepositoryPort(ABC):

    @abstractmethod

    async def save(self, order: Order) -> Order: ...

class PaymentGatewayPort(ABC):

    @abstractmethod

    async def charge(self, amount: Money, customer: str) -> PaymentResult: ...

class NotificationPort(ABC):

    @abstractmethod

    async def send(self, to: str, subject: str, body: str): ...

# Production adapter: Stripe

class StripePaymentAdapter(PaymentGatewayPort):

    def __init__(self, api_key: str):

        import stripe

        stripe.api_key = api_key

        self._stripe = stripe

    async def charge(self, amount: Money, customer: str) -> PaymentResult:

        try:

            charge = self._stripe.Charge.create(

                amount=amount.cents, currency=amount.currency, customer=customer

            )

            return PaymentResult(success=True, transaction_id=charge.id)

        except self._stripe.error.CardError as e:

            return PaymentResult(success=False, error=str(e))

# Test adapter: no external dependencies

class MockPaymentAdapter(PaymentGatewayPort):

    async def charge(self, amount: Money, customer: str) -> PaymentResult:

        return PaymentResult(success=True, transaction_id="mock-txn-123")

DDD — Value Objects and Aggregates

# Value Objects: immutable, validated at construction

from dataclasses import dataclass

@dataclass(frozen=True)

class Email:

    value: str

    def __post_init__(self):

        if "@" not in self.value or "." not in self.value.split("@")[-1]:

            raise ValueError(f"Invalid email: {self.value}")

@dataclass(frozen=True)

class Money:

    amount: int   # cents

    currency: str

    def __post_init__(self):

        if self.amount < 0:

            raise ValueError("Money amount cannot be negative")

        if self.currency not in {"USD", "EUR", "GBP"}:

            raise ValueError(f"Unsupported currency: {self.currency}")

    def add(self, other: "Money") -> "Money":

        if self.currency != other.currency:

            raise ValueError("Currency mismatch")

        return Money(self.amount + other.amount, self.currency)

# Aggregate root: enforces all invariants for its cluster of entities

class Order:

    def __init__(self, id: str, customer_id: str):

        self.id = id

        self.customer_id = customer_id

        self.items: list[OrderItem] = []

        self.status = OrderStatus.PENDING

        self._events: list[DomainEvent] = []

    def add_item(self, product: Product, quantity: int):

        if self.status != OrderStatus.PENDING:

            raise ValueError("Cannot modify a submitted order")

        item = OrderItem(product=product, quantity=quantity)

        self.items.append(item)

        self._events.append(ItemAddedEvent(order_id=self.id, item=item))

    @property

    def total(self) -> Money:

        totals = [item.subtotal() for item in self.items]

        return sum(totals[1:], totals[0]) if totals else Money(0, "USD")

    def submit(self):

        if not self.items:

            raise ValueError("Cannot submit an empty order")

        if self.status != OrderStatus.PENDING:

            raise ValueError("Order already submitted")

        self.status = OrderStatus.SUBMITTED

        self._events.append(OrderSubmittedEvent(order_id=self.id))

    def pop_events(self) -> list[DomainEvent]:

        events, self._events = self._events, []

        return events

# Repository: persist and reconstitute aggregates

class OrderRepository(ABC):

    @abstractmethod

    async def find_by_id(self, order_id: str) -> Optional[Order]: ...

    @abstractmethod

    async def save(self, order: Order) -> None: ...

    # Implementations persist events via pop_events() after writing state

Testing — In-Memory Adapters

The hallmark of correctly applied Clean Architecture is that every use case can be exercised in a plain unit test with no real database, no Docker, and no network:

# tests/unit/test_create_user.py

import asyncio

from typing import Dict, Optional

from domain.entities.user import User

from domain.interfaces.user_repository import IUserRepository

from use_cases.create_user import CreateUserUseCase, CreateUserRequest

class InMemoryUserRepository(IUserRepository):

    def __init__(self):

        self._store: Dict[str, User] = {}

    async def find_by_id(self, user_id: str) -> Optional[User]:

        return self._store.get(user_id)

    async def find_by_email(self, email: str) -> Optional[User]:

        return next((u for u in self._store.values() if u.email == email), None)

    async def save(self, user: User) -> User:

        self._store[user.id] = user

        return user

    async def delete(self, user_id: str) -> bool:

        return self._store.pop(user_id, None) is not None

async def test_create_user_succeeds():

    repo = InMemoryUserRepository()

    use_case = CreateUserUseCase(user_repository=repo)

    response = await use_case.execute(CreateUserRequest(email="alice@example.com", name="Alice"))

    assert response.success

    assert response.user.email == "alice@example.com"

    assert response.user.id is not None

async def test_duplicate_email_rejected():

    repo = InMemoryUserRepository()

    use_case = CreateUserUseCase(user_repository=repo)

    await use_case.execute(CreateUserRequest(email="alice@example.com", name="Alice"))

    response = await use_case.execute(CreateUserRequest(email="alice@example.com", name="Alice2"))

    assert not response.success

    assert "already exists" in response.error

Troubleshooting

Use case tests require a running database

Business logic has leaked into the infrastructure layer. Move all database calls behind an IRepository interface and inject an in-memory implementation in tests (see Testing section above). The use case constructor must accept the abstract port, not the concrete class.

Circular imports between layers

A common symptom is ImportError: cannot import name X between use_cases and adapters. This happens when a use case imports a concrete adapter class instead of the abstract port. Enforce the rule: use_cases/ imports only from domain/ (entities and interfaces). It must never import from adapters/ or infrastructure/.

Framework decorators appearing in domain entities

If SQLAlchemy Column() or Pydantic Field() annotations appear on domain entities, the entity is no longer pure. Create a separate ORM model in adapters/repositories/ and map to/from the domain entity in the repository's _to_entity() method.

All logic ending up in controllers

When the controller grows beyond HTTP parsing and response formatting, extract the logic into a use case class. A controller method should do three things only: parse the request, call a use case, map the response.

Value objects raising errors too late

Validate invariants in __post_init__ (Python) or the constructor so an invalid Email or Money cannot be constructed at all. This surfaces bad data at the boundary, not deep inside business logic.

Context bleed across bounded contexts

If the Order context is importing User entities from the Identity context, introduce an Anti-Corruption Layer. The Order context should hold its own lightweight CustomerId value object and only call the Identity context through an explicit interface.

Advanced Patterns

For detailed DDD bounded context mapping, full multi-service project trees, Anti-Corruption Layer implementations, and Onion Architecture comparisons, see:

Related Skills

  • microservices-patterns — Apply these architecture patterns when decomposing a monolith into services
  • cqrs-implementation — Use Clean Architecture as the structural foundation for CQRS command/query separation
  • saga-orchestration — Sagas require well-defined aggregate boundaries, which DDD tactical patterns provide
  • event-store-design — Domain events produced by aggregates feed directly into an event store
BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card