Clean architecture w Springu

0

Wzoruje się na Clean Architecture i mam kilka wątpliwości.
screenshot-20190803230846.png

Struktura mojego projektu to obecnie
screenshot-20190803231214.png

W domain/user/port mam zdefiniowany interfejs oraz jakiś serwis "biznesowy", który ma mi zwrócić dane personalne uzytkownika

public interface UserRepository {
    Optional<User> findUser(String username);
}

@RequiredArgsConstructor
public class UserReader {
    private final UserRepository repository;

    public Optional<PersonalDetailsDto> readPersonalData(String username) {
        return repository.findUser(username)
                .map(User::getPersonalDetails)
                .map(PersonalDetailsDto::new);
    }
}

W tej części aplikacji nie używam nic związanego z frameworkiem, tak więc klasy reprezentujące encje są pozbawione adnotacji SpringData

@Getter
@Builder
public class User {
    private final String username;
    private final String password;
    private final String[] roles;
    private final PersonalDetails personalDetails;
}

Warstwa controllerów, będzie dostawać Optional'a bądź Either'a i jedyne co tu chcę robić to mapować ewentualny Either.left() na odpowiedni kod HTTP. Tak więc DTO z odpowiednim setem danych będzie przygotowywane przy wyjściu z serwisów w warstwie biznesowej.

@Getter
public class PersonalDetailsDto {
    private final String firstName;
    private final String lastName;
    private final String gender;
    private final String birthDate;
    private final String weight;
    private final String height;

    public PersonalDetailsDto(PersonalDetails personalDetails) {
        this.firstName = personalDetails.getFirstName();
        this.lastName = personalDetails.getFirstName();
        this.gender = personalDetails.getGender().getValue();
        this.birthDate = personalDetails.getBirthDate()
                .format(DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL)
                .withLocale(Locale.UK));
        this.weight = personalDetails.getWeight() + " kg";
        this.height = personalDetails.getHeight() + " cm";
    }
}

Do tej pory ma to sens, mapuje sobie rzeczy jak Gender.MALE na Mężczyzna, czy tam parsuje w różny sposób datę, w zależności od tego co potrzebuje dostać na wyjściu w danym wypadku.

Wątpliwości zaczynam mieć przy warstwie encji bazodanowych.
Obecnie moja implementacja UserRepository wygląda tak

@Component
public class InMemoryUserRepository implements UserRepository {
    private final Map<String, User> users = new HashMap<>();

    public InMemoryUserRepository() {
        users.put("admin", User.builder()
                .username("admin")
                .password("pass")
                .roles(new String[] {"ADMIN"}).build());

        users.put("user", User.builder()
                .username("user")
                .password("pass")
                .roles(new String[] {"USER"})
                .personalDetails(PersonalDetails.builder()
                        .firstName("John")
                        .lastName("Doe")
                        .gender(Gender.MALE)
                        .height(183.0)
                        .weight(90.0)
                        .birthDate(LocalDate.of(1993, 5, 20))
                .build())
                .build());
    }

    @Override
    public Optional<User> findUser(String username) {
        return Optional.ofNullable(users.get(username));
    }
}

Jednak docelowo, będzie tutaj musiała powstać dodatkowa klasa, reprezentująca użytkownika w bazie danych. Czyli praktycznie kopia tego co mam w warstwie domeny z dodatkowym polem ID + innymi adnotacjami SpringData. Pytanie czy to już nie zaczyna być przerost nad treścią, czy może kompletnie źle zrozumiałem jak ma wyglądać taki podział architektury?

1

Generalnie tak, powinny być jakieś obiekty które modelują procesy biznesowe (tak, żeby je ładnie zamodelować), osobne zbiory obiektów jako encje bazodanowe (tak żeby zoptymalizować wykozystanie bazy) i inne obiekty jako dtosy API aplikacji.

Problem jest je przy prostych projektach nie zauważysz różnicy między nimi, bo "domenta" jest zbyt prosta

4

