Use Case

Наши программы – это просто модели сущностей или абстрактных идей внешнего мира. Мы часто говорим о том, что программа написана красиво, тогда, когда она максимально приближена к моделируемой предметной области.

Давайте попробуем смоделировать предметную область “Вариант использования”. Предположим, бизнес-аналитик принес вам требования, и там такой use case.

UCS-01: Signup user
Actor: anonymous user
Preconditions: 1) actor is not authenticated; 2) the user for this particular actor does not exist; 3) signup page (PG-02) is opened.
Postconditions: 1) the user is created; 2) actor is redirected to the root page (PG-01).

Main flow
1. Actor submits all the data needed for user signup.
2. The system checks the submitted data.
3. The system checks that the corresponding user does not exist.
4. The system creates the specified user in "Inactive" state.
5. The system sends the verification email (EM-01).
6. The system redirects the actor to login page (PG-03).
7. The user confirms the email
8. The system moves the user to "Active" state.

Alternative flows:
2a. If any of the fields are not specified the system shows error message (ERR-01) and stops the use case.
2b. If password does not match confirmed password the system shows error message (ERR-02) and stops the use case.
2c. If the user already exists the system shows error message (ERR-03) and stops the use case.
2d. If the system couldn't create the user it shows error message (ERR-04) and stops the use case.

В первом приближении код на Clojure может выглядеть примерно так:

(defn signup-ucs [ {:keys [email password confirmPassword]} ]
  (if-not (verify-non-empty email password confirmPassword)
    (->error "Some fields are empty!")
    (if-not (= password confirmPassword)
      (->error "Password does not match its confirmation!")
      (if (user-exists? email)
        (->error "The user with the same email already exists!")
        (let [user-id (create-user! email password)]
          (if-not user-id
            (->error "Could not create user!")
            (do (send-confirmation-email user-id)
                (redirect "/"))))))))

Этот код последовательно проверяет различные условия, можно ли создать пользователя с указанными параметрами. На каждой проверке может получиться, что пользователя создать нельзя, и необходимо показать какое-то сообщение об ошибке. В результате имеем функцию с кучей вложенных if-ов, и если просто добавить туда еще хоть строчку логики, то прочесть этот код станет совершенно невозможно.

Проблема большой вложенности if-ов в том, что функция делает две вещи: не только создает пользователя, но и принимает решения о том, что делать при ошибке. Функцию, которая делает две вещи, вместо одной, нужно разделить на две функции.

В Java такая проблема решается выбрасыванием исключений и обработкой их всех в одном месте.

Решение на Clojure

Впринципе, исключения можно использовать и в Clojure, особенно если применить функции ex-info и ex-data. Но, честно говоря, я вообще ни разу не видел и не слышал, чтобы в Clojure кто-нибудь использовал исключения для отделения основного потока управления от альтернативных. Exception-ы выглядят в Clojure как чужеродные существа, поэтому их особо никто и не использует.

Зато вполне можно воспользоваться монадой Either, т.к. она именно для этих целей и предназначена. Вот какой у меня получился код с применением Either:

(defn signup-main [{:keys [email password confirmPassword]}]
  (m/mlet [non-empty (->either :check-non-empty
                               (verify-non-empty email password confirmPassword))
           
           all-equals (->either :check-non-equals
                                (= password confirmPassword))

           user-exists (->either :check-user-exists
                                 (not-user-exists? email))           

           user-created (->either :user-not-created
                                  (create-user! email password))

           email-sent   (->either :email-not-sent
                                  (send-confirmation-email! user-created))]
          (m/return :done)))

(defn signup-full [ request ]
  (either/branch
   (signup-main request)
   
   (fn [left-value]
     (let [->error (partial pages/show-error "/signup")]
       (case left-value
         :check-non-empty   (->error "Some fields are empty!")
         :check-non-equals  (->error "Password does not match its confirmation!")
         :check-user-exists (->error "The user with the same email already exists!")
         :check-user-created(->error "Could not create user!")
         :check-email-sent  (->error "Error while sending confirmation email!")
         :default           (->error "Unknown error..."))))

   (fn [right-value]
     (redirect "/"))))

Код функции ->either:

(defn ->either [ err-result bool ]
  (if bool
    (either/right bool)
    (either/left err-result)))

Если подходить формально, то переработанная версия немного длиннее. Тем не менее, этот код обладает гораздо более важной характеристикой: читабельностью. Есть две функции: signup-main, которая реализует Main flow варианта использования UCS-01: Signup user, и есть функция signup-full, которая обрабатывает альтернативные сценарии. Каждая из этих функций делает только одну вещь, что сильно упрощает понимание кода. Кроме того, в переработанном варианте нет вложенных ветвлений, которые всегда сильно затрудняют чтение.

Выводы

  1. При реализации варианта использования в контроллере следует отделять основной поток управления от альтернативных. В идеале следует добиться полного соответствия описания варианта использования и кода, который его реализует. В коде Main flow должны отсутствовать ветвления. В альтернативных потоках ветвлений должно быть столько же, сколько и в варианте использования: по одному на альтернативную ветку.

  2. Для разделения основного и альтернативных потоков в Clojure можно использовать как исключения (при помощи ex-info/ex-data), так и монады, например Either. Многократно вложенный if является ошибкой проектирования.