Help us understand the problem. What is going on with this article?

Elm de Clean Architecture

背景

Backend脳なのでFrontendなアーキテクチャがあまりピンときていない. Elmをがっつりと触れていない. FrontendとBackendのコンテキストスイッチに体力と時間を要して疲れる.,, ElmでなんちゃってCleanArchitectureな思考ができたらFrontendもBackendも同じように考えられて楽チンだー!という工夫.

環境

$ elm --version
0.19.0
$

Directory構成

Directory構成はこんな感じ.

frontend
├── README.md
├── elm-stuff
├── elm.json
├── index.html
├── index.js
└── src
    ├── Main.elm
    ├── Route.elm
    ├── Page
    │   ├── Xxxx.elm
    │
    ├── Domain
    │   ├── Model
    │   │   ├── Xxxx.elm
    │   │
    │   └── Usecase
    │       ├── GuestUsecase.elm
    │       ├── UserUsecase.elm
    │       ├── AdministratorUsecase.elm
    │
    ├── Query
    │   ├── Model
    │   │   ├── Xxxx.elm
    │   │
    │   └── Service
    │       ├── XxxxQuery.elm
    │
    ├── Adapter
    │   ├── Helper.elm
    │   ├── XxxxApi.elm
    │   ├── Ports.elm
    │
    └── Types
        ├── Session.elm

Main, Route

src直下は初期設定やSPAに必要なロジックなどを定義する. SPA構成は公式ドキュメント翻訳プロジェクト基礎からわかるElm入門を読むとわかり易い.

Types

ドメインちっくじゃないシステムちっくな型や関数を定義する. SPAで画面間を引き回すSessionなど.

Page

依存方向: Page -> Domain.Usecase, Domain.Model, Query.Service, Query.Model

Model, Msg, update, viewをワンセットで作る. Cmd.mapHtml.mapを使って他ページの事は考えなくて良いようにする. 永続データ系処理はDomain.Usecaseにおまかせする. Domain.Model, Query.Modelの助けを借りつつ画面内処理はupdate内でがんばる.

Page.SamplePage.elm
import Domain.Model.Xxxx as Xxxx exposing(Xxxx)
import Domain.Usecase.XxxxUsecase
import Query.Model.Xxxx as Xxxx exposing(Xxxx)
import Query.Service.XxxxQuery

-- Update


type Msg
    = -- View case
      InViewEditing Xxxx
      -- Use case
    | UsecaseRequest
    | UsecaseResponse (Result Http.Error Xxxx)
      -- Query case
    | QueryRequest
    | QueryResponse (Result Http.Error Xxxx)


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        -- View case 画面内のモデル編集はupdate内でがんばる
        InViewEditing entity ->
            let
                -- 画面内でモデルをなんやかんやする
            in
            ( { model | entity = modifiedEntity }, Cmd.none )

        -- Use case 更新系はDomain.Usecaseに任せる
        UsecaseRequest ->
            ( model, Domain.Usecase.XxxxxUsecase.method UsecaseResponse model.session.token model.entity )

        UsecaseResponse (Ok entity) ->
            ( { model | entity = entity }, Cmd.none )

        UsecaseResponse (Err error) ->
            let
                -- エラー処理をなんやかんやする
            in
            ( { model | errorMessage = errorMessage }, Cmd.none )


        -- Query case 参照系はQuery.Serviceに任せる
        QueryRequest ->
            ( model, Query.Service.XxxxQuery.method QueryResponse model.session.token )

        QueryResponse (Ok entity) ->
            ( { model | entity = entity }, Cmd.none )

        QueryResponse (Err error) ->
            let
                -- エラー処理をなんやかんやする
            in
            ( { model | errorMessage = errorMessage }, Cmd.none )



Domain.Model

依存方向: Domain.Model

ドメインモデルを定義する. 今の処, そこまでカプセル化にこだわっていない.

Domain.Model.Sample.elm
module Domain.Model.User exposing (DisplayId, Id, Name, User, create, init)

type alias User =
    { id : Id
    , displayId : DisplayId
    , name : Name
    }


