Modelować relacje z DDD (czy z rozsądkiem)?

9

Oto uproszczony wymóg:

Użytkownik tworzy Questionz wieloma Answers. Questionmusi mieć co najmniej jeden Answer.

Wyjaśnienie: pomyśl Questioni Answerjak w teście : jest jedno pytanie, ale kilka odpowiedzi, z których kilka może być poprawnych. Użytkownik jest aktorem, który przygotowuje ten test, dlatego tworzy pytania i odpowiedzi.

Staram się modelować ten prosty przykład, aby 1) dopasować do rzeczywistego modelu 2), aby był wyrazisty z kodem, aby zminimalizować potencjalne niewłaściwe użycie i błędy oraz dać wskazówki programistom, jak korzystać z modelu.

Pytanie jest bytem , a odpowiedź jest przedmiotem wartości . Pytanie zawiera odpowiedzi. Do tej pory mam te możliwe rozwiązania.

[A] Fabryka w środkuQuestion

Zamiast tworzyć Answerręcznie, możemy wywołać:

Answer answer = question.createAnswer()
answer.setText("");
...

To stworzy odpowiedź i doda ją do pytania. Następnie możemy manipulować odpowiedzią, ustawiając jej właściwości. W ten sposób tylko pytania mogą stworzyć odpowiedź. Ponadto zapobiegamy otrzymywaniu odpowiedzi bez pytania. Jednak nie mamy kontroli nad tworzeniem odpowiedzi, ponieważ jest to na stałe zapisane w Question.

Istnieje również jeden problem z „językiem” powyższego kodu. Użytkownik to ten, który tworzy odpowiedzi, a nie pytanie. Osobiście nie lubię, gdy tworzymy obiekt wartości i zależnie od programisty, aby wypełniał go wartościami - skąd może być pewien, co należy dodać?

[B] Fabryka wewnątrz pytania, weź nr 2

Niektórzy twierdzą, że powinniśmy zastosować tego rodzaju metodę w Question:

question.addAnswer(String answer, boolean correct, int level....);

Podobnie jak w powyższym rozwiązaniu, ta metoda pobiera dane obowiązkowe dla odpowiedzi i tworzy takie, które również zostaną dodane do pytania.

Problem polega na tym, że powielamy konstruktora Answerbez powodu. Czy pytanie naprawdę tworzy odpowiedź?

[C] Zależności konstruktora

Dajmy sobie swobodę w tworzeniu obu obiektów. Wyraźmy również zależność bezpośrednio w konstruktorze:

Question q = new Question(...);
Answer a = new Answer(q, ...);   // answer can't exist without a question

Daje to wskazówki dla programistów, ponieważ odpowiedzi nie można utworzyć bez pytania. Nie widzimy jednak „języka”, który mówi, że odpowiedź jest „dodana” do pytania. Z drugiej strony, czy naprawdę musimy to zobaczyć?

[D] Zależność konstruktora, weź # 2

Możemy zrobić odwrotnie:

Answer a1 = new Answer("",...);
Answer a2 = new Answer("",...);
Question q = new Question("", a1, a2);

Jest to sytuacja odwrotna do powyższej. Tutaj odpowiedzi mogą istnieć bez pytania (co nie ma sensu), ale pytanie nie może istnieć bez odpowiedzi (co ma sens). Również „język” tutaj jest bardziej przejrzysty, ponieważ pytanie będzie zawierać odpowiedzi.

[E] Wspólny sposób

To, co nazywam powszechnym sposobem, pierwszą rzeczą, którą zwykle robią ppl:

Question q = new Question("",...);
Answer a = new Answer("",...);
q.addAnswer(a);

która jest „luźną” wersją dwóch powyższych odpowiedzi, ponieważ zarówno odpowiedź, jak i pytanie mogą istnieć bez siebie. Nie ma żadnej szczególnej wskazówki, że trzeba je połączyć.

[F] Połączone

A może powinienem łączyć C, D, E - aby objąć wszystkie sposoby tworzenia relacji, aby pomóc programistom w użyciu tego, co jest dla nich najlepsze.

Pytanie