Prawie dobrze, widać progres z typowej architektury warstwowej, a teraz musisz zacząć budować moduły, bo w pakiecie entity masz User, Gender i PersonalDetails, co nie łączy się jakiś koncept dla mnie, tym bardziej, że inne rzeczy typu UserRepository, UserReader, UserConfiguration` (kontroler pomine, bo jest w spoko miejscu) sa porozrzucane każdy gdzie indziej. Włóż je w jedno miejsce, stwórz jakieś publiczne API, cała reszta ma byc niedostępna poza API, domenę zacznij wpychać jak najwięcej do obiektów, jak najmniej do serwisów i bedzie ok. Generalnie masz już tam jakieś Secuirty, kontrolery, a nie widać czemu ma to służyć i co ten kod ma robić

Żeby nie zostawić się z niczym - nie zaczynaj od d**y strony. Kompletnie olej Springa. Jednym z założeń Clean Architecture jest to, by kod domenowy (biznesowy) był odseparowany od warstwy aplikacji i infrastruktury, żeby był całkowicie odporny na zmiany w nich. Wymyśl sobie jakąś apke, która ma coś robić, może być to sklep internetowy, forum, whatever. Napisz kod domenowy, który bedzie realizował jakies zadanie, bez Springa. Podejść jest wiele, ja polecam package by component, czyli każdy moduł ładujesz do osobnego pakietu. Jedyne co ma być dostepne publicznie z tego pakietu to jakieś obiekty api, typu dtosy (one mogą być w podpakiecie), jakis punkt wejścia do modułu (wzorzec fasady jest bardzo ok), jakaś klasa, która pozwoli zbudować moduł oraz interfejsy, które w javie muszą byc publiczne żeby mogły być zaimplementowane poza pakietem (generalnie implementacja np. Repository nie powinna być w tym samym pakiecie co kod domenowy). Do tego oczywiście napisz testy, które będą testować wszystkie przypadki użycia tak jakbyś korzystał z tego modułu z zewnątrz, czyli poprzez fasadę czy co innego tam wymyślisz. Klasę User masz niemutowalną, to fajnie, ale encje domenowe wcale nie muszą być niemutowalne - dowolność twórcy. Za to mają gettery na wszystkie pola, czyli nic nie enkapsulują, średnio też z abstrakcją. Niech te obiekty zaczną mieć jakieś zachowania, zamiast pokazywać jakie mają pola. Jak już to zrobisz to kolejną wążną rzeczą jest dodanie kolejnego modułu i komunikacja między nimi. Wszystkie dobre zasady OOP mają też przełożenie na budowanie modułów, np brak cyklicznych zależności. Jak już będziesz miał kilka modułów, logikę biznesową, dobre testy, wtedy tam będziesz mógł dodać Springa, Swinga, implementację repo, która będzie wrzucać do bazy danych.

0
hcubyc napisał(a):

Prawie dobrze, widać progres z typowej architektury warstwowej, a teraz musisz zacząć budować moduły, bo w pakiecie entity masz User, Gender i PersonalDetails, co nie łączy się jakiś koncept dla mnie,

@Getter
@Builder
public class User {
    private final String username;
    private final String email;
    private final String password;
    private final String[] roles;
    private final boolean active;
    private final PersonalDetails personalDetails;
}

@Getter
@Builder
public class PersonalDetails {
    private final String firstName;
    private final String lastName;
    private final Gender gender;
    private final LocalDate birthDate;
    private final Double weight;
    private final Double height;
}

public enum Gender {
    MALE("Mężczyzna"), FEMALE("Kobieta");
    private final String value;
    Gender(String value) {
        this.value = value;
    }
    public String getValue() {
        return value;
    }
}

To widziałem w taki sposób połączone, tworząc 2 klasy dla użytkownika i jego danych personalnych chciałem odseparować czysto technicznego użytkownika od tego bardziej biznesowego.

tym bardziej, że inne rzeczy typu UserRepository,UserReader,UserConfiguration` (kontroler pomine, bo jest w spoko miejscu) sa porozrzucane każdy gdzie indziej.

