Model Domeny – sercem aplikacji w metodologii Domain Driven Design
Luty 12, 2009
W tym poście, zgodnie z wcześniejszą obietnicą, przedstawię podejście do tworzenia modelu domenowego wyrażonego w postaci kodu. Model ten jest sercem każdej aplikacji tworzonej w oparciu o Domain Driven Design, gdyż w jego odpowiedzialności leży realizowanie funkcjonalności, którymi cechuje się ta aplikacja. Martin Fowler w swojej książce opisuje model domenowy jako sieć połączonych ze sobą obiektów, przy czym każdy obiekt reprezentuje jednostkę istniejąca w realnym obszarze biznesowym. Niektóre z tych obiektów przedstawiają dane, inne natomiast reguły biznesowe. Najczęściej jednak występują obiekty, które posiadają obie te cechy.
W każdej z aplikacji, z którymi miałem dotychczas do czynienia, również występowały modele domenowe. W większości przypadków modele te jednak nie realizowały opisywanych przez Martina Fowlera cech, dlatego, że składały się one z klas przedstawiających tylko i wyłącznie dane obiektów domenowych wyrażonych w postaci właściwości(get; set;). Rozwiązanie to zostało okrzyknięte antywzorcem – Anemiczny Model Domenowy dlatego, że wszelkie reguły biznesowe związane z tymi obiektami są umieszczane w odrębnych klasach. Klasy te najczęściej reprezentują wzorzec Transaction Script, gdyż każda operacja biznesowa jest izolowana w jednej metodzie (transakcji). Rozwiązanie to może być skuteczne w przypadku prostych aplikacji, zaś w przypadku bardziej skomplikowanych doprowadzi ono do częstej duplikacji logiki biznesowej rozsianej pomiędzy wielu klasach.
Jednym z najczęstszych powodów powstawania anemicznych modeli domenowych jest sposób projektowania aplikacji w oparciu o wcześniej stworzony schemat bazy danych. To prowadzi to do tego, że model domenowy powstaje w skutek mapowania tabel do klas posiadających odzwierciedlone kolumny w postaci właściwości. Jedynym wyzwaniem dla niektórych osób może być modelowanie relacji, które w przypadku bazy danych są kluczami obcymi, zaś w przypadku obiektowego modelu powinny być relacjami do innych obiektów. Nie brakuje oczywiście przykładów, gdzie relacje w modelu domenowym tworzone są również w oparciu o klucze obce, co całkowicie zaprzecza obiektowemu programowaniu. Idealnym przykładem jest aplikacja .Net Petshop, która ma na celu przedstawienie najlepszych praktyk pisania aplikacji webowych w oparciu o technologię ASP.NET. Cóż za ironia
W poście przedstawiającym znaczenie języka “ubiquitous language” przedstawiłem przykład rozmowy pomiędzy programistami a ekspertem domeny aukcji internetowych. Wytworzony, w trakcie tej przykładowej rozmowy, wspólny język uczestników jak i diagramy klas posłużą do stworzenia przykładowego modelu domeny. Jako, że jestem wielkim zwolennikiem praktyki Test Driven Development, każdy przykład zostanie poprzedzony testem, pozwalającym na zweryfikowanie poprawności zaprojektowanego kodu i funkcjonalności.
W przykładowych wymaganiach zostały wyróżnione trzy kluczowe obiekty domenowe: Użytkownik(User), Aukcja(Auction) i Podbicie(Bid). Użytkownik, mimo, że jest kluczowym obiektem w domenie aukcji, w przedstawionych wymaganiach nie posiada jak na razie żadnych reguł biznesowych. Dlatego też skoncentruję się na początku na obiektach Podbicie i Aukcja.
Zgodnie z wcześniej opisanymi wymaganiami, podbicie dokonywane jest przez użytkownika, który musi podać odpowiednią kwotę. Dlatego pierwszy test zdefiniuje sposób tworzenia nowego podbicia.
[Test]
public void Can_create_a_bid()
{
var amount = new Money(20);
var user = A.RichUser;
var bid = new Bid(user, amount);
Assert.That(bid.WasBiddedBy, Is.EqualTo(user));
Assert.That(bid.Amount, Is.EqualTo(amount));
}
Jak widać obiekt klasy Bid tworzony jest w prosty sposób przy użyciu konstruktora. Informacja o użytkowniku, który dokonał podbicia jest przekazywana w postaci parametru typu User. Do stworzenia obiektu User została wykorzystana klasa statyczna A (Matka Obiektów) służąca do tworzenia powtarzających się obiektów wykorzystywanych w wielu testach. Bardziej interesującym parametrem jest drugi parametr typu Money. Służy on do przedstawienia proponowanej ceny. Można by w tym przypadku wykorzystać jeden z wbudowanych typów języka C#, np. decimal. Należy się jednak zastanowić, czy typ wbudowany sprosta wymaganiom biznesowym i czy w ogóle będzie on zrozumiały dla biznesu. Tworząc specjalny typ Money zapewniamy, że wszelka logika biznesowa związana z operacjami pieniężnymi zostanie zawarta w tym typie. Jedną z jego odpowiedzialności w naszym przypadku, będzie zapewnienie, że wartość pieniężna podawana przez klienta będzie musiała być większa niż 0. Oczywiście należy przedyskutować wszelkie szczegóły na temat obiektu Money z ekspertem domenowym, dlatego że mogą ujawnić się dodatkowe wymagania, takie jak wspieranie wielu walut.
Następnym wymaganiem związanym z podbiciem, którym się zajmiemy, jest data i czas dodania podbicia. Pierwsze pytanie, jakie się nasuwają to: czy informacja ta może być równoznaczna z aktualną datą i czasem stworzenia obiektu typu Bid? Czy może musi to być informacja o tym, kiedy ten obiekt zostanie przypisany do obiektu reprezentującego aukcję(Auction)? Druga opcja nieco komplikuje sprawę, dlatego, że odpowiedzialnością obiektu Auction będzie ustawienie daty i czasu dodania obiektu Bid. Dlatego w celu uniknięcia nadmiernego sprzężenia tych dwóch obiektów, skorzystamy wstępnie z rozwiązania pierwszego, a następnie przedyskutujemy problem daty i czasu z klientem.
[Test]
public void a_newly_created_bid_should_have_the_current_date_and_time()
{
var theTimeBefore = DateTime.Now.AddMilliseconds(-1);
var bidder = A.RichUser;
var twentyZloties = new Money(20);
var aBid = new Bid(bidder, twentyZloties);
Assert.That(aBid.AddedTime, Is.GreaterThan(theTimeBefore));
Assert.That(aBid.AddedTime, Is.LessThan(DateTime.Now.AddMilliseconds(1)));
}
To już chyba wszystko związane z wymaganiami dotyczących obiektu Bid. Oczywiście brakuje jeszcze kodu, który pozwoli nam wykonać poprawnie testy.
public class Bid
{
public Bid(User biddedBy, Money amount)
{
if (biddedBy == null) throw new ArgumentNullException("biddedBy");
if (amount == null) throw new ArgumentNullException("amount");
WasBiddedBy = biddedBy;
Amount = amount;
AddedTime = DateTime.Now;
}
public Money Amount { get; private set; }
public User WasBiddedBy { get; private set; }
public DateTime AddedTime { get; private set; }
}
Jak widać, kod jest bardzo prosty i realizuje wymagania wyrażone w postaci testów, ale co najważniejsze, zapewnia on stworzenie zawsze poprawnego obiektu typu Bid. Możliwe jest to poprzez wyeliminowanie publicznych setterów i sprawdzanie warunków wstępnych w ramach wywołania konstruktora.
Bardziej ciekawą klasą jest klasa Auction, która definiuje dotychczas większość reguł biznesowych. Jedną z nich jest możliwość dodawania nowych podbić do aukcji.
[Test]
public void should_accept_a_new_bid()
{
var testedAuction = An.OngoingAuctionWithoutBids;
var anUser = A.RichUser;
var twentyZloties = new Money(20);
var newBid = new Bid(anUser, twentyZloties);
testedAuction.Place(newBid);
Assert.That(testedAuction.Bids.Contains(newBid));
}
Na daną chwilę nie jest istotne jak zostanie zdefiniowane tworzenie obiektu Auction, dlatego obiekt aukcji tworzony jest przez klasę statyczną An(Matka Obiektów). Powyższy test definiuje sposób użycia metody Place odpowiedzialnej za dodania podbicia do aukcji oraz sposób dostępu do wszystkich dostępnych podbić. Aby przejść dany test, napisana zostanie prosta implementacja, która rozwijana będzie z dodaniem każdego następnego wymagania
public class Auction
{
private IList bids;
public ICollection Bids
{
get { return new ReadOnlyCollection(bids); }
}
public void Place(Bid newBid)
{
if (newBid == null) throw new ArgumentNullException("newBid");
bids.Add(newBid);
}
}
Metoda Place ogranicza się do dodawania obiektu typu Bid do prywatnej listy przechowującej wszystkie podbicia. Nie jest wymagane by została sprawdzana poprawność obiektu Bid, gdyż leży to w odpowiedzialności tego obiektu. Ciekawostką może być właściwość Bids, która nie udostępnia bezpośrednio prywatnej listy. Rozwiązanie powyżej przedstawione, ogranicza możliwość dodania nowego podbicia wyłącznie przez metodę Place.
Pierwsze koty za płoty, teraz przejdźmy do następnego wymagania. Poniższy test zapewnia odrzucanie podbić, których wartość nie przekracza ostatnio przyjętego podbicia.
[Test]
[ExpectedException(typeof(PlaceBidOperationException))]
public void should_not_accept_a_new_bid_because_the_amount_is_lower_then_the_of_the_previous_bid()
{
var testedAuction = An.OngoingAuctionWithTheHighestBidEqualTo20Zloties;
var anUser = A.RichUser;
var tenZloties = new Money(10);
var newBid = new Bid(anUser, tenZloties);
testedAuction.Place(newBid);
}
Test jest dosyć prosty, dlatego przejdziemy bezpośrednio do implementacji, która zapewni poprawne wykonanie testu.
…
public void Place(Bid newBid)
{
if (newBid == null) throw new ArgumentNullException("newBid");
Bid lastBid = LastBid;
if (WasBidded)
{
if (lastBid.Amount.IsGraterThen(newBid.Amount))
{
throw new PlaceBidOperationException(
"The amount of this bid is lower then of the previously placed bid");
}
}
bids.Add(newBid);
}
private bool WasBidded
{
get { return bids.Count > 0; }
}
private Bid LastBid
{
get
{
return bids.OrderByDescending(b => b.AddedTime)
.Take(1).SingleOrDefault();
}
}
…
Tu już się robi ciekawiej. Stworzone zostały dwie właściwości prywatne: WasBidded, która pozwala na sprawdzenie, czy aukcja posiada jakiekolwiek podbicia i LastBid, która kryje logikę dostępu do najwyższego podbicia. Sama logika sprawdzenia, czy dane podbicie jest większe niż ostatnio dodane jest bardzo prosta, gdyż opiera się ono na porównywaniu dwóch wartości pieniężnych.
Hmm… Szczerze, to nie podoba mi się sposób dostępu do ostatniego podbicia. Może by tak zastosować do przechowywania wszystkich podbić zamiast zwykłej listy, sortowaną listę SortedList. Na razie może niech zostanie tak jak jest.
Jednym z ważniejszych wymagań jest to by właściciel aukcji nie brał udziału w licytacji, co zapobiega sztucznym podbiciom.
[Test]
[ExpectedException(typeof(PlaceBidOperationException))]
public void should_not_accept_the_bid_because_it_is_placed_by_the_owner_of_the_auction()
{
var testedAuction = An.OngoingAuctionOwnedByPoorUser;
testedAuction.Place(new Bid(A.PoorUser, new Money(20)));
}
Test jest tak samo prosty jak kod implementujący wymagania.
…
public void Place(Bid newBid)
{
if (newBid == null) throw new ArgumentNullException("newBid");
if (newBid.WasBiddedBy == Owner)
{
throw new PlaceBidOperationException("The owner is not allowed to place bids on his auction");
}
Bid lastBid = LastBid;
if (WasBidded)
{
if (lastBid.Amount.IsGraterThen(newBid.Amount))
{
throw new PlaceBidOperationException("The amount of this bid is lower then of the previously placed bid");
}
}
bids.Add(newBid);
}
public User Owner { get; private set; }
…
Klasa Auction została uzupełniona o właściwość reprezentującą użytkownika, który jest właścicielem aukcji. Jako że aukcja zawsze musi mieć swojego właściciela, który się nigdy nie zmienia, właściwość Owner nie posiada publicznego settera. Dlatego też zostanie ona ustawiana przez konstruktor lub klasę faktoryzującą obiekt Auction. Wybranie odpowiedniego rozwiązania w tym momencie nie jest kluczowe.
Dalszymi wymaganiami było zapewnienie, że aukcja nie może być podbita przez użytkownika, który dodał dotychczasowo najwyższe podbicie oraz zabranianie dodawania podbić przed rozpoczęciem aukcji i po jej skończeniu. Ze względu na obszerność tego posta pominę testy i przedstawię już końcową wersje metody Place
…
public void Place(Bid newBid)
{
if (newBid == null) throw new ArgumentNullException("newBid");
if (Ends DateTime.Now)
{
throw new PlaceBidOperationException("The auction has not yet begun");
}
if (newBid.WasBiddedBy == Owner)
{
throw new PlaceBidOperationException("The owner is not allowed to place bids on his auction");
}
Bid lastBid = LastBid;
if (WasBidded)
{
if (lastBid.Amount.IsGraterThen(newBid.Amount))
{
throw new PlaceBidOperationException(
"The amount of this bid is lower then of the previously placed bid");
}
if (lastBid.WasBiddedBy == newBid.WasBiddedBy)
{
throw new PlaceBidOperationException("The last bid was placed by the same user");
}
}
bids.Add(newBid);
}
public DateTime Begins { get; private set; }
public DateTime Ends { get; private set; }
…
Poprzez enkapsulację w klasie Auction reguł biznesowych z nią związanych, uzyskamy łatwy w utrzymaniu i zrozumieniu kod. Co najważniejsze, model domenowy wyrażony w postaci kodu jest spójny z tym przedstawionym w trakcie rozmowy pomiędzy ekspertem a programistami. Oczywiście należy być świadomym tego, że to tylko namiastka tego, co nas czekałoby w rzeczywistym przypadku. Poza tym ze względu na długość wpisu pominąłem niektóre z wymagań.
Zdaję sobie sprawę z tego, że pozostaje wiele pytań związanych z dalszym rozwojem aplikacji opartej na przedstawionym modelu domenowym, jak choćby sposób zapisywania stanu obiektów. Mam nadzieję, że te i inne kwestie będę mógł opisać w dalszych wpisach.
Entry Filed under: Programowanie. Tagi: DDD, Domain Model, OOP.
4 Comments Add your own
Leave a Comment
Some HTML allowed:
<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <pre> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>
Trackback this post | Subscribe to the comments via RSS Feed