Wiem, że ludzie mogą wybrać jedną z powyższych odpowiedzi na podstawie „przeczucia”. Zastanawiam się jednak, czy którykolwiek z powyższych wariantów jest lepszy od drugiego z uzasadnionego powodu. Proszę również nie myśleć w ramach powyższego pytania, chciałbym tu przytoczyć najlepsze praktyki, które można zastosować w większości przypadków - a jeśli się zgadzacie, większość przypadków użycia tworzenia niektórych podmiotów jest podobnych. Bądźmy tu także agnostycy technologiczni, np. Nie chcę myśleć, czy ORM będzie używany, czy nie. Po prostu chcę dobrego, ekspresyjnego trybu.

Jakaś mądrość na ten temat?

EDYTOWAĆ

Zignoruj ​​inne właściwości Questioni Answer, nie są one istotne dla pytania. Zredagowałem powyższy tekst i zmieniłem większość konstruktorów (tam, gdzie było to potrzebne): teraz akceptują wszelkie niezbędne potrzebne wartości właściwości. Może to być po prostu ciąg zapytania lub mapa ciągów w różnych językach, statusach itp. - niezależnie od przekazywanych właściwości, nie są one w centrum uwagi;) Załóżmy więc, że przekraczamy niezbędne parametry, chyba że podano inaczej. Dzięki!

lawpert
źródło

Odpowiedzi:

6

Zaktualizowano Uwzględniono wyjaśnienia.

Wygląda na to, że jest to domena wielokrotnego wyboru, która zwykle ma następujące wymagania

  1. pytanie musi mieć co najmniej dwie opcje, abyś mógł wybrać jedną z nich
  2. musi być co najmniej jeden poprawny wybór
  3. nie powinno być wyboru bez pytania

na podstawie powyższego

[A] nie może zapewnić niezmiennika z punktu 1, możesz skończyć pytaniem bez żadnego wyboru

[B] ma tę samą wadę co [A]

[C] ma tę samą wadę co [A] i [B]

[D] jest prawidłowym podejściem, ale lepiej jest przekazywać wybory jako listę niż przekazywać je indywidualnie

[E] ma tę samą wadę co [A] , [B] i [C]

Dlatego wybrałbym [D], ponieważ pozwala to zapewnić przestrzeganie reguł domeny z punktów 1, 2 i 3. Nawet jeśli powiesz, że jest bardzo mało prawdopodobne, aby pytanie pozostało bez wyboru przez długi czas, zawsze dobrym pomysłem jest przekazanie wymagań domeny za pomocą kodu.

Zmieniłbym też nazwę na Answerto, Choiceponieważ ma to dla mnie większy sens w tej dziedzinie.

public class Choice implements ValueObject {

    private Question q;
    private final String txt;
    private final boolean isCorrect;
    private boolean isSelected = false;

    public Choice(String txt, boolean isCorrect) {
        // validate and assign
    }

    public void assignToQuestion(Question q) {
        this.q = q;
    }

    public void select() {
        isSelected = true;
    }

    public void unselect() {
        isSelected = false;
    }

    public boolean isSelected() {
        return isSelected;
    }
}

public class Question implements Entity {

    private final String txt;
    private final List<Choice> choices;

    public Question(String txt, List<Choice> choices) {
        // ensure requirements are met
        // 1. make sure there are more than 2 choices
        // 2. make sure at least 1 of the choices is correct
        // 3. assign each choice to this question
    }
}

Choice ch1 = new Choice("The sky", false);
Choice ch2 = new Choice("Ceiling", true);
List<Choice> choices = Arrays.asList(ch1, ch2);
Question q = new Question("What's up?", choices);

Notka. Jeśli uczynisz Questionbyt zagregowanym korzeniem, a Choiceobiekt wartości częścią tego samego agregatu, nie ma szans, że można go zapisać Choicebez przypisania go Question(nawet jeśli nie przekazujesz bezpośredniego odwołania do Questionargumentu Choicekonstruktor), ponieważ repozytoria działają tylko z katalogami głównymi, a gdy je zbudujesz Question, wszystkie opcje zostaną przypisane do konstruktora.

Mam nadzieję że to pomoże.

AKTUALIZACJA

