refactor: migrate to DDD architecture with Dishka DI
Complete architectural refactoring from simple MVC to Clean Architecture/DDD pattern: Domain Layer: - Add entities (Post, BaseEntity) with business logic - Add value objects (Title, Content, Slug) with validation - Add repository interfaces (PostRepository) - Add domain exceptions Application Layer: - Add use cases (CreatePost, GetPost, UpdatePost, DeletePost, ListPosts, PublishPost) - Add DTOs for data transfer - Add TransactionManager interface Infrastructure Layer: - Add SQLAlchemy models and async database connection - Add SQLAlchemyPostRepository implementation - Add Dishka DI container with providers - Add error handlers and middleware Presentation Layer: - Add FastAPI routes with Dishka integration - Add Pydantic schemas - Add dependency injection using FromDishka[T] Other Changes: - Remove old flat structure (api/, common/, core/, modules/) - Add hatchling build system for package scripts - Add blog CLI command - Update AGENTS.md with new architecture docs - All 48 tests passing, mypy clean, ruff clean
This commit is contained in:
34
app/domain/__init__.py
Normal file
34
app/domain/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Domain layer exports."""
|
||||
|
||||
from app.domain.entities import BaseEntity, Post
|
||||
from app.domain.exceptions import (
|
||||
AlreadyExistsException,
|
||||
DomainException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
ValidationException,
|
||||
)
|
||||
from app.domain.repositories import PostRepository, Repository
|
||||
from app.domain.value_objects import Content, Slug, Title, ValueObject
|
||||
|
||||
__all__ = [
|
||||
# Entities
|
||||
"BaseEntity",
|
||||
"Post",
|
||||
# Value Objects
|
||||
"ValueObject",
|
||||
"Title",
|
||||
"Content",
|
||||
"Slug",
|
||||
# Repositories
|
||||
"Repository",
|
||||
"PostRepository",
|
||||
# Exceptions
|
||||
"DomainException",
|
||||
"ValidationException",
|
||||
"NotFoundException",
|
||||
"AlreadyExistsException",
|
||||
"UnauthorizedException",
|
||||
"ForbiddenException",
|
||||
]
|
||||
6
app/domain/entities/__init__.py
Normal file
6
app/domain/entities/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Domain entities."""
|
||||
|
||||
from app.domain.entities.base import BaseEntity
|
||||
from app.domain.entities.post import Post
|
||||
|
||||
__all__ = ["BaseEntity", "Post"]
|
||||
33
app/domain/entities/base.py
Normal file
33
app/domain/entities/base.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Base entity for DDD domain layer."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class BaseEntity(ABC):
|
||||
"""Base class for all domain entities."""
|
||||
|
||||
id: UUID = field(default_factory=uuid4)
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, BaseEntity):
|
||||
return NotImplemented
|
||||
return self.id == other.id
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.id)
|
||||
|
||||
def touch(self) -> None:
|
||||
"""Update the updated_at timestamp."""
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
@abstractmethod
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert entity to dictionary."""
|
||||
...
|
||||
88
app/domain/entities/post.py
Normal file
88
app/domain/entities/post.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Domain entity for Blog Post."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.domain.entities.base import BaseEntity
|
||||
from app.domain.value_objects.content import Content
|
||||
from app.domain.value_objects.slug import Slug
|
||||
from app.domain.value_objects.title import Title
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class Post(BaseEntity):
|
||||
"""Blog post domain entity."""
|
||||
|
||||
title: Title
|
||||
content: Content
|
||||
slug: Slug
|
||||
author_id: str
|
||||
published: bool = False
|
||||
tags: list[str] = field(default_factory=list)
|
||||
|
||||
def publish(self) -> None:
|
||||
"""Publish the post."""
|
||||
self.published = True
|
||||
self.touch()
|
||||
|
||||
def unpublish(self) -> None:
|
||||
"""Unpublish the post."""
|
||||
self.published = False
|
||||
self.touch()
|
||||
|
||||
def update_content(self, content: Content) -> None:
|
||||
"""Update post content."""
|
||||
self.content = content
|
||||
self.touch()
|
||||
|
||||
def update_title(self, title: Title) -> None:
|
||||
"""Update post title and regenerate slug."""
|
||||
self.title = title
|
||||
self.slug = Slug.from_title(title.value)
|
||||
self.touch()
|
||||
|
||||
def add_tag(self, tag: str) -> None:
|
||||
"""Add a tag to the post."""
|
||||
if tag not in self.tags:
|
||||
self.tags.append(tag)
|
||||
self.touch()
|
||||
|
||||
def remove_tag(self, tag: str) -> None:
|
||||
"""Remove a tag from the post."""
|
||||
if tag in self.tags:
|
||||
self.tags.remove(tag)
|
||||
self.touch()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert entity to dictionary."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"title": self.title.value,
|
||||
"content": self.content.value,
|
||||
"slug": self.slug.value,
|
||||
"author_id": self.author_id,
|
||||
"published": self.published,
|
||||
"tags": self.tags.copy(),
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
title_str: str,
|
||||
content_str: str,
|
||||
author_id: str,
|
||||
tags: list[str] | None = None,
|
||||
) -> "Post":
|
||||
"""Factory method to create a new post."""
|
||||
title = Title(title_str)
|
||||
content = Content(content_str)
|
||||
slug = Slug.from_title(title_str)
|
||||
return cls(
|
||||
title=title,
|
||||
content=content,
|
||||
slug=slug,
|
||||
author_id=author_id,
|
||||
tags=tags or [],
|
||||
)
|
||||
39
app/domain/exceptions.py
Normal file
39
app/domain/exceptions.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Domain exceptions."""
|
||||
|
||||
|
||||
class DomainException(Exception):
|
||||
"""Base exception for domain layer."""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
self.message = message
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class ValidationException(DomainException):
|
||||
"""Raised when validation fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NotFoundException(DomainException):
|
||||
"""Raised when an entity is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AlreadyExistsException(DomainException):
|
||||
"""Raised when trying to create an entity that already exists."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UnauthorizedException(DomainException):
|
||||
"""Raised when user is not authorized."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ForbiddenException(DomainException):
|
||||
"""Raised when access is forbidden."""
|
||||
|
||||
pass
|
||||
6
app/domain/repositories/__init__.py
Normal file
6
app/domain/repositories/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Repository interfaces."""
|
||||
|
||||
from app.domain.repositories.base import Repository
|
||||
from app.domain.repositories.post import PostRepository
|
||||
|
||||
__all__ = ["Repository", "PostRepository"]
|
||||
43
app/domain/repositories/base.py
Normal file
43
app/domain/repositories/base.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Base repository interface for DDD."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Generic, TypeVar
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.entities.base import BaseEntity
|
||||
|
||||
T = TypeVar("T", bound=BaseEntity)
|
||||
|
||||
|
||||
class Repository(ABC, Generic[T]):
|
||||
"""Generic repository interface."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_id(self, entity_id: UUID) -> T | None:
|
||||
"""Get entity by ID."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_all(self) -> list[T]:
|
||||
"""Get all entities."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def add(self, entity: T) -> None:
|
||||
"""Add new entity."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def update(self, entity: T) -> None:
|
||||
"""Update existing entity."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, entity_id: UUID) -> None:
|
||||
"""Delete entity by ID."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def exists(self, entity_id: UUID) -> bool:
|
||||
"""Check if entity exists."""
|
||||
...
|
||||
40
app/domain/repositories/post.py
Normal file
40
app/domain/repositories/post.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Post repository interface."""
|
||||
|
||||
from abc import abstractmethod
|
||||
|
||||
from app.domain.entities.post import Post
|
||||
from app.domain.repositories.base import Repository
|
||||
|
||||
|
||||
class PostRepository(Repository[Post]):
|
||||
"""Repository interface for Blog Posts."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_slug(self, slug: str) -> Post | None:
|
||||
"""Get post by slug."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_author(self, author_id: str) -> list[Post]:
|
||||
"""Get all posts by author."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_published(self) -> list[Post]:
|
||||
"""Get all published posts."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_by_tag(self, tag: str) -> list[Post]:
|
||||
"""Get posts by tag."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def slug_exists(self, slug: str) -> bool:
|
||||
"""Check if slug already exists."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def search(self, query: str) -> list[Post]:
|
||||
"""Search posts by query string."""
|
||||
...
|
||||
8
app/domain/value_objects/__init__.py
Normal file
8
app/domain/value_objects/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Value objects."""
|
||||
|
||||
from app.domain.value_objects.base import ValueObject
|
||||
from app.domain.value_objects.content import Content
|
||||
from app.domain.value_objects.slug import Slug
|
||||
from app.domain.value_objects.title import Title
|
||||
|
||||
__all__ = ["ValueObject", "Title", "Content", "Slug"]
|
||||
37
app/domain/value_objects/base.py
Normal file
37
app/domain/value_objects/base.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Base value object for DDD domain layer."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ValueObject(ABC, Generic[T]):
|
||||
"""Base class for all value objects."""
|
||||
|
||||
value: T
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._validate()
|
||||
|
||||
@abstractmethod
|
||||
def _validate(self) -> None:
|
||||
"""Validate the value object. Raise ValueError if invalid."""
|
||||
...
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, ValueObject):
|
||||
return False
|
||||
return bool(self.value == other.value)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.value)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
def to_primitive(self) -> Any:
|
||||
"""Convert value object to primitive type."""
|
||||
return self.value
|
||||
23
app/domain/value_objects/content.py
Normal file
23
app/domain/value_objects/content.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Content value object."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.domain.value_objects.base import ValueObject
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Content(ValueObject[str]):
|
||||
"""Blog post content value object."""
|
||||
|
||||
MIN_LENGTH: int = 10
|
||||
MAX_LENGTH: int = 50000
|
||||
|
||||
def _validate(self) -> None:
|
||||
if not isinstance(self.value, str):
|
||||
raise ValueError("Content must be a string")
|
||||
if not self.value.strip():
|
||||
raise ValueError("Content cannot be empty or whitespace")
|
||||
if len(self.value) < self.MIN_LENGTH:
|
||||
raise ValueError(f"Content must be at least {self.MIN_LENGTH} characters")
|
||||
if len(self.value) > self.MAX_LENGTH:
|
||||
raise ValueError(f"Content must be at most {self.MAX_LENGTH} characters")
|
||||
41
app/domain/value_objects/slug.py
Normal file
41
app/domain/value_objects/slug.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Slug value object for URL-friendly identifiers."""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.domain.value_objects.base import ValueObject
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Slug(ValueObject[str]):
|
||||
"""URL slug value object."""
|
||||
|
||||
MAX_LENGTH: int = 200
|
||||
SLUG_PATTERN: str = r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
||||
|
||||
def _validate(self) -> None:
|
||||
if not isinstance(self.value, str):
|
||||
raise ValueError("Slug must be a string")
|
||||
if len(self.value) > self.MAX_LENGTH:
|
||||
raise ValueError(f"Slug must be at most {self.MAX_LENGTH} characters")
|
||||
if not re.match(self.SLUG_PATTERN, self.value):
|
||||
raise ValueError(
|
||||
"Slug must contain only lowercase letters, numbers, and hyphens"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_title(cls, title: str) -> "Slug":
|
||||
"""Generate slug from title."""
|
||||
# Convert to lowercase, replace spaces with hyphens
|
||||
slug = title.lower().strip()
|
||||
# Keep only alphanumeric, spaces, and hyphens
|
||||
slug = re.sub(r"[^a-z0-9\s-]", "", slug)
|
||||
# Replace spaces and multiple hyphens with single hyphen
|
||||
slug = re.sub(r"[-\s]+", "-", slug)
|
||||
# Limit length and strip hyphens
|
||||
max_len = 200 # Same as MAX_LENGTH
|
||||
slug = slug[:max_len].strip("-")
|
||||
# Ensure we have at least one character
|
||||
if not slug:
|
||||
slug = "post"
|
||||
return cls(value=slug)
|
||||
23
app/domain/value_objects/title.py
Normal file
23
app/domain/value_objects/title.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Title value object."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.domain.value_objects.base import ValueObject
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Title(ValueObject[str]):
|
||||
"""Blog post title value object."""
|
||||
|
||||
MIN_LENGTH: int = 3
|
||||
MAX_LENGTH: int = 200
|
||||
|
||||
def _validate(self) -> None:
|
||||
if not isinstance(self.value, str):
|
||||
raise ValueError("Title must be a string")
|
||||
if len(self.value) < self.MIN_LENGTH:
|
||||
raise ValueError(f"Title must be at least {self.MIN_LENGTH} characters")
|
||||
if len(self.value) > self.MAX_LENGTH:
|
||||
raise ValueError(f"Title must be at most {self.MAX_LENGTH} characters")
|
||||
if not self.value.strip():
|
||||
raise ValueError("Title cannot be empty or whitespace")
|
||||
Reference in New Issue
Block a user