Jak pracować z repozytoriami w czystej architekturze

0

Implementuje "czystą architekturę" w pythonie i zastanawiam się jak pracować z repozytoriami w use case'ach przy założeniu, że mogą pojawić się przypadki wymagające kilku repozytoriów korzystające z różnych źródeł danych oraz wymagające transakcji, żeby wszystkie operacje na repozytoriach w use case były atomowe.

Do obsługi transakcji zaimplementowałem klase unit_of_work.py, której interfejs wygląda tak:

class AsyncUnitOfWorkProtocol(Protocol):
    async def __aenter__(self) -> Self: ...
    async def __aexit__(self, exc_type, exc, tb) -> None: ...
    async def commit(self) -> None: ...
    async def rollback(self) -> None: ...

Rozważam głównie dwie opcje:

Opcja 1:

Controller otwiera context manager klasy unit_of_work.py tym samym otwierając transkacje. Wewnątrz tworzę use case przekazując repozytorium. Tutaj też są dwie opcje:

  1. Repozytorium importowane oddzielnie
  2. Repozytorium przypisane do pola klasy unit_of_work.py, żeby Controller nie musiał wiedzieć jak utworzyć instancje repozytorium

Przykład:

@router.post(
    "/user/",
    status_code=status.HTTP_201_CREATED,
)
async def create_user(user_data: CreateUser):
    uow = AsyncSqlalchemyFirebaseUnitOfWork(async_session_factory, SagaFactory())

    async with uow:
        use_case = CreateUser(uow.user_repository)
        await use_case.execute(input_dto)

    # ...

Opcja 2:

Przekazanie do use case obiektu unit_of_work.py zamiast repozytorium. To ma kilka implikacji:

  1. Use case jest odpowiedzialny za obsługę transkacji.
  2. Nie jest wiadome z góry z jakich repozytoriów korzysta use case. Otrzymuje obiekt uow, który zawiera wszystkie repozytoria obsługiwane przez ten uow.
  3. commit/rollback są wykonywane wewnątrz use case, a nie w controllerze.

Na początku stosowałem podejście drugie, ale co w sytuacji, gdy będę potrzebował jedno repo z sqlalchemy, a drugie repo z jakiegoś mikroserwisu. Bedą wtedy dwie klasy: SqlalchemyUnitOfWork i np. SagaUnitOfWork, wtedy będę musiał przekazać dwa obiekty uow do use case, a to już wprowadza mały bałagan, nawet nie wiadomo jak określić argumenty - uow_a, uow_b? Którego użyć żeby skorzystać z repo A? itp.

Często też błąd transakcji rzucany jest w momencie wywołania commit() zatem to czy transakcja obsługiwana jest w use case czy w warstwie wyższej też ma znaczenie, np. w przypadku utworzenia transakcji w controllerze mógłbym dodać użytkownika do sesji w use case i wysłać email aktywacyjny, a po przekazaniu sterowania do controllera nastąpi wywołanie commit(), które wywoła rollback() jeśli wystąpi błąd i email zostanie wysłany, mimo, że użytkownik nie został utworzony.

Czy ktoś już rozwiązywał podobny problem i znalazł optymalne rozwiązanie?

1

A po co repozytorium? Impulsem to tworzenia repozytoriów było DDD i konieczność łatwego pobierania agregatów i zapisywania ich stanu.

Jeżeli twoja aplikacja działa na anemicznych encjach to olej dodatkowe abstrakcje i używaj normalnie tego co oferuje ORM. Będzie łatwiej, szybciej i czytelniej.

A jeśli chcesz użyć argumentu izolacji warstwy persystencji od logiki biznesowej/aplikacyjnej , to dopóki masz jeden model warstwa persystencji będzie przenikać w głąb aplikacji w ten czy inny sposób. Jedynym sensownym sposobem na izolację warstw jest użycie dwóch modeli: biznesowego i persystencji i odpowiednie mapowanie pomiędzy nimi. Coś na wzór DTO i modelu biznesowego.

0

Rozumiem że chodzi o coś takiego że chcesz np. stworzyć usera razem np. z jego avatarem, gdzie dane o userze idą do bazy, a avatar idzie do czegoś co trzyma system plików, i masz np. UserRepository (które gada z bazą) oraz AvatarFsAdapter (które gada z systemem plików)?

Jeśli tak, to zastanawiam się jaki masz problem konkretnie? 🤔 Czy chodzi o to żeby logika use-case'u była w use-case'ie, a szczegóły implementacyjne repozytoriów w innym? Bo jeśli tak, to ja proponowałbym podejście outside-in, czyli zacząłbym od tego zdania które napisałeś: zastanawiam się jak pracować z repozytoriami w use case'ach przy założeniu [...] kilku repozytoriów korzystające z różnych źródeł [...], żeby wszystkie operacje na repozytoriach w use case były atomowe.

Jeśli operacje wynikające z use-case'u mają być atomowe, to moim zdaniem oznacza to tyle, że use-case powinien wypluć pojedynczą komendę i nie wiedzieć nic o repozytoriach, np tak:

@router.post(
    "/user/",
    status_code=status.HTTP_201_CREATED,
)
async def create_user(user_data: CreateUser):
    use_case = CreateUserWithAvatar(user_data)
    dto = use_case.createUserResult()

    repo = UserWithAvatarRepository()
    repo.atomicInsert(dto)
    
class CreateUserWithAvatar:
  def __init__(self, user_data):
    pass

  def createUserResult(self,): CreateUserWithAvatarDto
    # tutaj cała logia use-case'u związana z decyzją jakie dane
    # wsadzić gdzie
    return CreateUserWithAvatarDto()
@dataclass
class CreateUserWithAvatarDto:
   username:str
   password:str
   display_name:str
   avatar_filename: str
   avatar_size: int
   avatar_format: str
class UserWithAvatarRepository:
  def __init__(self, users:UserRepository, avatars:AvatarFsAdapter):
    pass

  def atomicInsert(self, dto: CreateUserWithAvatarDto)
    # tutaj zapnij odpowiednie transakcje, dodaj
    # wartości które dostałeś z dto, ewentualnie zrób
    # rollback
    pass

Zalety takiego podejścia są takie:

  • Use-case nie wie nic o tym jak dane będą zapisane, to dobrze
  • Repo UserWithAvatarRepository nie wie nic o logice use-case'u, dostaje tylko komendę z danymi które ma wsadzić. Moim zdaniem to też dobrze.

Zarejestruj się i dołącz do największej społeczności programistów w Polsce.

Otrzymaj wsparcie, dziel się wiedzą i rozwijaj swoje umiejętności z najlepszymi.