Jeśli naprawdę przeszkadza ci to, jak wybory są tworzone przed ich pytaniem, istnieje kilka sztuczek, które mogą okazać się przydatne

1) Zmień kolejność kodu, tak aby wyglądał, jakby zostały utworzone po pytaniu lub przynajmniej w tym samym czasie

Question q = new Question(
    "What's up?",
    Arrays.asList(
        new Choice("The sky", false),
        new Choice("Ceiling", true)
    )
);

2) Ukryj konstruktory i użyj statycznej metody fabrycznej

public class Question implements Entity {
    ...

    private Question(String txt) { ... }

    public static Question newInstance(String txt, List<Choice> choices) {
        Question q = new Question(txt);
        for (Choice ch : choices) {
            q.assignChoice(ch);
        }
    }

    public void assignChoice(Choice ch) { ... }
    ...
}

3) Użyj wzorca konstruktora

Question q = new Question.Builder("What's up?")
    .assignChoice(new Choice("The sky", false))
    .assignChoice(new Choice("Ceiling", true))
    .build();

Jednak wszystko zależy od Twojej domeny. W większości przypadków kolejność tworzenia obiektów nie jest ważna z punktu widzenia dziedziny problemowej. Co ważniejsze, gdy tylko otrzymasz instancję klasy, jest ona logicznie kompletna i gotowa do użycia.


Przestarzały. Wszystko poniżej nie ma znaczenia dla pytania po wyjaśnieniach.

Po pierwsze, zgodnie z modelem domeny DDD powinien mieć sens w świecie rzeczywistym. Stąd kilka punktów

  1. pytanie może nie zawierać odpowiedzi
  2. nie powinno być odpowiedzi bez pytania
  3. odpowiedź powinna odpowiadać dokładnie jednemu pytaniu
  4. „pusta” odpowiedź nie odpowiada na pytanie

na podstawie powyższego

[A] może być sprzeczny z punktem 4, ponieważ łatwo jest go niewłaściwie używać i zapomnieć o ustawieniu tekstu.

[B] jest prawidłowym podejściem, ale wymaga parametrów, które są opcjonalne

[C] może być sprzeczny z pkt 4, ponieważ pozwala na odpowiedź bez tekstu

[D] jest sprzeczne z punktem 1 i może być sprzeczne z punktami 2 i 3

[E] może być sprzeczny z pkt 2, 3 i 4

Po drugie, możemy wykorzystać funkcje OOP do wymuszenia logiki domeny. Mianowicie możemy użyć konstruktorów dla wymaganych parametrów i ustawiaczy dla opcjonalnych.

Po trzecie, użyłbym wszechobecnego języka, który powinien być bardziej naturalny dla domeny.

I wreszcie możemy to wszystko zaprojektować przy użyciu wzorców DDD, takich jak zagregowane pierwiastki, jednostki i obiekty wartości. Możemy uczynić Pytanie głównym źródłem jego agregacji, a Odpowiedź stanowić jego część. Jest to logiczna decyzja, ponieważ odpowiedź nie ma znaczenia poza kontekstem pytania.

Wszystkie powyższe sprowadzają się do następującego projektu

class Answer implements ValueObject {

    private final Question q;
    private String txt;
    private boolean isCorrect = false;

    Answer(Question q, String txt) {
        // validate and assign
    }

    public void markAsCorrect() {
        isCorrect = true;
    }

    public boolean isCorrect() {
        return isCorrect;
    }
}

public class Question implements Entity {

    private String txt;
    private final List<Answer> answers = new ArrayList<>();

    public Question(String txt) {
        // validate and assign
    }

    // Ubiquitous Language: answer() instead of addAnswer()
    public void answer(String txt) {
        answers.add(new Answer(this, txt));
    }
}

Question q = new Question("What's up?");
q.answer("The sky");

PS Odpowiadając na twoje pytanie, poczyniłem kilka założeń dotyczących Twojej domeny, które mogą być niepoprawne, więc możesz dostosować powyższe do swoich potrzeb.

