This commit is contained in:
e4www
2026-01-30 16:34:56 +03:00
commit 12f2ceaf9b
27 changed files with 1331 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID
@dataclass(frozen=True)
class UserDTO:
id: UUID
email: str
full_name: str
is_active: bool
created_at: datetime

View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
from app.application.users.ports.user_repository import UserRepository
@runtime_checkable
class UnitOfWork(Protocol):
users: UserRepository
async def __aenter__(self) -> "UnitOfWork": ...
async def __aexit__(self, exc_type, exc, tb) -> None: ...
async def commit(self) -> None: ...
async def rollback(self) -> None: ...

View File

@@ -0,0 +1,12 @@
from __future__ import annotations
from typing import Protocol
from uuid import UUID
from app.domain.users.entities import User
class UserRepository(Protocol):
async def add(self, user: User) -> None: ...
async def get_by_id(self, user_id: UUID) -> User | None: ...
async def get_by_email(self, email: str) -> User | None: ...

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
from dataclasses import dataclass
from uuid import uuid4
from app.application.users.dto import UserDTO
from app.application.users.ports.unit_of_work import UnitOfWork
from app.domain.users.entities import User
from app.domain.users.services import ensure_unique_email
from app.domain.users.value_objects import Email
@dataclass(frozen=True)
class CreateUserCommand:
email: str
full_name: str
class CreateUserUseCase:
"""
Inbound port (use case).
Outbound ports: UnitOfWork -> UserRepository, and email integration.
"""
def __init__(self, uow: UnitOfWork, welcome_email_sender) -> None:
self._uow = uow
self._welcome_email_sender = welcome_email_sender
async def execute(self, cmd: CreateUserCommand) -> UserDTO:
email_vo = Email(cmd.email)
async with self._uow:
existing = await self._uow.users.get_by_email(str(email_vo))
ensure_unique_email(existing_user=existing)
user = User.register(
user_id=uuid4(), email=email_vo, full_name=cmd.full_name
)
await self._uow.users.add(user)
await self._uow.commit()
for ev in user.pull_events():
from app.domain.users.events import UserRegistered
if isinstance(ev, UserRegistered):
await self._welcome_email_sender.send_welcome(
to_email=ev.email, full_name=cmd.full_name
)
return UserDTO(
id=user.id,
email=str(user.email),
full_name=user.full_name,
is_active=user.is_active,
created_at=user.created_at,
)

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from app.application.users.dto import UserDTO
from app.application.users.ports.unit_of_work import UnitOfWork
from app.domain.users.exceptions import UserNotFound
@dataclass(frozen=True)
class GetUserQuery:
user_id: UUID
class GetUserUseCase:
def __init__(self, uow: UnitOfWork) -> None:
self._uow = uow
async def execute(self, q: GetUserQuery) -> UserDTO:
async with self._uow:
user = await self._uow.users.get_by_id(q.user_id)
if user is None:
raise UserNotFound("User not found")
return UserDTO(
id=user.id,
email=str(user.email),
full_name=user.full_name,
is_active=user.is_active,
created_at=user.created_at,
)

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Generic, TypeVar
TId = TypeVar("TId")
@dataclass(eq=False)
class Entity(Generic[TId]):
id: TId
def __eq__(self, other: object) -> bool:
if not isinstance(other, Entity):
return False
return self.id == other.id and self.__class__ is other.__class__
def __hash__(self) -> int:
return hash((self.__class__, self.id))

View File

@@ -0,0 +1,10 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class ValueObject:
"""Marker base class for value objects."""
pass

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from uuid import UUID
from app.domain.shared.base_entity import Entity
from app.domain.users.events import UserRegistered
from app.domain.users.value_objects import Email
@dataclass(eq=False)
class User(Entity[UUID]):
email: Email
full_name: str
is_active: bool = True
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
_events: list[object] = field(default_factory=list, init=False, repr=False)
@property
def events(self) -> list[object]:
return list(self._events)
def pull_events(self) -> list[object]:
ev, self._events = self._events, []
return ev
@classmethod
def register(cls, *, user_id: UUID, email: Email, full_name: str) -> "User":
user = cls(id=user_id, email=email, full_name=full_name)
user._events.append(
UserRegistered(
user_id=user_id,
email=str(email),
occurred_at=datetime.now(timezone.utc),
)
)
return user