Tutaj cały koncept rozumiem tak
-UserReader - to jest mój useCase, klasa będzie odpowiedzialna za zwracanie danych użytkownika w różny sposób.
-UserRepository - port mojej domeny, czyli szczegół z jej punktu widzenia który powinienem zaimplementować poza nią, w tym przypadku mam InMemory, ale prosto mogę wpiąć tutaj Mongo albo Postgresa.
-UserConfiguration - warstwa konfiguracji z tego względu, że tutaj realizuje DI dla useCaseów mojej domeny, czyli wstrzykuje odpowiednie repository póki co.

Dlatego jest to wszystko trochę porozrzucane.

Włóż je w jedno miejsce, stwórz jakieś publiczne API, cała reszta ma byc niedostępna poza API, domenę zacznij wpychać jak najwięcej do obiektów, jak najmniej do serwisów i bedzie ok.
Podejść jest wiele, ja polecam package by component, czyli każdy moduł ładujesz do osobnego pakietu.

Nawiązujesz do czegoś takiego?
Powiedzmy że zamykam wszystko w package scope, publiczna zostaje fasada, dtosy no i interfejsy, których metody zwracają obiekt z domeny, obiekt który jest package scoped. Jak to zaimplementować poza tym pakietem?

Kompletnie olej Springa. Jednym z założeń Clean Architecture jest to, by kod domenowy (biznesowy) był odseparowany od warstwy aplikacji i infrastruktury, żeby był całkowicie odporny na zmiany w nich.

Jednym z założeń tego side projektu było własnie poćwiczenie zaimplementowania "czystego" połączenia między logika biznesową a frameworkiem, do tego to co chcę zrealizować praktycznie od początku wymaga zalogowanego użytkownika, tak więc wrzuciłem już prostą konfigurację Security opartą o JWT.

Niech te obiekty zaczną mieć jakieś zachowania, zamiast pokazywać jakie mają pola.

A o tym nie myślałem, mam widocznie mocno zakorzenione stare podejście architektury warstwowej i entity, nawet jeżeli jest to encja biznesowa traktuje jak worek na dane, a wszelką logikę planowałem robić w useCaseach.

3

Nawiązujesz do czegoś takiego?

Tak. Jak pisałem, to jedno z podejść do CA, generalnie polecam ksiazke uncle boba - Clean Architecture, tam będzie to opisane.

Powiedzmy że zamykam wszystko w package scope, publiczna zostaje fasada, dtosy no i interfejsy, których metody zwracają obiekt z domeny, obiekt który jest package scoped. Jak to zaimplementować poza tym pakietem?

Interfejsy i fasada zwracają DTOsy, które muszą byc publiczne, żeby dalo się z tego korzystać i implementować - więc fasada, przyjmuje jakiegoś DTOsy - typowe struktury danych, wewnatrz operuje na obiektach domenowych i na wyjściu znów mapuje na DTOsy, exceptiony też będa API domeny jeżeli bedziesz je rzucać.

Jednym z założeń tego side projektu było własnie poćwiczenie zaimplementowania "czystego" połączenia między logika biznesową a frameworkiem, do tego to co chcę zrealizować praktycznie od początku wymaga zalogowanego użytkownika, tak więc wrzuciłem już prostą konfigurację Security opartą o JWT.

ale jednym z założeń CA jest to, żeby to infrastruktura zależala od domeny, a nie odwrót i żeby domena była maksymalnie abstrakcyjna i żadna cześc infrastruktry nie wpadła do domeny (czy to jako klasa czy nawet abstrakcja). Stawia to pewne wyzwania przed developerem, bo faktycznie domena może potrzebować użytkownika z jakimiś rolami czy coś w ten deseń, wtedy musisz nałożyć na to abstrakcję, a potem po prostu zaimplementować, może być przez JWT. Generalnie uważam, że robienie tego dobrze wymaga zdecydowanie więcej niż jednego projektu i wielu prób i błędów - musisz próbować wielu podejść, np. walidacja w encji, walidacja w obiektach, to jak zmapujesz sobie 'security' na koncept domenowy i wiele innych. Aczkolwiek moim zdaniem zdecydowanie warto, bo za każdą 'iteracją' będziesz miał wrażenie, że robisz to co raz lepiej. Jedno z podejść jakie widziałem to np. customowe adnotacje w domenie. Np chcesz dodać metryki w kodzie, żeby np mierzyć ile czasu zajmuje domenie (i wszystkim jej zależnościa) przetworzenie requestu użytkownika, można to zrobić tak, że po prostu wystawiasz interfejs gdzies poza domeną, który udostepnia API metryk, a można np. zrobić customową adnotację, która potem przez np AOP bedzie mierzyła wykonani danej metody. Różnie to ludzie interpretują.