zafarkhaja
źródło
1
Podsumowując: jest to mieszanka B i C. Proszę zobaczyć moje wyjaśnienie wymagań. Twój punkt 1. może istnieć tylko przez „krótki” czas, podczas budowania pytania; ale nie w bazie danych. W tym sensie 4. nigdy nie powinno się zdarzyć. Mam nadzieję, że teraz wymagania są jasne;)
lawpert
Btw, z wyjaśnieniem, wydaje mi się, że addAnswerlub assignAnswerbyłby lepszym językiem niż tylko answer, mam nadzieję, że się z tym zgadzasz. W każdym razie moje pytanie brzmi - czy nadal wybrałbyś B i np. Miałbyś kopię większości argumentów w metodzie odpowiedzi? Czy nie byłoby to duplikowaniem?
lawpert
Przepraszam za niejasne wymagania, czy byłbyś uprzejmy zaktualizować odpowiedź?
lawpert
1
Okazało się, że moje założenia były błędne. Waszą domenę kontroli jakości potraktowałem jako przykład stron stosu wymiany, ale bardziej przypomina ona test wielokrotnego wyboru. Jasne, zaktualizuję swoją odpowiedź.
zafarkhaja
1
@lawpert Answerto obiekt wartości, który będzie przechowywany z głównym agregatem jego agregatu. Nie przechowujesz bezpośrednio obiektów wartości, ani nie zapisujesz encji, jeśli nie są one pierwiastkami ich agregatów.
zafarkhaja
1

W przypadku gdy wymagania są tak proste, że istnieje wiele możliwych rozwiązań, należy przestrzegać zasady KISS. W twoim przypadku byłaby to opcja E.

Istnieje również przypadek stworzenia kodu, który wyraża coś, czego nie powinien. Na przykład wiązanie tworzenia odpowiedzi na pytanie (A i B) lub odniesienie odpowiedzi na pytanie (C i D) dodaje pewne zachowanie, które nie jest konieczne w domenie i może być mylące. Również w twoim przypadku Pytanie najprawdopodobniej zostanie połączone z odpowiedzią, a odpowiedź będzie typem wartości.

Euforyk
źródło
1
Dlaczego [C] jest niepotrzebnym zachowaniem? Widzę, że [C] komunikuje, że Odpowiedź nie może żyć bez pytania i to jest dokładnie to. Ponadto wyobraź sobie, że odpowiedź wymaga więcej flag (np. Typ odpowiedzi, kategoria itp.), Które są obowiązkowe. Przechodząc do KISS tracimy tę wiedzę o tym, co jest obowiązkowe, a programista musi wiedzieć z przodu, co musi dodać / ustawić w odpowiedzi, aby wszystko było w porządku. Uważam, że tutaj nie chodziło o modelowanie tego bardzo prostego przykładu, ale o znalezienie lepszej praktyki pisania wszechobecnego języka za pomocą OO.
igor
@igor E już informuje, że odpowiedź jest częścią pytania, nakładając obowiązek przypisania odpowiedzi do pytania, aby można ją było zapisać w repozytorium. Gdyby istniał sposób na zapisanie samej odpowiedzi bez wczytywania jej pytania, C byłby lepszy. Ale nie jest to oczywiste z tego, co napisałeś.
Euforyczny
@igor Ponadto, jeśli chcesz powiązać tworzenie odpowiedzi z pytaniem, A byłoby lepiej, ponieważ jeśli wybierzesz C, ukrywa się, gdy odpowiedź jest przypisana do pytania. Ponadto, czytając tekst w punkcie A, należy rozróżnić „zachowanie modelowe” i tego, kto je inicjuje. Pytanie może być odpowiedzialne za tworzenie odpowiedzi, gdy trzeba je w jakiś sposób zainicjować. Nie ma to nic wspólnego z „tworzeniem odpowiedzi przez użytkowników”.
Euforyczny
Dla przypomnienia, jestem rozdarty między C&E :) Teraz to: „... poprzez obowiązkowe przypisanie odpowiedzi na pytanie, aby zostało zapisane, jest repozytorium”. Oznacza to, że część „obowiązkowa” pojawia się tylko wtedy, gdy przejdziemy do repozytorium. Obowiązkowe połączenie nie jest więc „widoczne” dla programisty w czasie kompilacji, a reguły biznesowe przeciekają do repozytorium. Właśnie dlatego testuję tutaj [C]. Może ta rozmowa może dać więcej informacji o tym, o czym myślę, że dotyczy opcji C.
igor
To: „... chcesz powiązać tworzenie odpowiedzi z pytaniem ...”. Nie chcę wiązać samej kreacji . Chcę tylko wyrazić obowiązkową relację (osobiście lubię być w stanie samodzielnie tworzyć modele obiektów, jeśli to możliwe). Tak więc, moim zdaniem, nie chodzi o tworzenie, dlatego wkrótce porzucę A i B. Nie widzę, aby Pytanie było odpowiedzialne za stworzenie odpowiedzi.
igor
1