View File

@@ -0,0 +1,12 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID
@dataclass(frozen=True)
class UserRegistered:
user_id: UUID
email: str
occurred_at: datetime

View File

@@ -0,0 +1,14 @@
class DomainError(Exception):
pass
class InvalidEmail(Exception):
pass
class UserAlreadyExists(Exception):
pass
class UserNotFound(Exception):
pass

View File

@@ -0,0 +1,9 @@
from __future__ import annotations
from app.domain.users.entities import User
from app.domain.users.exceptions import UserAlreadyExists
def ensure_unique_email(*, existing_user: User | None) -> None:
if existing_user is not None:
raise UserAlreadyExists("User with this email already exists.")

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from app.domain.shared.base_value_object import ValueObject
from app.domain.users.exceptions import InvalidEmail
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@dataclass(frozen=True)
class Email(ValueObject):
value: str
def __post_init__(self) -> None:
if not _EMAIL_RE.match(self.value):
raise InvalidEmail(f"Invalid email: {self.value}")
def __str__(self) -> str:
return self.value

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from datetime import datetime
from uuid import uuid4
from sqlalchemy import Boolean, DateTime, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class UserModel(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(
String(320), primary_key=True, default=lambda: str(uuid4())
)
full_name: Mapped[str] = mapped_column(String(200), nullable=False)
email: Mapped[str] = mapped_column(
String(320), unique=True, index=True, nullable=False
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.users.ports.user_repository import UserRepository
from app.domain.users.entities import User
from app.domain.users.value_objects import Email
from app.infrastructure.db.sqlalchemy.models import UserModel
class SqlAlchemyUserRepository(UserRepository):
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def add(self, user: User) -> None:
model = UserModel(
id=str(user.id),
email=str(user.email),
full_name=user.full_name,
is_active=user.is_active,
created_at=user.created_at,
)
self._session.add(model)
async def get_by_id(self, user_id: UUID) -> User | None:
stmt = select(UserModel).where(UserModel.id == str(user_id))
res = await self._session.execute(stmt)
model = res.scalar_one_or_none()
if model is None:
return None
return User(
id=UUID(model.id),
email=Email(model.email),
full_name=model.full_name,
is_active=model.is_active,
created_at=model.created_at,
)
async def get_by_email(self, email: str) -> User | None:
stmt = select(UserModel).where(UserModel.email == email)
res = await self._session.execute(stmt)
model = res.scalar_one_or_none()
if model is None:
return None
return User(
id=UUID(model.id),
email=Email(model.email),
full_name=model.full_name,
is_active=model.is_active,
created_at=model.created_at,
)

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
import os
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./app.db")
_engine: AsyncEngine = create_async_engine(DATABASE_URL, echo=False, future=True)
_sessionmaker: async_sessionmaker[AsyncSession] = async_sessionmaker(
bind=_engine, expire_on_commit=False
)
def get_engine() -> AsyncEngine:
return _engine
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
return _sessionmaker

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.application.users.ports.unit_of_work import UnitOfWork
from app.infrastructure.db.sqlalchemy.repositories.user_repository import (
SqlAlchemyUserRepository,
)
class SqlAlchemyUnitOfWork(UnitOfWork):
def __init__(self, sessionmaker: async_sessionmaker[AsyncSession]) -> None:
self._sessionmaker = sessionmaker
self._session: AsyncSession | None = None
self.users = None
async def __aenter__(self) -> "SqlAlchemyUnitOfWork":
self._session = self._sessionmaker()
self.users = SqlAlchemyUserRepository(self._session)
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
if exc:
await self.rollback()
await self._session.close() # type: ignore[union-attr]
async def commit(self) -> None:
await self._session.commit() # type: ignore[union-attr]
async def rollback(self) -> None:
await self._session.rollback() # type: ignore[union-attr]

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import os
from email.message import EmailMessage
import aiosmtplib
class SmtpWelcomeEmailSender:
def __init__(self) -> None:
self._host = os.getenv("SMTP_HOST", "localhost")
self._port = int(os.getenv("SMTP_PORT", "1025"))
self._username = os.getenv("SMTP_USERNAME")
self._password = os.getenv("SMTP_PASSWORD")
self._from_email = os.getenv("SMTP_FROM_EMAIL", "noreply@example.com")
self._use_tls = os.getenv("SMTP_USE_TLS", "false").lower() == "true"
async def send_welcome(self, *, to_email: str, full_name: str) -> None:
msg = EmailMessage()
msg["From"] = self._from_email
msg["To"] = to_email
msg["Subject"] = "Welcome!"
msg.set_content(f"Hi, {full_name}! Welcome to our service.")
await aiosmtplib.send(
msg,
hostname=self._host,
port=self._port,
username=self._username,
password=self._password,
start_tls=self._use_tls,
)

View File

@@ -0,0 +1,10 @@
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter(prefix="/health", tags=["health"])
@router.get("")
async def health():
return {"status": "ok"}

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from app.application.users.use_cases.create_user import (
CreateUserCommand,
CreateUserUseCase,
)
from app.application.users.use_cases.get_user import GetUserQuery, GetUserUseCase
from app.domain.users.exceptions import InvalidEmail, UserAlreadyExists, UserNotFound
from app.presentation.dependencies.users import get_create_user_uc, get_get_user_uc
from app.presentation.schemas.users import CreateUserRequest, UserResponse
router = APIRouter(prefix="/users", tags=["users"])
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
req: CreateUserRequest,
uc: CreateUserUseCase = Depends(get_create_user_uc),
) -> UserResponse:
try:
dto = await uc.execute(
CreateUserCommand(email=str(req.email), full_name=req.full_name)
)
return UserResponse(**dto.__dict__)
except InvalidEmail as e:
raise HTTPException(status_code=400, detail=str(e))
except UserAlreadyExists as e:
raise HTTPException(status_code=409, detail=str(e))
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: UUID,
uc: GetUserUseCase = Depends(get_get_user_uc),
) -> UserResponse:
try:
dto = await uc.execute(GetUserQuery(user_id=user_id))
return UserResponse(**dto.__dict__)
except UserNotFound as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from fastapi import Depends
from app.application.users.use_cases.create_user import CreateUserUseCase
from app.application.users.use_cases.get_user import GetUserUseCase
from app.infrastructure.db.sqlalchemy.session import get_sessionmaker
from app.infrastructure.db.sqlalchemy.uow import SqlAlchemyUnitOfWork
from app.infrastructure.integrations.email.smtp_sender import SmtpWelcomeEmailSender
def get_uow() -> SqlAlchemyUnitOfWork:
return SqlAlchemyUnitOfWork(get_sessionmaker())
def get_email_sender() -> SmtpWelcomeEmailSender:
return SmtpWelcomeEmailSender()
def get_create_user_uc(
uow: SqlAlchemyUnitOfWork = Depends(get_uow),
email_sender: SmtpWelcomeEmailSender = Depends(get_email_sender),
) -> CreateUserUseCase:
return CreateUserUseCase(uow=uow, welcome_email_sender=email_sender)
def get_get_user_uc(
uow: SqlAlchemyUnitOfWork = Depends(get_uow),
) -> GetUserUseCase:
return GetUserUseCase(uow=uow)

28
app/presentation/main.py Normal file
View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from fastapi import FastAPI
from app.infrastructure.db.sqlalchemy.models import Base
from app.infrastructure.db.sqlalchemy.session import get_engine
from app.presentation.api.v1.health import router as health_router
from app.presentation.api.v1.users import router as users_router
def create_app() -> FastAPI:
app = FastAPI(title="DDD Onion FastAPI Demo", version="1.0.0")
app.include_router(health_router)
app.include_router(users_router)
@app.on_event("startup")
async def on_startup() -> None:
# демо-миграция: создать таблицы при старте
# в проде делай Alembic, но файл models.py уже готов.
engine = get_engine()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
return app
app = create_app()

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field
class CreateUserRequest(BaseModel):
email: EmailStr
full_name: str = Field(min_length=1, max_length=200)
class UserResponse(BaseModel):
id: UUID
email: EmailStr
full_name: str
is_active: bool
created_at: datetime