AutoMapper to zło

Kiedyś bardzo lubiłem bibliotekę AutoMapper. Była ona z reguły jedną z pierwszych bibliotek, jakie zawsze dodawałem do projektu na samym początku. Pozwalała mi tworzyć mnóstwo obiektów i mapować je do woli, poświęcając na to niewiele czasu „programistycznego”. Była świetna! Aż do momentu, gdy zaczęła być jedną z największych bolączek.

Wady AutoMapper

Jedną z wad stosowania AutoMapper jest to, że zaczynamy tworzyć bardzo dużo klas modeli. Nie zastanawiamy się nad tym, czy są one nam potrzebne – po prostu je tworzymy. Dodanie kolejnego mapowania zajmuje mniej niż minutę. Niestety potem wychodzą takie problemy, że chcąc dodać nowe pole do modelu (albo zmienić już istniejące), musimy zmodyfikować kilka (albo i kilkanaście) klas. Wtedy z reguły nie potrzebujemy zmieniać mapowania, ale już wszystkie klasy modeli, na które dany model jest mapowany już tak.

Kolejną wadą, jaką zaobserwowałem, jest logika biznesowa w klasach mapujących. Na początku staramy się, aby było tam tylko mapowanie. Jednakże z czasem, gdy nasza aplikacja zaczyna się rozrastać, w tych klasach zaczyna pojawiać się logika biznesowa. Pomijając oczywisty fakt, że to nie jest miejsce do umieszczenia logiki biznesowej, często nie zdajemy sobie sprawy, że taka logika w ogóle tam jest. Potem gdy coś nie działa poprawie, mapowanie jest ostatnim miejscem, do jakiego zaglądamy, szukając błędów w działaniu aplikacji.

Ponadto klasy mapujące jeden model na drugi, często nie są testowane. Trudno się dziwić. Po pierwsze to napisanie testu dla takiej klasy zajmuje kilka razy więcej czasu niż napisanie samej klasy mapującej. Po drugie w tej klasie powinno być samo mapowanie, więc co tu testować?

Trochę z innej strony – wielokrotnie znajdowałem błędy, które były spowodowane właśnie użyciem AutoMapper. Nie chodzi mi o sytuacje, w których ktoś zawarł logikę biznesową w klasie mapującej i w tej logice był błąd. Były to bardziej sytuacje wynikające ze stylu pisania klasy mapującej. Zdarzało się, że jeden programista wypisywał wszystkie właściwości, które mają być mapowane, a następnie dodawał „IgnoreForAllOtherMembers” (czyli metodę, która powodowała pominięcie automatycznego mapowania pozostałych właściwości). Drugi programista postępował wręcz odwrotnie – w mapowaniu ustawiał które właściwości mają się nie mapować, a pozostałe właściwości (te które miały takie same nazwy), mapowały się automatycznie. Potem wszyscy dziwili się, czemu w jednym modelu coś zostało zmapowane, a w innym nie.

Przytrafił mi się też jeden błąd, który był bezpośrednio związany z użyciem AutoMapper. Powodem było nie do końca intuicyjne radzenie sobie tej biblioteki z sytuacjami, gdzie nazwa jednej właściwości była początkiem innej właściwości – np. „Date” i „DateTime”. Pod właściwość „DateTime” była ustawiana wartość „Date” (pomimo osobnych mapowań) i dużo czasu nam zajęło zdiagnozowanie tego, dlaczego tak się dzieje.

Co więcej, zdarzają się sytuacje w których, aby stworzyć docelowy obiekt, musimy zmapować wiele obiektów. Wyobraźmy sobie sytuację, że ktoś nowy dołączył do zespołu. Skąd taki ktoś ma wiedzieć, że aby stworzyć obiekt X, trzeba najpierw pobrać obiekty A, B i C, a następnie zmapować je w odpowiedniej kolejności? Mało tego, to nawet nie musi być ktoś nowy. Może to być ktoś, kto wcześniej tego obiektu nie widział i ma z nim do czynienia po raz pierwszy. W takich sytuacjach niepotrzebnie narzucamy na siebie presję albo dokumentowania, albo pamiętania o tym, że trzeba użyć kilku mapowań i to w odpowiedniej kolejności.