Wybrałbym [C] lub [E].

Po pierwsze, dlaczego nie A i B? Nie chcę, aby moje Pytanie było odpowiedzialne za tworzenie powiązanych wartości. Wyobraź sobie, że Pytanie ma wiele innych obiektów wartości - czy umieściłbyś createmetodę dla każdego z nich? Lub jeśli istnieją jakieś złożone agregaty, ten sam przypadek.

Dlaczego nie [D]? Ponieważ jest odwrotnie niż w naturze. Najpierw tworzymy pytanie. Możesz sobie wyobrazić stronę internetową, na której to wszystko tworzysz - użytkownik najpierw utworzyłby pytanie, prawda? Dlatego nie D.

[E] to KISS, jak powiedział @Euphoric. Ale ostatnio też polubiłem [C]. To nie jest tak mylące, jak się wydaje. Co więcej, wyobraź sobie, że pytanie zależy od większej liczby rzeczy - wtedy programista musi wiedzieć, co musi umieścić w pytaniu, aby zostało poprawnie zainicjowane. Chociaż masz rację - nie ma języka „wizualnego” wyjaśniającego, że odpowiedź jest dodawana do pytania.

Dodatkowe lektury

Takie pytania sprawiają, że zastanawiam się, czy nasze języki komputerowe nie są zbyt ogólne do modelowania. (Rozumiem, że muszą być ogólne, aby odpowiedzieć na wszystkie wymagania programowe). Ostatnio próbuję znaleźć lepszy sposób na wyrażenie języka biznesowego przy użyciu płynnych interfejsów. Coś takiego (w języku sudo):

use(question).addAnswer(answer).storeToRepo();

tj. próba odejścia od dużych * usług i * klas repozytoriów do mniejszych części logiki biznesowej. Po prostu pomysł.

Igor
źródło
Mówisz w dodatku o językach specyficznych dla domeny?
lawpert
Teraz, kiedy wspomniałeś, wygląda to tak :) Kup Nie mam z tym większego doświadczenia.
igor
2
Myślę, że do tej pory istnieje konsensus, że IO jest ortogonalną odpowiedzialnością i dlatego nie powinny być obsługiwane przez podmioty (storeToRepo)
Esben Skov Pedersen
Zgadzam się @Ebenben Skov Pedersen, że sama jednostka nie powinna nazywać się repo w środku (tak powiedziałeś, prawda?); ale jako AFAIU mamy tutaj jakiś wzorzec konstruktora, który wywołuje polecenia; więc IO nie jest tu wykonywane w tym obiekcie. Przynajmniej tak to rozumiem;)
lawpert
@lawpert jest poprawny. Nie rozumiem, jak to ma działać, ale byłoby interesujące.
Esben Skov Pedersen
1

Uważam, że nie zauważyłeś tutaj żadnego punktu, Twój główny katalog zagregowany powinien być twoją jednostką testową.

A jeśli tak jest naprawdę, uważam, że TestFactory najlepiej nadaje się do rozwiązania twojego problemu.

Delegowałbyś budynek pytań i odpowiedzi do fabryki, a zatem mógłbyś w zasadzie użyć dowolnego rozwiązania, o którym pomyślałeś, nie uszkadzając swojego modelu, ponieważ ukrywasz się przed klientem tak, jak tworzysz instancje podrzędne.

Jest to tak długo, jak TestFactory jest jedynym interfejsem używanym do tworzenia wystąpienia testu.

Alexandre BODIN
źródło