Open Close Principle czyli jak zarobić ale się nie narobić.

Wyobraźmy sobie taką sytuację: jest sklep internetowy, podczas składania zamówienia system wylicza rabat – przy zamówieniach 500-1000 zł 5%, powyżej 1000 zł 10%, powyżej 5000 dodatkowo darmowa przesyłka. Brzmi znajomo?

public double CalculateDiscount(IOrder order)
{
    if (order.Total <= 500 && order.Total > 1000)
    {
        return order.Total*0.05;
    }
    if (order.Total > 1000)
    {
        return order.Total*0.1;
    }
    if (order.Total > 5000)
    {
        order.SippingFee = 0;
        return order.Total*0.1;
    }
    return 0;
}

Napisane, przetestowane, zapłacone. Ale teraz klient (zleceniodawca/kierownik/project owner/właściciel tego sklepu) mówi, że chce dodać jeszcze jedną opcję: 30tego listopada darmowa przesyłka dla wszystkich (taki dzień darmowej dostawy). Nic trudnego dodamy trochę kodu:

public double CalculateDiscount(IOrder order)
{
    if (order.Total >= 500 && order.Total < 1000)
    {
        return order.Total*0.05;
    }
    if (order.Total > 1000)
    {
        return order.Total*0.1;
    }
    if (DateTime.UtcNow.Month == 11 && DateTime.UtcNow.Day == 30)
    {
        order.SippingFee = 0;
    }
    if (order.Total > 5000)
    {
        order.SippingFee = 0;
        return order.Total*0.1;
    }
    return 0;
}

I miesiąc później nasz klient chce mieć obsługę kuponów rabatowych:

...
if (order.HasCoupon && CouponIsValid(order.Coupon))
{
    return order.Total*order.Coupon.Discount;
}
...

Kolejny miesiąc i kolejny pomysł – jakaś promocja w zakupach grupowych:

// miejmy nadzieję że wcześniejszy kod pozwoli korzystać z wielu różnych kuponów

…. widać w jakim kierunku to idzie. Kod rośnie szybciej niż ciasto drożdżowe na kaloryferze i równie szybko co ryba na kaloryferze zaczyna pachnieć.

Na takie bolączki przychodzi Open Close Principle. Zamiast małymi kroczkami psuć kod i z każdą iteracją zwiększać złożoność przypadkową , czyli tą złą, możemy zastosować Open Close Principle, która mówi: Klasa powinna być zamknięta na modyfikacje ale jednocześnie otwarta na rozszerzanie.

A gdyby tak nasza metoda wyglądała mniej więcej tak ?

public double CalculateDiscount(IOrder order)
{
    var discounts = GetAllAvaliableDiscountsInPropperOrder();
    foreach (var discount in discounts)
    {
        discount.Recalculate(order);
    }
    return order.Discount;
}

Taki kod jak powyżej jest zamknięty na modyfikacje ale otwarty na rozszerzanie. Dodawanie kolejnego sposobu obliczania zniżki nie wymaga modyfikowania powyższego kodu. Co więcej, możemy zniżki ładować dynamicznie np. za pomocą MEF-a a przez to w zależności od klienta dystrybuować aplikacje z różnymi modelami obliczania zniżki – bez rekompilacji czy dziwnych, skomplikowanych konfiguratoró. Prawie pewne jest, że po 5-10 klientach zbuduje nam się pokaźna biblioteka wymysłów pomysłów, która zaspokoi 90% potrzeb przyszłych klientów.  Czyż nie jest to piękne? Każdy kolejny klient nie kosztuje nas praktycznie nic.

  • Adam

    Fajnie się czyta, ale takie pytanie co z metodą: GetAllAvaliableDiscountsInPropperOrder() skoro teraz ja będziesz modyfikował?

  • w założeniu metoda GetAllAvaliableDiscountsInPropperOrder() powinna pobrać listę wszystkich dostępnych klas obliczających zniżki. Może to być MEF czy inny kontener DI, może jakiś xml lub baza. To już jest mniej istotne skąd. Na co należy tu zwrócić uwagę, że modyfikując zniżki lub dodając nowe, wogóle nie musimy dotykać metody CalculateDiscount

  • Sprox

    Adam: Tez wlasnie zawsze mnie troche smieszyly te wzorce, ktore poprostu przenosily czesc funkcji do innego pliku tak jak to pokazano w tym przykladzie. Ale one naprawde maja sens 🙂 Nawet gdyby dane te nie byly dynamicznie ladowane, nalezy logike przetrzymywac w osobnej klasie.

  • Tak, logika w zewnętrznym pliku tutaj bardzo dużo upraszcza. Istotą obliczania kosztów zlecenia jest zsumowanie pozycji, dodanie podatków, kosztów wysyłki i odjęcie zniżek. Szczegóły tego jak obliczamy koszt wysyłki i jak obliczamy obniżki nie mają znaczenia z tego punktu widzenia. Mogą zależeć od wielu zmiennych, które łatwiej uchwycić w osobnej klasie niż mieszać z logiką samego zamówienia.

  • Z nieba mi spadł ten wpis. Niedawno niemalże pokłóciłem się z pewnymi programistami twierdząc, że można, mi sie udaje, tak zaprojektować oprogramowanie by wymagało jedynie rozszerzania a nie zmieniania, koronnym argumentem było „bo Pan nie programuje, to tylko teoria”… niniejszym przyznaję się, że „podbieram” cytat i linkuje u siebie…

  • Cieszę się Jarku, że przykład się przyda. SRP jak cały SOLID wymaga dosyć mocnej samodyscypliny – szczególnie na początku – jednak jeśli będziemy się jej trzymali to potem pięknie się odwdzięcza.

  • Ipsos

    Wartościowy wpis. Dodatkowo z użyciem IoC można ładnie „wymieść” dodawanie nowych algorytmów w inne miejsce (zwykle init aplikacji). Widzę tu jednak pewien code smell: discount.Recalculate(order);
    Dlaczego modyfikujesz obiekt w procedurze, zamiast użyć funkcji i zwrócić obiekt DTO ze zniżkami? Albo używasz klasy friend, albo trzeba by użyć ref bądź out – zależnie od języka. Tak się nie powinno robić. Nie modyfikujemy obiektów – zwracamy NOWE obiekty. A jeśli są wielkie, korzystamy ze wzorca „pyłek”. No chyba, że bardzo ale to bardzo nam zależy na zarządzaniu pamięcią i chcemy wszystko zrobić „in place”. Pozdrawiam i gratuluję udanego bloga.