Co w zamian?

Oczywiście na każdą z tych bolączek można znaleźć jakieś rozwiązanie. Pojawia się jednak zasadnicze pytanie: po co? Czy nie lepiej wyeliminować problem u źródła, zamiast próbować wymyślać lekarstwa?

Od jakiegoś czasu w swoich projektach staram się unikać tworzenia niepotrzebnych modeli. Jeśli obiekt zwracany przez nasze API jest dokładnie taki sam jak obiekt przechowywany w bazie danych, to po co tworzyć pośrednie klasy modelu? Tworzenie dodatkowych klas i mapowań zajmuje czas, a bardzo często jest to w ogóle niepotrzebne.

Zdaję sobie sprawę, że obiekt zwracany przez API często różni się od obiektu przechowywanego w bazie danych. W takich sytuacjach tworzę odpowiednią klasę modelu, która będzie odzwierciedlać zwracane dane. W momencie pobrania danych z bazy danych mapujemy te dane na docelowy model i nie potrzebujemy żadnych modeli pośrednich. Dodatkowo, jeśli pojawi się nowa funkcjonalność i będziemy musieli zmienić zwracane dane, będziemy mieli tylko jeden model do zmiany.

Czasem jednak mogą się zdarzyć sytuacje, że będziemy potrzebowali zmapować jeden obiekt na drugi. Wtedy w docelowym obiekcie najlepiej jest stworzyć konstruktor, który jako parametr będzie przyjmował źródłowy obiekt. W tym konstruktorze ręcznie, bez żadnych dodatkowych narzędzi, mapujemy to, co potrzebujemy. Ktoś może powiedzieć, że zajmuje to więcej czasu niż skorzystanie z AutoMapper – oczywiście, ale o to też chodzi. Jeśli będziemy zmuszeni ręcznie napisać wszystkie mapowania, to może dwa razy zastanowimy się, czy aby na pewno potrzebujemy dodawać nowy model.

Takie samo rozwiązanie możemy zastosować, kiedy do stworzenia obiektu naszego docelowego modelu, potrzebujemy kilku obiektów. Dodajemy wtedy konstruktor, który przyjmuje wszystkie źródłowe obiekty jako parametry. Dzięki temu, gdy ktoś będzie musiał stworzyć obiekt docelowego modelu, od razu będzie wiedział, że musi w tym celu wykorzystać kilka obiektów źródłowych. Ponadto, jeśli logikę mapującą umieścimy w konstruktorze, dużo łatwiej będzie nam znaleźć błąd, ponieważ ręcznie napisane mapowanie będzie z reguły dużo czytelniejsze, niż gdy wykorzystamy AutoMapper.

Możemy nawet pójść krok dalej i dla naszych klas modelu zawsze tworzyć konstruktory, które jako parametry przyjmują obiekty, które są im niezbędne do działania. Nieważne czy są to inne obiekty, z których będziemy robili mapowanie, czy jest to zbiór typów prostych, które przypiszemy do naszych właściwości. Największą zaletą takiego podejścia jest to, że chcąc stworzyć obiekt jakiejś klasy, od razu będziemy wiedzieć, czego potrzebujemy.

Słowo na koniec

Zachęcam wszystkich do zastanowienia się następnym razem, gdy będziemy chcieli dodać bibliotekę AutoMapper do naszego projektu, czy jest nam ona naprawdę potrzebna. Dodatkowo za każdym razem, gdy będziemy chcieli dodać dodatkową klasę pośredniego modelu, poświęćmy chwilę na analizę, czy jest nam ona niezbędna. W większości sytuacji okazuje się, że takich dodatkowych klas w ogóle nie potrzebujemy i bez nich programuje nam się o wiele wygodniej.

