Refactor uber encji - kilka pytań

0

Cześć.

Załóżmy, że mamy sobie legacy kod i uber encje User:

class User {
    private long id;
    private String firstName;
    private String lastName;
    private boolean confirmed;
    
    private String phoneNumber;
    private String pinToConfirmPhone;
    private boolean phoneConfirmed;
    
    // business methods
}

która stoi na jednej tabelce SQLowej USER z odpowiadającymi 1 do 1 polami.

Teraz chcemy sobie zrobić refactor i widzimy, że mamy tutaj część pól odpowiadającym rejestracji/potwierdzaniu użytkownika oraz część związana z kontaktem z nim za pomocą nr tel. Robimy sobie zatem osobne klasy:

class Registration {
    private long id;
    private String firstName;
    private String lastName;
    private boolean confirmed;

    // business methods

}

class PhoneContact {
    private long id;
    private String phoneNumber;
    private String pinToConfirmPhone;
    private boolean phoneConfirmed;

    // business methods

}

Mamy sobie teraz jakieś dwa osobne agregaty i rozbijamy je do osobnych modułów, a w przyszłości może nawet do osobnych mikroserwisów. W celu uproszczenia nie wspominam o tym, że przy refaktorze robię value objecty zamiast prymitywów.

Moje pytania i wątpliwości:

  1. Czy te dwa agregaty mogą sobie nadal leżeć na tej samej jednej tabeli USER czy też warto ją znormalizować i wydzielić tabelę PHONE_CONTACT?
  2. Tabele USER i PHONE_CONTACT były by tak naprawdę w relacji 1 do 1. Czy tabela PHONE_CONTACT powinna mieć swoje ID to samo co ID usera, czy powinna mieć raczej swój unikalny id i klucz obcy do USER w jakiejś kolumnie USER_ID? To wydaje mi się trochę sztuczne, bo i tak jest relacja 1 do 1. Mogę też nałożyć klucz obcy na kolumnę ID w tabeli PHONE_CONTACT i idki USER i PHONE_CONTACT będą takie same.
  3. Nie bardzo wiem, w którąś iść stronę jeśli chodzi o poprzednie 2 punkty i to też prowadzi do pewnych rozróżnień w domenie, a mówi się, że infra nie powinna wpływać na domenę. Dzieje się tak dlatego, że jeśli będą te 2 agregaty na jednej tabeli to przy wyciąganiu po id z repo PhoneContactdostanę ten obiekt z nullami w środku jeśli nikt z nim wcześniej nic nie robił. Id będę znał, bo w systemie najpierw następuje rejestracja a dopiero potem można updatować sobie nr. Jeśli natomiast będzie to osobna tabela to wyciągając po id pierwszy raz dostanę pustego Optionala. To wpływa na zmiany domenie ponieważ w zależności od rozwiązania w tabelkach w DB wystarczy mi albo samo findById po PhoneContact w przypadku jednej tabeli albo muszę zrobić findById + orElse(createNewPhone()) w przypadku dwóch rozdzielonych tabel. W którą stronę iść? Czy gdzieś źle kombinuję?

Pozdr.

2

Ja bym zrobił tak:

  1. Oddzielne tabele w bazie danych, żeby nie wprowadzać chaosu
  2. Klucz obcy USER_ID bez dodatkowego ID
2

Czy te dwa agregaty mogą sobie nadal leżeć na tej samej jednej tabeli USER czy też warto ją znormalizować i wydzielić tabelę PHONE_CONTACT?

Moim zdaniem to bez znaczenia. Cały myk w oderwaniu modelu bazodanowego od modelu domenowego polega na tym, że jedno niezależy od drugiego w żaden sposób. Jeśli możesz, to moze warto wydzielić osobną tabelę, ale równie dobrze może zostać tak jak jest, a ty po prostu zrobisz odpowiednie query.

Dzieje się tak dlatego, że jeśli będą te 2 agregaty na jednej tabeli to przy wyciąganiu po id z repo PhoneContact dostanę ten obiekt z nullami w środku jeśli nikt z nim wcześniej nic nie robił

Nie bardzo rozumiem problem. Jeśli ktoś jest zarejestrowany ale nie ma telefonu to co powinno się stać na poziomie domeny? To jest pytanie które musisz sobie zadać. Co więcej, przecież to TY implementujesz to repozytorium więc to ty decydujesz co zostaje zwrócone. Może tworzenie usera powinno automatycznie dodać mu też "pusty" rekord na numer telefonu? A może pusty optional jest poprawnym zachowaniem? To jest pytanie czysto biznesowe a nie techniczne.

0

@Shalom:

Chodzi o to, że jak wywołasz sobie jakies phone/update/id to przy jednej tabeli dostaniesz encje, ale w środku będą same nulle, a jeśli dany rekord nie będzie istniał to poleci wyjątek i to jest ok.

Jeśli natomiast dla tego updatu masz dwie tabele zapewne dostaniesz pierwszy raz empty optionala. Możesz wtedy oczywiście stworzyć sobie PhoneContact z nullami w orElsie, ale co jeśli dla danego id user naprawdę nie istnieje w systemie z jakiegoś dziwnego powodu (mineło np 100 lat i już dawno go usunięto w systemie, ale w jakiś sposób client go pamięta w swojej pamięci).

W tym przypadku zostaje albo klucz obcy na tabeli phone_contact, który będzie przed tym chronił, aby nie dodać telefonu dla usera, którego nie ma albo w logice albo jakaś walidacja sprawdzająca czy Registration dla danego id istnieje. Czyli dochodzimy do momentu, że 1 tabela vs 2 tabele jakoś wpływają na logikę domenową.

Mój biznes wygląda tak, żę najpierw się rejestrujesz podstawowymi danymi z agregatu Registration, a potem możesz sobie dodać dane telefoniczne (nawet po roku).

0

Czyli dochodzimy do momentu, że 1 tabela vs 2 tabele jakoś wpływają na logikę domenową.

Nie, dochodzimy do tego że to ZAWSZE jest decyzja biznesowa, tylko jak masz jedną tabelę to akurat domyślnie zachowanie jest przypadkiem takie jakie chciałeś mieć. Jeszcze raz: podchodzisz do tego od d**y strony. Zacznij od: jakie jest wymaganie biznesowe i jakie jest oczekiwane zachowanie

Co więcej: nie myl logiki domenowej z szczegółami technicznymi jak implementacja repozytorium. Wiadomo że struktura bazy wpływa na repozytorium bo albo masz tam select * from contact_details albo select x,y,z from user! Istotne jest co się dzieje później, na wyjściu z tego repozytorium. To ty decydujesz czy zwracasz pusty optional jeśli telefon jest pusty, czy robisz coś innego.

Zrób interfejs UserDataRepository i ustal jakiś kontrakt tego interfejsu i używaj w logice domenowej. Zapewniam cię że można będzie zaimplementować ten interfejs zarówno z jedną tabelą jak i z dwiema, bez żadnych problemów.

0

Ok, to ostatnie pytanie @Shalom .

Czy jeśli updatuję sobie PhoneContact na osobnych tabelach (registration oraz phone_contact) i dla danego ID Registration w ogóle nie istnieje w systemie to lepszym rowiązaniem jest sprawdzić w serwisie czy Registration istenieje po id odwołując się do RegistrationFacade czy po prostu założyć klucz obcy na tabeli, który zabroni stworzenia rekordu phone_contact dla nieistniejącego registration? To jest przypadek brzegowy spowodowany np tym, że apka kliencka ma jakieś mega stare dane w sobie.

0

Ja bym robił walidacje i tu i tu. Constraint na bazie nie zaszkodzi, ale i tak musisz mieć jakąś inną walidacje "biznesową". Reszta zależy od designu całego systemu, bo jestem w stanie wyobrazić sobie coś w stylu users.getById(id).updatePhoneNumber(number) gdzie wyciągasz obiekt domenowy User i używasz go do aktualizowania numeru, bo przecież i tak nigdy nikt nie odnosi się do numeru inaczej niż przez konkretnego Usera. Ale równie dobrze może chcesz tam zrobić jakieś userService.updateNumber(id, number). Nie ma prostej odpowiedzi nie wiedząc jak to w ogóle zaimplementowałeś.

0

@Shalom: U mnie to działa tak, że na front sobie idzie formatka ze wszystkimi danymi na temat usera i ta formatka jest sobie wyciągnięta ze zwykłego widoku bazodanowego, bo te wszystkie rzeczy i tak siedzą w jednym mikroserwisie. Czyli taki CQRS zrobiony prosto na jednej bazie.

Na podstawie tej formatki na froncie robimy albo update tel albo ustawienie nowego. Niemniej jednak ktoś tak zaprojektował backend, że to jest jeden i ten sam endpoint do obu operacji i jest to po prostu jakiś /update nawet jeśli nigdy tego numeru nie ustawialiśmy. Nie ma tego rozdzielonego na update i create. Jedynie napis na formatce rozróżnia to, że zmieniamy albo tworzymy nowy.

Zatem po stronie już samego backendu jest sobie coś w stylu

phoneContactFacade.updatePhone(id, phoneDataDto);

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