1

Wygląda to teraz tak, InMemoryUserRepository póki co operuje na dto zamiast encji bazodanowej.
screenshot-20190804130656.png
Wewnątrz domeny jest jedna klasa która łączy ją ze Springiem, tego się nie dało uniknąć

@Configuration
class UserConfiguration {
    private final UserRepository userRepository = new InMemoryUserRepository();
    private final VerificationTokenRepository verificationTokenRepository = new InMemoryVerificationTokenRepository();

    @Bean
    UserFacade userFacade() {
        ReadUserUseCase readUser = new ReadUserUseCase(userRepository);
        RegisterUserUseCase registerUser = new RegisterUserUseCase(userRepository, verificationTokenRepository);
        return new UserFacade(readUser, registerUser);
    }
}

Dtosy, interfejsy, fasada są publiczne i to nimi komunikuje się z innymi warstwami aplikacji, klasy encji biznesowych oraz useCaseów są package scoped. Wymusza to jednak zwracanie dtosów z tych interfejsów

public interface UserRepository {
    Optional<UserDto> findUser(String username);
    void save(UserDto user);
}
public interface VerificationTokenRepository {
    UUID generateVerificationToken(String username);
}

Co wymaga dodatkowego mapowania

  Optional<PersonalDetailsDto> readPersonalData(String username) {
        return repository.findUser(username)
                .map(User::fromDto)
                .map(User::getPersonalDetails)
                .map(PersonalDetails::toDto);
    }

No i tutaj widzę minus takiego rozwiązania, wymaga dużo mapowań :/
SQL/NoSQL -> DatabaseEntity -> DTO -> DomainEntity -> DTO -> Web

Package scope wewnątrz domeny dodatkowo nie pozwala na dodatkowe rozbicie tego na mniejsze pakiety, a prosta domena będzie się rozrastać i to sporo.

Co do obiektów domenowych, rozbiłem to w taki sposób

@RequiredArgsConstructor
class RegisterUserUseCase {
    private final UserRepository userRepository;
    private final VerificationTokenRepository verificationTokenRepository;

    Either<UserError, UUID> register(RegisterUserDto registerUserDto) {
        if(userRepository.findUser(registerUserDto.getUsername()).isPresent())
            return Either.left(UserError.USERNAME_ALREADY_EXISTS);
        var userCreationResult = User.createUser(registerUserDto);
        return userCreationResult
            .map(user -> {
                userRepository.save(user.toDto());
                return verificationTokenRepository.generateVerificationToken(user.getUsername());
            });
    }
}
static Either<UserError, User> createUser(RegisterUserDto dto) {
        return validEmail(dto.getEmail()) ?
            Either.right(User.builder()
                    .username(dto.getUsername())
                    .password(dto.getPassword())
                    .email(dto.getEmail())
                    .active(false)
                    .roles(new String[] {"USER"})
                    .build()) :
            Either.left(UserError.INVALID_EMAIL);
    }

Docelowo oczywiście będzie więcej logiki po jednej i drugiej stronie, ale chodzi o ogólny szkic.
Email weryfikuje za pomocą apache-commons, nie pisałem tego z palca - chyba nie jest zabronione coś takiego wewnątrz obiektu domenowego? ;)
Wszelkie sugestie co zepsułem mile widziane ;)

1
AngryProgrammer napisał(a):

No i tutaj widzę minus takiego rozwiązania, wymaga dużo mapowań :/
SQL/NoSQL -> DatabaseEntity -> DTO -> DomainEntity -> DTO -> Web

Jeśli robisz coś co jest CRUDem to niestety tak będzie. Tego typu architektura pokazuje swoje zalety dopiero przy jakichś bardziej złożonych projektach.
Btw, dobrze, że używasz Either ;)