18 myśli na “AutoMapper to zło”

  1. Pingback: dotnetomaniak.pl

    1. Ciekawe narzędzie. Obawiam się jednak, że również może prowadzić do nadmiernego tworzenia modeli. Jeśli mapowanie tworzy się „szybko”, często modeli tworzymy dużo — bo niewiele nas to kosztuje. A dużo klas modeli, to dużo problemów.
      Niemniej jednak dzięki za link. Potrafię sobie wyobrazić sytuacje, w których takie narzędzie będzie miało zastosowanie.

    1. To zależy. Jak najbardziej extension methods to dobra alternatywa. Osobiście nie lubię mieć publicznych „setterów”, dlatego z reguły używam właśnie konstruktora.

  2. „Jeśli obiekt zwracany przez nasze API jest dokładnie taki sam jak obiekt przechowywany w bazie danych” – wtedy każda zmiana w bazie automatycznie zmienia też obiekt, który jest używany albo przez UI albo przez kogoś z zewnątrz, raczej warto dodawać te dodatkowe ‚view modele’.

    Co do Automappera to się zgadzam, do podobnych wniosków doszliśmy już 5 lat temu i od tamtego czasu piszemy mapowanie ręcznie. Do tego warto nie używać inicjalizatorów tylko każde property mapować osobno – łatwiej wyśledzić ewentualne null refy…

    1. Jak najbardziej masz rację. Jeśli baza danych zmienia się często, wtedy takie jedno mapowanie ma jak najbardziej sens. Z drugiej jednak strony, jeśli baza danych rzadko ulega zmianie (np. często tworzymy nowe tabele, a rzadko modyfikujemy istniejące) albo zmiany w strukturze bazy danych powinny być również odzwierciedlone w API, wtedy taki dodatkowy model wydaje się zbędny. Wszystko zależy od tego, co chcemy osiągnąć. Dwa modele jeszcze nie są takie straszne (np. model i view model). Gorzej, jeśli ta liczba zaczyna wzrastać, czyli pojawiają się pośrednie modele (np. Dto).

  3. Źródło większości problemów to właśnie brak testów do mapowania; dodanie pól do modeli powinno wysypywać dobrze napisane unit testy. W moim projekcie napisaliśmy sobie bardzo prosty helper do porównywania instancji klas z symboliczną obsługą „Override” i „Except” i napisanie unittestów do mapy niewiele więcej niż 2-3 minuty.

    Jeśli mapper zawiera w sobie logikę to znaczy, że ktoś folguje sobie podczas code review. No i, logika w mapperze == nieporównywalnie trudniejsze testy.

    1. Zgadzam się jednak z tematem dziwnych błędów: wielokrotnie zdarzało nam się „osobliwe” działanie automappera kiedy jedna z klas (source/dest) dziedziczyła po innej.

    2. Brzmi ciekawie i rozsądnie.

      Jeśli chodzi o logikę w maperze i code review, to pamiętam, że zawsze bywało z tym różnie. Z tego co zaobserwowałem, to nie jest tak, że ktoś robił to celowo — często pojawiała się ona tam przypadkowo, przy okazji jakichś zmian. A potem to się rozrastało i rozrastało. Czasem było tak, że na początku tej logiki nie było, ale wskutek zmian wymagań pojawiała się, bo dotyczyła tego, jak jeden obiekt ma się mapować na drugi. Na początku było proste mapowanie, a z czasem zaczynało się coraz bardziej komplikować. W takich sytuacjach dla wielu osób mapper był najbardziej sensownym miejscem do zrobienia tego. Dodatkowo często było to też najszybsze rozwiązanie. Z jeszcze innej strony, czasem takie rzeczy po prostu umykają podczas code review, bo większą uwagę przykłada się do najważniejszego kodu, a nie do „prostego” mappera.

      1. Faktycznie może z „folgowaniem” wyciągnąłem pochopne wnioski, w końcu można to samo powiedzieć o długu technicznym czy bugach 😉

        Polecam mały eksperyment – setup mappera (jakaś podstawowa, automatyczna mapa) + testy do niego z wykorzystaniem opisanego helpera. Następnie dodanie kawałka logiki do mapy i próba przetestowania tego 🙂 Bardzo szybko programista dojdzie do wniosku, że mapper to nie jest jednak najprostsze miejsce do wprowadzenia takiej zmiany.

        Sam jestem umiarkowanym fanem tego rozwiązania (patrz przytoczony problem „dziwnych” błędów przy klasach dziedziczących), nie mniej jednak bardzo często obserwuję tendencję „zatrudniania” automappera do czegoś, czemu on nie służy. Zespół którego częścią jestem obecnie nauczył mnie, że już sama nazwa sugeruje, że narzędzie to służy do szybkiego i wygodnego mapowania jednego obiektu, na drugi. Przykład świadomie przerysowany, ale równie dobrze mógłbym wykorzystać NBuildera jako factory 😀

        Wszystko rozbija się o to do czego wykorzystujemy dane rozwiązanie. Jeśli docelowe obiekty mapy to np. typy odpowiedzi API, to automaper jest cudownym narzędziem zwalniającym deweloperów z myślenia czy przez przypadek nie zmieniają definicji endpointu w API (nie dodają/ujmują jakiegoś property). Nie trzeba też w klasach modelowych używać [JsonIgnore], etc, etc.

        Wszystko jest dla ludzi, dopóki jest to stosowane zgodnie z przeznaczeniem.

        1. Może jeśli w projektach w których byłem, inaczej byśmy podeszli do AutoMapper, to ten post wyglądałby odmiennie 🙂 Niemniej jednak teraz dość sceptycznie patrzę, gdy ktoś mi mówi, że w swoim projekcie używa AutoMappera. Doskonale wiem, jakie on problemy może ze sobą nieść i jak wygodniej pisze mi się kod, odkąd zrezygnowałem z tworzenia mapperów.
          Jednakże zgadzam się też z tym, co napisałeś — jeśli używa się czegoś z głową, może to być bardzo przydatne.

  4. Ja pozostaje przy AM, oczywiście zgodzę się z tym, że logika czy duża ilość modeli czy cuda wianki powodują problemy. Ale jeśli ktoś używa AM odpowiednio i z rozwagą to AM robi robotę której potrzeba.

  5. Ja używam AM jako „implementation detail” czyli metoda mapujaca/konstruktor/metoda fabrykująca wygląda jakby AM nie istniał, ale w środku go używa.
    Przy okazji zauważam że użycie fluentAPI do konfiguracji EF i serializacji json znacząco ogranicza konieczność tworzenia nowych klas i stosowania mapowań.

  6. Osobiście nie powiedziałbym, że to co nazywasz „nadmierną ilością modeli” jest czymś złym. To ile ich potrzeba zależy od kontekstu i domeny.

    Sam Automapper natomiast jest wygodny i fajny, ale…
    1) dla malutkich projekcików
    2) dla prostych mapowań (1:1) (chociaż tutaj osobiście zastosowałbym refleksję i nie dokładał zewnętrznych zależności)

    Tam gdzie w grę wchodzą już jakieś konwersje dużo lepiej jest napisać mapowania ręcznie. Oczywiście trzeba poświęcić na to trochę czasu ale ma się nad tym pełną kontrolę, można to w łatwy sposób debugować, modyfikować czy rozszerzać. Automapper dodaje dużą warstwę „magii i czarodziejstwa”, która w pewnym momencie zaczyna mocno przeszkadzać. W jednym z projektów jakie prowadziłem usunąłem wszystkie mapowania wykonywane przez Automapper na mapowania ręczne.
    Trochę to trwało, jednakże ze względu na złożoność niektórych konwersji ostatecznie zaoszczędziliśmy dużo więcej czasu.

    1. Masz jak najbardziej rację. W pełni się z Tobą zgadzam 🙂

      Z „nadmierną ilością modeli” chodziło mi o to, że często tworzymy dodatkowe modele, chociaż tak naprawdę one nam nie są potrzebne. Wydaje nam się, że ich potrzebujemy, ale tak naprawdę bez nich też byśmy sobie dali radę. I dlatego uważam je za „nadmiarowe”. Chodzi tu tylko i wyłącznie o modele pośrednie, a nie o modele niezbędne z perspektywy domeny. Modeli domenowych powinno być tyle, aby w pełni pokrywały to, co chcemy zamodelować.

Leave a Reply