init : User
init =
    { id = 0
    , displayId = ""
    , name = ""
    }


create : Id -> DisplayId -> Name -> User
create id displayId name =
    { init
        | id = id
        , displayId = displayId
        , name = name
    }


type alias Id =
    Int


type alias DisplayId =
    String


type alias Name =
    String

Domain.Usecase

依存方向: Domain.Usecase -> Domain.Model, Adapter

永続データを更新する一連の流れをひとまとめにした関数. Pageのupdateから呼び出す. Task.andThenPlatform.Cmd.batchを使って複数処理を一つのCmd msgにしたりもする.

Domain.Usecase.SampleUsecase.elm
import Adapter.XxxxApi
import Task

usecaseFunction : msg Types.Authorize.Token Argument -> Cmd msg
usecaseFunction msg token argument =
    let
        -- Adapter関数でなんやかんや処理する関数
    in
    Task.Task.attempt msg <| -- Adapter関数でなんやかんや処理する関数のTask

特定PageのMsgを直接importしない! updateを定義しない! 汎用的な関数にする!

Query.Model

依存方向: Query.Model -> Domain.Model

ドメインモデルでないViewに特化したモデルを定義する. 内部にドメインモデルを含むこともある.

Query.Service

依存方向: Query.Service -> Query.Model, Adapter

Domain.Usecaseと構成は同じだけどReadに特化した関数.

Query.Service.SampleQuery.elm
import Adapter.XxxxApi
import Task

queryFunction : msg Types.Authorize.Token Argument -> Cmd msg
queryFunction msg token argument =
    let
        -- Adapter関数をなんやかんや合成したり切り貼りしたり処理する関数
    in
    Task.Task.attempt msg <| -- Adapter関数をなんやかんや合成したり切り貼りしたり処理する関数のTask

特定PageのMsgを直接importしない! updateを定義しない! 汎用的な関数にする!

Adapter

依存方向: Adapter -> Domain.Model, Query.Model

戻り値の型はDomain.Model, Query.Modelをimportする.

WebAPI

WebAPIを叩く場合はHttp.taskを使ってTaskを返してあげる.

Adapter.SampleApi.elm
import Adapter.Helper
import Domain.Model.User as User exposing (User)
import Http
import Json.Decode
import Json.Encode
import Task

apiUrl : String
apiUrl =
    "http://localhost:9000"

create : Types.Authorize.Token -> User -> Task.Task Http.Error User
create token user =
    let
        requestEncoder : User -> Json.Encode.Value
        requestEncoder encodeUser =
            Json.Encode.object
                [ ( "id", Json.Encode.string encodeUser.id )
                , ( "name", Json.Encode.string encodeUser.name )
                ]

        responseDecoder : Json.Decode.Decoder User
        responseDecoder =
            Json.Decode.map2 User.create
                (Json.Decode.field "id" Json.Decode.string)
                (Json.Decode.field "name" Json.Decode.string)
    in
    Http.task
        { method = "POST"
        , headers = [ header "Authorization" <| "Bearer " ++ token ]
        , url = apiUrl ++ "/users"
        , body = Http.jsonBody <| requestEncoder user
        , resolver = Adapter.Helper.jsonResolver responseDecoder
        , timeout = Nothing

特定PageのMsgをimportしない!

Port

Portを叩く場合はCmd msgを返す.

PortもAdapter的な概念で閉じてWebAPI系とI/Oを共通化したいけど, I/Oが異なるので落とし所を思案中. concourseを見てみるとElmRuntimeの戻りをCallbackMsgで各Pageに振り分けていたり, CleanArchitectureにこだわらなければ整理の仕方は色々ありそう.

所感

Page.XxxxPage.updateもDomain.UsecaseもQuery.Serviceもlet式と内部関数で絶賛手続型です. BackendだとDIPは重要だと思うけど, Elmで閉じている環境下ならAdapter関連のDIPは無視してもいいかな?と考えている. テクニカルなシステムは管理が大変なので, 業務要件・業務仕様が理路整然としているならシステムロジックも理路整然としたいですね. すぐに脳がキャパオーバーになっちゃう.

References

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away