1.
Marek Goldmann | Marzec 1, 2009 at 2:57 pm
Post wydawał mi się trochę zbyt długi, dlatego zwlekałem z jego przeczytaniem dosyć długo. Okazało się, że było to złudzenie, ponieważ bardzo przyjemnie się go czytało.
Myślę, że bardzo trafnie przedstawiłeś iteracje przy tworzeniu modelu domeny, jak i samo zagadnienie. Dzięki, przyda się na pewno!
Czego można się dalej spodziewać? Sposób zapisywania stanu obiektu? A o co tu chodzi?
2.
jenrom | Marzec 3, 2009 at 8:29 am
Poprzez zapisywanie stanu obiektów rozumię szeroko pojęte hasło “Persistance”, czyli zapisywanie obiektów do bazy danych, xml, etc. Jak można zauważyć przedstawione klasy nie posiadają żadnego kodu infrastrukturalnego, co jest jednym z wymogów(zaleceń) DDD. Mam nadzieję, że w krótce mi sie uda opublikować coś więcej na ten temat
3. dotnetomaniak.pl | Marzec 14, 2009 at 4:36 pm
Model Domeny – sercem aplikacji w metodologii Domain Driven Design « !FrAgile Thinking…
Dziękujemy za publikację – Trackback z dotnetomaniak.pl…
4.
biz | Maj 5, 2009 at 8:18 am
Bardzo fajnie się czyta – ciekawie napisane. Ach ten Anemic Domain Model ciągle nas prześladuje
.