Zamiast sprawdzać Optional.isPresent, możesz użyć Option z vavra który ma metode toEither która robi to co robisz tam tym ifem ;)

1

Wewnątrz domeny jest jedna klasa która łączy ją ze Springiem, tego się nie dało uniknąć

nic złego

Wymusza to jednak zwracanie dtosów z tych interfejsów

tak ma być, domena komunikuje się przez API (fasada, interfejsy i DTOsy) ze swiatem zewnetrznym, a wewnatrz korzysta z niedostepnych publicznie obiektow domenowych

Email weryfikuje za pomocą apache-commons, nie pisałem tego z palca - chyba nie jest zabronione coś takiego wewnątrz obiektu domenowego?

nie jest zabronione, możesz tak robić.

Package scope wewnątrz domeny dodatkowo nie pozwala na dodatkowe rozbicie tego na mniejsze pakiety, a prosta domena będzie się rozrastać i to sporo.

Zazwyczaj jak się bardzo rozrośnie to czasem może być tak, że moduł sam w sobie jest za duży i trzeba go rozbić na mniejsze.

Wszelkie sugestie co zepsułem mile widziane

VerificationTokenRepository to IMHO zły poziom abstrakcji. Jaki token? Potrzebujesz dane o zalogowanym użytkowniku, token to sposób w jaki to realizujesz w implementacji, więc poziom abstrakcji nie ten
a no i jeżeli na rysunku jest zaznaczony Use Case to nie znaczy, że klasy muszą się tak nazywać, ale to jak wolisz ;)

1

Wewnątrz domeny jest jedna klasa która łączy ją ze Springiem, tego się nie dało uniknąć

nic złego

I na to też jest rozwiązanie – mapowianie w XMLu lub za pomocą configuration beans, a nie za pomocą adnotacji w samych klasach.

@AngryProgrammer jest kilka małych rzeczy, które cię trafią później np. używanie tłumaczeń w enumach. Czy parametr w MALE("mężczyzna") coś wnosi? Wywal do prezentera i niech on się martwi jak zinterpretować Gender.MALE. Poza tym

-UserRepository - port mojej domeny, czyli szczegół z jej punktu widzenia który powinienem zaimplementować poza nią, w tym przypadku mam InMemory, ale prosto mogę wpiąć tutaj Mongo albo Postgresa.

Port nie służy do tego. Port opisuje, jak można czegoś użyć. Ładnie to ktoś kiedyś ujął, że „port is for …ing something”. To, co chcesz tu osiągnąć, to… heksagon, czyli jakaś domena (zapis do bazy). Prawidłowo sklejone elementy powinny wyglądać mniej więcej tak:

UserSpace – interfejs udostępniający operacje – polecenia na użytkownikach
UserReader – interfejs udostępniający operacje – zapytania na użytkownikach
UserRepositoryConnector – adapter modułu zapisu do bazy danych (jakkolwiek rozumianej), który jest zależnością pakietu domain.user do technical.storage.user.

I w środku cała reszta, już niewidoczna poza pakietem.

0

A ja mam pytanie do podejścia Kuby Nabrdalika.
Stosuje je w pracy i namawiam kolegów z zespołu żeby też to robili (chociaż cięzko to idzie :( ). Ostatnio pojawił się argument jak stosować transakcyjność w tej fasadzie? Przy użyciu klasy konfigurującej (używając @Configuration i @Bean) nie mogę użyć adnotacji @Transactional :( Najlepszym (jedynym?) rozwiązaniem jest użycie tego o czym mowił @jarekr000000

ale to już jest za dużo "nie-springowych udziwnień" dla kolegów. Nie widzę innego rozwiązania tutaj. Macie jakieś pomysły?

0

Albo tak jak Jarek mówi, albo robisz jakiś własny (prosty) mechanizm transakcji na warstwie dostępu do bazy danych

0

Nie ma clean architecture w Springu.
Jak coś wewnątrz cebuli zależy od wstrzyknięć springowych to niejako kładzie to ideę.
Ale można Spring z clean architecture pogodzić. Tylko wtedy jest to clean architecture pomimo Springa.

1 użytkowników online, w tym zalogowanych: 0, gości: 1