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:
2026-05-01 20:20:41 +03:00
parent b8334efa5a
commit 87b094220d
75 changed files with 2783 additions and 459 deletions

34
app/domain/__init__.py Normal file
View 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",
]

View File

@@ -0,0 +1,6 @@
"""Domain entities."""
from app.domain.entities.base import BaseEntity
from app.domain.entities.post import Post
__all__ = ["BaseEntity", "Post"]

View 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."""
...

View 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
View 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

View File

@@ -0,0 +1,6 @@
"""Repository interfaces."""
from app.domain.repositories.base import Repository
from app.domain.repositories.post import PostRepository
__all__ = ["Repository", "PostRepository"]

View 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."""
...

View 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."""
...

View 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"]

View 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

View 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")

View 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)

View 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")