Android
DDD
ドメイン駆動設計
MVP
クリーンアーキテクチャ

Androidアプリをリリースしたので設計についてまとめてみる(クリーンアーキテクチャ)

アプリについて

リリースしたアプリはこちらです。
https://play.google.com/store/apps/details?id=com.teach_timetable_appppppp.www.teachtimetable

時間割アプリ画像 (1).png

ソースコードはこちら
https://github.com/fungiy/TeachTimeTable

学校の先生向けの時間割アプリ。
言語はKotlinです。

作ってる途中に考えたことなんかをコードを交えながら解説してみます。

間違った記述があればご指摘ください。
また、ここはこうしたほうがいいみたいなご指摘も大歓迎です。
(プルリクもお待ちしてます)

設計について

基本的にクリーンアーキテクチャとMVPで製作しました。
実践DDDを読みながらやりました。

クリーンアーキテクチャ

クリーンアーキテクチャは以下の図が有名だと思います。

CleanArchitecture.jpg

クリーンアーキテクチャでは、ビジネスロジック(ドメイン層)を中心に置き、データベース・ネットワークアクセスなどのインフラ層と、UI層を外側として扱います。
このため、Viewで発生したイベントやデータは、原則としてドメイン層を経由してデータベースにアクセスします。
また、ネットワークから取得したデータやローカルのデータベースから取得したデータをUI側で使うときも同様にドメイン層を経由します。

パッケージの切り方

ドメイン層とUI層インフラ層を分離するため、ドメイン層のパッケージをアプリから分離しました。
名前でいうとappパッケージとdomainパッケージです。
イメージとしては、Gradleでドメイン層ライブラリをアプリ側でインポートして使っている感じです。
ライブラリなので当然ドメイン層はアプリ側への依存を持つことができません。

また、ドメイン層についてはAndroid SDKのコードが入らないピュアなKotlinになっています。

ドメイン層を独立させると、外側の層の置き換えが容易になる利点があります。
たとえば今回は途中で一部のローカルの保存処理をRealmからSharedPreferencesに変更したのですが、比較的少ない影響範囲で切り替えることができたと思います。
試してはいませんが、理論上はUI層についても同様に切り替えることができるはずです(たとえばiOSやWebでドメイン層のコードをそのまま使うなど)

インフラ層のパッケージ

また、インフラ層のためにパッケージをもう一つ切るやり方もありうると思います。
今回はデータベースのRealmやSharedPreferencesがAndroidに依存しているため、UI層とまとめてappパッケージに入れています。

ディレクトリ分け

雑にだいたいこんなイメージ

  • appパッケージ

    • インフラ層
      • リポジトリ(実装)
      • データモデル
    • UI層
      • View
        • Activity
        • Fragment
      • Presenter
  • domainパッケージ

    • エンティティ
    • バリューオブジェクト
    • サービス
    • リポジトリ(抽象)

ドメイン層の実装

ドメイン層の実装は、それぞれのクラスをDDDでいう

  • エンティティ
  • バリューオブジェクト
  • サービス
  • リポジトリ

などの役割に当てはめることを意識しました。

エンティティ

モデルとも言われるもの。
以下のクラスは、作成したエンティティの一つです。
エンティティは不変のidを持ちます。
Subjectは教科を表すエンティティです。
たとえば今回のSubjectの場合、教科名や総授業時数などの値が途中で変わったとしてもidが同一であれば同じ教科とみなします。

Subject.kt
/*
 * ○教科
 *
 * id
 * 教科名
 * 学年
 * クラス
 * 総授業時数
 */
class Subject(
        val id: SubjectId,
        var subjectName: String,
        var grade: Grade,
        var className: String,
        var totalLessonCount: TotalLessonCount
) : Entity {

    fun gradeAndClass(): String {
        return "${grade.value}年${className}組"
    }

    // 省略
}

バリューオブジェクト

バリューオブジェクトはエンティティと似ていますが、エンティティとは異なりidを持ちません。
また、全ての属性は不変にします(Kotlinだとvalで不変にできる)
全ての属性が同じであれば同一と見做します。
そのためequalsメソッドを実装しています。

上のSubjectも、SubjectId,Grade(学年),totalLessonCountについてはプリミティブ型ではなくバリューオブジェクトで実装している。
バリューオブジェクトを属性にしてエンティティを構成するのがいいらしい。

ただ、今回に関してはバリューオブジェクトを少し作りすぎたかなと思っている。
下のPeriodAtDayOfWeekにしても、Period(時限)とDayOfWeek(曜日)は別のクラスの方が自然だったきがする。

バリューオブジェクトを多用すると、引数をたくさん渡すときに型のチェックが効いて嬉しい。

PeriodAtDayOfWeek.kt
/*
 * ○曜日の時限
 */
data class PeriodAtDayOfWeek(val period: Int, val dayOfWeek: DayOfWeek): ValueObject {

    // 省略

    override fun equals(other: Any?): Boolean {
        // 省略
    }
}

サービス

ステートレス(状態を持たない)なメソッドの集まり。

使いどころとしては複数のエンティティが関係するところなんかでよく使っていました。
というかビジネスロジックの7割はここに書いた気がする。

一番最初に挙げた図のユースケースという部分がここに該当すると自分では思っている。

また、今回はサービスについては実装クラスと抽象クラスを分けて作りました。

以下は作ったサービスの一部。
ビジネスロジックを書いている。

LessonCountCalculateService.kt
interface LessonCountCalculateService {

    /**
    * 学期の中の対象の時限で、教科が何個めであるかを数え上げる
    */
    fun calcLessonCountInSemester(periodAtTheDateInSemesterInSchoolYear: PeriodAtTheDateInSemesterInSchoolYear,
                               subjectId: SubjectId): Int

    /**
     * 次の時限を返す
     */
    fun getNextPeriod(periodAtTheDateInSemesterInSchoolYear: PeriodAtTheDateInSemesterInSchoolYear, maxPeriod: Int): PeriodAtTheDateInSemesterInSchoolYear
}
LessonCountCalculateServiceImpl.kt
class LessonCountCalculateServiceImpl(
        private val schoolYearService: SchoolYearService,
        private val weekTimeTableService: WeekTimeTableService
) : LessonCountCalculateService {

    /**
     * 学期の中の対象の時限で、教科が何個めであるかを数え上げる
     */
    override fun calcLessonCountInSemester(periodAtTheDateInSemesterInSchoolYear: PeriodAtTheDateInSemesterInSchoolYear, subjectId: SubjectId): Int {
        val schoolYear = schoolYearService.findSchoolYearById(periodAtTheDateInSemesterInSchoolYear.schoolYearId)
        var sum = 0

        schoolYear?.let {

            val semester = schoolYear.getSemester(periodAtTheDateInSemesterInSchoolYear.semesterNumber)

            semester?.let {

                var targetPeriodDateInSemesterInSchoolYear = PeriodAtTheDateInSemesterInSchoolYear(
                        periodAtTheDate = PeriodAtTheDate(period = 1, theDate = it.startDate),
                        semesterInSchoolYear = periodAtTheDateInSemesterInSchoolYear.semesterInSchoolYear
                )

                // 対象の日付と時限になるまで
                while (periodAtTheDateInSemesterInSchoolYear.targetPeriodIsOlderThenThis(targetPeriodDateInSemesterInSchoolYear.periodAtTheDate)
                        ||
                        targetPeriodDateInSemesterInSchoolYear == periodAtTheDateInSemesterInSchoolYear
                ) {
                    val weekLessonUiModel = weekTimeTableService.createWeekLessonUiModel(targetPeriodDateInSemesterInSchoolYear)

                    if (weekLessonUiModel.isDoLecture() && (weekLessonUiModel.subject() != null)) {

                        if (weekLessonUiModel.subject()!!.id == subjectId)
                            sum++

                    }

                    targetPeriodDateInSemesterInSchoolYear = getNextPeriod(targetPeriodDateInSemesterInSchoolYear, schoolYear.maxPeriod)
                }
            }
        }

        return sum
    }

    /**
     * 次の時限を返す
     */
    override fun getNextPeriod(periodAtTheDateInSemesterInSchoolYear: PeriodAtTheDateInSemesterInSchoolYear, maxPeriod: Int): PeriodAtTheDateInSemesterInSchoolYear {
        return if (periodAtTheDateInSemesterInSchoolYear.period < maxPeriod) {
            PeriodAtTheDateInSemesterInSchoolYear(
                    periodAtTheDate = PeriodAtTheDate(period = periodAtTheDateInSemesterInSchoolYear.period + 1, theDate = periodAtTheDateInSemesterInSchoolYear.theDate),
                    semesterInSchoolYear = periodAtTheDateInSemesterInSchoolYear.semesterInSchoolYear
            )
        } else {
            PeriodAtTheDateInSemesterInSchoolYear(
                    periodAtTheDate = PeriodAtTheDate(period = 1, theDate = CalendarUtils.nextTheDate(periodAtTheDateInSemesterInSchoolYear.theDate)),
                    semesterInSchoolYear = periodAtTheDateInSemesterInSchoolYear.semesterInSchoolYear
            )
        }
    }
}

リポジトリ(ドメイン層)

リポジトリはドメイン層とインフラ層に跨って置かれます。
具体的には、ドメイン層に抽象(インターフェイス)を配置し、DB・ネットワークの具体的な実装はインフラ層に任せる。
こうすることによって、ドメイン層のサービスなどからリポジトリを触っても、依存関係が逆転しているので実際のDBアクセスやネットワークアクセスをドメイン層は知らなくて良い。

以下は作ったリポジトリの一つです。

SchoolYearRepository.kt
interface SchoolYearRepository: BaseRepository<SchoolYear> {
    fun count(): Long

    fun findDefaultSchoolYear(): SchoolYear

    fun findDefaultSchooYearId(): SchoolYearId

    fun saveDefaultSchoolYear(schoolYearId: SchoolYearId)
}

具体的な実装はインフラ層で。

UIで使うモデルについて

UIで使うモデルの置き場所をどこにするか若干迷った。
最終的にドメイン層にUI用のモデルをおいたけど、UIの概念がドメイン層を侵食してる気がしなくもない。
でも一応モデルだからドメイン層でいいのか。

インフラ層

インフラ層は主にDBアクセスとネットワークアクセスを担います。
ただ、今回のアプリはネットワークアクセスをほぼしておらず基本ローカルDBにデータを保存しているので、DBアクセスがメイン。

ちなみに、今回のアプリでは、ドメイン層のエンティティとは別にエンティティと同じデータを表すデータモデルをインフラ層用に作りました。
すなわち、データ保存時と読み出し時にいちいちデータ移し替えの手間が発生します。
このやり方がいいのかはいまいちわからないです。

ただ、今回データベースとしてオブジェクトデータベースのRealmを使ったが、このモデルはRealmのモデルを継承or実装する必要があり、ドメイン層でインフラ層と同じモデル(エンティティ)を使うとどうしてもRealmの概念がドメイン層に入り込んでしまう。
クリーンアーキテクチャでは外側のことを内側が知ってはならないのが原則であり、これを破ってしまうため、移し替えはあるもののRealmにはRealm用のエンティティを使いました。

このやり方はエンティティの総数がそこまで多くないのもあるけど、結果としてそこそこ正解だったと思う。

リポジトリ(インフラ層)

インフラ層のリポジトリ。
さっきのドメイン層のリポジトリを実装しています。

あとRealmインスタンスの使い方がこれでいいのか微妙。
毎回closeする必要があるのかとか。

SchoolYearRepositoryImpl.kt
class SchoolYearRepositoryImpl(private val schoolYearMapper: SchoolYearMapper, context: Context): SchoolYearRepository {
    override fun findDefaultSchooYearId(): SchoolYearId {
        return SchoolYearId(sharedPreferences.getString(DEFAULT_SCHOOL_YEAR_ID, NOT_FOUND))
    }

    private val sharedPreferences = context.getSharedPreferences(DB_NAME, MODE_PRIVATE)

    override fun findDefaultSchoolYear(): SchoolYear {
        return findById(findDefaultSchooYearId())!!
    }

    override fun saveDefaultSchoolYear(schoolYearId: SchoolYearId) {
        val editor = sharedPreferences.edit()
        editor.putString(DEFAULT_SCHOOL_YEAR_ID, schoolYearId.value)
        editor.apply()
    }

    override fun count(): Long {
        val realm = Realm.getDefaultInstance()
        val count = realm.where(SchoolYearDataModel::class.java).count()
        realm.close()

        return count
    }

    override fun save(entity: SchoolYear) {
        val dataModel = schoolYearMapper.toDateModelWithNonNull(entity)

        val realm = Realm.getDefaultInstance()
        realm.beginTransaction()
        realm.copyToRealmOrUpdate(dataModel)
        realm.commitTransaction()
        realm.close()
    }

    override fun findAll(): List<SchoolYear> {
        val realm = Realm.getDefaultInstance()
        val result = schoolYearMapper
                .toList(realm
                        .where(SchoolYearDataModel::class.java)
                        .equalTo("isArchived", false)
                        .findAll())
        realm.close()

        return result

    }

    override fun findById(id: Id): SchoolYear? {
        val realm = Realm.getDefaultInstance()
        val result = schoolYearMapper.toDomainEntity(realm.where(SchoolYearDataModel::class.java).equalTo("id", id.value).findFirst())
        realm.close()

        return result
    }

    override fun delete(id: Id) {
    }
}

データモデル

Realm用のモデルクラス。
このクラスだけはJavaで作っている。
理由はRealmのモデルがJavaじゃないと何となく不安なため。
こういう感じで、カジュアルに一部だけJavaを使えるのがKotlinのいいところ。

表している情報としては、ドメイン層のSubjectクラスと全く同じ。

ただ、Realmのモデルの属性はpublicもしくはgetter,setterを定義する必要があったりして、そこらへんもドメイン層では別のエンティティを使った理由として大きい。

SubjectDataModel.java
public class SubjectDataModel extends RealmObject {
    @PrimaryKey
    public String id;
    public String subjectName;
    public Integer grade;
    public String className;
    public int totalLessonCount;

    @LinkingObjects("subject")
    public final RealmResults<BasicLessonDataModel> basicLessons = null;
    @LinkingObjects("subject")
    public final RealmResults<LessonPlanDataModel> lessonPlans = null;

    public SubjectDataModel() {

    }

    public SubjectDataModel(String id, String subjectName, Integer grade, String className, int totalLessonCount) {
        this.id = id;
        this.subjectName = subjectName;
        this.grade = grade;
        this.className = className;
        this.totalLessonCount = totalLessonCount;
    }
}

インフラ層についてはこんな感じ。

UI層

MVP

ここはいわゆるActivityとかが属するAndroidらしいコードです。
ただ、UI層といってもActivityやFragmentにイベントを処理するロジックをすベて書いてしまうとFat Activityと呼ばれる問題が発生するので、UI層をさらにViewとPresenterに分けて作りました。
いわゆるMVP(Model-View-Presenter)という考え方にのっとっています。
ちなみにModelは、上で述べたドメイン層とインフラ層まとめて全部がここでいうModelらしいです。

なお、ViewはAndroid固有のandroid.viewとは全くの別物です。

ViewとPresenterの定義

ViewとPresenterは、インターフェイスを定義しておきます。
ViewはPresenter型を、PresenterはView型を持っていてそのメソッドを呼びます。

あと、ViewとPresenterのインターフェイスを見るだけでそのUIがざっくり何ができるのか把握しやすいメリットがあるような気がする。

AddBasicLessonDialogContract.kt
 // 省略

 interface View {
    var action: Action

    fun saveSuccess()

    fun deleteSuccess()

    fun setSubjectList(subjects: List<Subject>, currentSubject: Subject?)

    fun setRoomList(rooms: List<Room>, currentRoom: Room?)

    fun initLayout()

    fun visibleDeleteButton()

    fun goneDeleteButton()

    fun periodAtDayOfWeekInSemesterInSchoolYear(): PeriodAtDayOfWeekInSemesterInSchoolYear

    fun selectSubjectId(): SubjectId

    fun useNewRoom(): Boolean

    fun inputNewRoomName(): String

    fun selectRoomId(): RoomId?

    fun showValidateError(reason: String)
}

interface Action {
    fun loadedView()

    fun onClickSaveButton()

    fun onClickDeleteButton()
}

こんな感じでViewとPresenterそれぞれインターフェイスを定義します。
ViewにはそのView(Activity・Fragment)のUIが変化する部分、Action(Presenter)にはユーザーから受け取るイベントなんかを書くことが多いです。

なお、ここではPresneterの抽象の呼び名としてActionと呼んでいます。
実装するときは~~Presenterという名前にしました。

Actionというのは文字通りユーザーのアクションがそのままPresenterのメソッドになるためです。

View

Viewは、ActivityやFragmentをViewと呼んでいます。
一番UIに近い部分です。
Androidではここでイベントをハンドリングすることが多いです。

MVPでは、イベントを受け取ったら、それをそのままPresenterに渡すだけに徹します。

こうすることで、Activityの責務を減らしてシンプルに保つことができます。

AddBasicLessonDialogFragment.kt
class AddBasicLessonDialogFragment : BottomSheetDialogFragment(), AddBasicLessonDialogContract.View {
    override fun periodAtDayOfWeekInSemesterInSchoolYear(): PeriodAtDayOfWeekInSemesterInSchoolYear
        = arguments.getSerializable(PERIOD_AT_DAY_OF_WEEK_IN_SEMESTER_IN_SCHOOL_YEAR) as PeriodAtDayOfWeekInSemesterInSchoolYear

    override fun selectSubjectId(): SubjectId = (binding.subjectsSpinner.selectedItem as Subject).id

    override fun useNewRoom(): Boolean = binding.useNewRoomSwitch.isChecked

    override fun inputNewRoomName(): String = binding.classRoomNameEdit.text.toString()

    override fun selectRoomId(): RoomId? {
        val room = (binding.roomsSpinner.selectedItem as Room)

        return room.id
    }

    override fun setRoomList(rooms: List<Room>, currentRoom: Room?) {
        val adapter = RoomSpinnerAdapter(
                context = activity,
                resource =  android.R.layout.simple_spinner_item,
                rooms = rooms.toMutableList()
        )

        binding.roomsSpinner.adapter = adapter
        currentRoom?.let {
            binding.roomsSpinner.setSelection(adapter.getPosition(it))
        }
    }

    override fun visibleDeleteButton() {
        binding.deleteButton.visibility = View.VISIBLE
    }

    override fun goneDeleteButton() {
        binding.deleteButton.visibility = View.GONE
    }

    override fun deleteSuccess() {
        mListener?.onDataDeleted()
        activity.supportFragmentManager.beginTransaction().remove(this).commit()
    }

    override fun saveSuccess() {
        mListener?.onDataSaved()
        activity.supportFragmentManager.beginTransaction().remove(this).commit()
    }

    override fun setSubjectList(subjects: List<Subject>, currentSubject: Subject?) {
        val adapter = SubjectSpinnerAdapter(
                context = activity,
                resource =  android.R.layout.simple_spinner_item,
                subjects = subjects.toMutableList()
        )

        binding.subjectsSpinner.adapter = adapter
        currentSubject?.let {
            binding.subjectsSpinner.setSelection(adapter.getPosition(currentSubject))
        }
    }

    override fun initLayout() {
        val periodAtDayOfWeekInSemesterInSchoolYear = arguments.getSerializable(PERIOD_AT_DAY_OF_WEEK_IN_SEMESTER_IN_SCHOOL_YEAR) as PeriodAtDayOfWeekInSemesterInSchoolYear

        binding.periodDayOfSomeText.text = periodAtDayOfWeekInSemesterInSchoolYear.periodAtDayOfWeek.toString()

        binding.submitButton.setOnClickListener { _ ->

            if (binding.subjectsSpinner.selectedItem == null) {
                Toast.makeText(context, "教科が未登録です。先に教科を作成してください", Toast.LENGTH_SHORT).show()
            }else if(binding.roomsSpinner.selectedItem == null && !binding.useNewRoomSwitch.isChecked) {
                Toast.makeText(context, "登録済みの教室はありません", Toast.LENGTH_SHORT).show()
            } else {
                action.onClickSaveButton()
            }
        }

        binding.deleteButton.setOnClickListener { view ->

            val snackBar = Snackbar.make(view, "削除します。よろしいですか?", Snackbar.LENGTH_INDEFINITE)
            snackBar.setActionTextColor(ContextCompat.getColor(context, R.color.colorDelete))
            snackBar.setAction("削除", {
                action.onClickDeleteButton()
            })
            snackBar.show()
        }

        binding.classRoomNameTextInputLayout.visibility = if (binding.useNewRoomSwitch.isChecked) View.VISIBLE else View.GONE
        binding.roomsSpinner.visibility = if(binding.useNewRoomSwitch.isChecked) View.GONE else View.VISIBLE
        binding.useNewRoomSwitch.setOnCheckedChangeListener { _, isChecked ->
            binding.classRoomNameTextInputLayout.visibility = if (isChecked) View.VISIBLE else View.GONE
            binding.roomsSpinner.visibility = if(isChecked) View.GONE else View.VISIBLE
        }
    }

    private lateinit var binding: FragmentAddLessonDialogBinding
    override lateinit var action: AddBasicLessonDialogContract.Action
    private val injector: KodeinInjector = KodeinInjector()
    private var mListener: Listener? = null

    override fun showValidateError(reason: String) {
        Toast.makeText(context, reason, Toast.LENGTH_SHORT).show()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        injector.inject(Kodein {
            extend(appKodein())
        })
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
                              savedInstanceState: Bundle?): View? {

        return inflater.inflate(R.layout.fragment_add_lesson_dialog, container, false)
    }

    override fun onViewCreated(view: View?, savedInstanceState: Bundle?) {
        binding = FragmentAddLessonDialogBinding.bind(getView()!!)

        val basicTimeTableService by injector.instance<BasicTimeTableService>()
        val schoolYearService by injector.instance<SchoolYearService>()
        val subjectService by injector.instance<SubjectService>()
        val roomService by injector.instance<RoomService>()

        action = AddBasicLessonPresenter(
                view = this,
                basicTimeTableService = basicTimeTableService,
                schoolYearService = schoolYearService,
                subjectService = subjectService,
                roomService = roomService
        )

        val periodAtDayOfWeekInSemesterInSchoolYear = arguments.getSerializable(PERIOD_AT_DAY_OF_WEEK_IN_SEMESTER_IN_SCHOOL_YEAR) as PeriodAtDayOfWeekInSemesterInSchoolYear

        action.loadedView()
    }

    override fun onAttach(context: Context) {
        super.onAttach(context)

        if (context is Listener) {
            mListener = context
        } else {
            throw Exception("not implement")
        }
    }

    override fun onDetach() {
        mListener = null
        super.onDetach()
    }

    interface Listener {

        fun onDataSaved()

        fun onDataDeleted()
    }

    companion object {

        fun newInstance(periodAtDayOfWeekInSemesterInSchoolYear: PeriodAtDayOfWeekInSemesterInSchoolYear): AddBasicLessonDialogFragment =
                AddBasicLessonDialogFragment().apply {
                    arguments = Bundle().apply {
                        putSerializable(PERIOD_AT_DAY_OF_WEEK_IN_SEMESTER_IN_SCHOOL_YEAR, periodAtDayOfWeekInSemesterInSchoolYear)
                    }
                }

    }
}

上のコードは結局MVPを遵守しきれずにFragmentにロジックを書いている部分が多々あり、そこは反省点です。

Fragmentが先ほど定義したView(AddBasicLessonDialogContract.View)をimplementsしており、Fragmentの中でPresenterを生成してFragment自身をView型としてPresenterに渡していることがわかります。
こうすることで、PresenterはAndroid側を知ることなくUIを操作できます。

また、ユーザーからのアクションがあったときはPresenterのメソッドを呼び出しています。

Presenter

以下が上のViewに対応するPresenter。
だいぶんRxの書き方が雑な気がしますが気にしないでください。

Presenterではユーザーからのイベントを受け、ドメイン層のサービスを呼び出し非同期処理で結果を受け取り、View(Fragment)に反映しています。

非同期処理(Rx)の使い方についてはどうするのがいいのか結構迷ったけど、ビジネスロジック呼ぶときにそれを丸ごと非同期処理にするのがわかりやすくていいなと思いました。
サービス(ユースケース)の呼び出し自体を非同期処理にすることでDBアクセス、ネットワークアクセスを非同期でやりつつ、同期非同期の概念をドメイン層に持ち込まないで済むんじゃないかと思った。
ここら辺は自分でもよくわかっていないので変なことを言ってるかもしれません。

あとAndroidのバリデーションのやり方はこれでいいんだろうか。

AddBasicLessonPresenter.kt
class AddBasicLessonPresenter(private val view: AddBasicLessonDialogContract.View,
                              private val basicTimeTableService: BasicTimeTableService,
                              private val schoolYearService: SchoolYearService,
                              private val subjectService: SubjectService,
                              private val roomService: RoomService
) : AddBasicLessonDialogContract.Action {

    override fun onClickDeleteButton() {
        Completable
                .fromAction { basicTimeTableService.deleteBasicLesson(view.periodAtDayOfWeekInSemesterInSchoolYear()) }
                .subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe {
                    view.deleteSuccess()
                }

    }

    override fun loadedView() {
        val findAllSubjectSingle = Single
                .create<List<Subject>> { e -> e.onSuccess(subjectService.findAllSubject()) }
                .subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())

        val findAllRoomSingle = Single
                .create<List<Room>> { e -> e.onSuccess(roomService.findAll()) }
                .subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())

        Single
                .create<SchoolYear> { e ->
                    schoolYearService.findSchoolYearById(view.periodAtDayOfWeekInSemesterInSchoolYear().semesterInSchoolYear.schoolYearId)?.let {
                        e.onSuccess(it)
                    }
                }
                .subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())
                .doOnSuccess {
                    it.getSemester(view.periodAtDayOfWeekInSemesterInSchoolYear().semesterInSchoolYear.semesterNumber)?.let {
                        val basicLesson = it.getBasicLesson(period = view.periodAtDayOfWeekInSemesterInSchoolYear().period, dayOfWeek = view.periodAtDayOfWeekInSemesterInSchoolYear().dayOfWeek)

                        findAllSubjectSingle.doOnSuccess {
                            view.setSubjectList(subjects = it, currentSubject = basicLesson?.subject)
                        }
                                .subscribe()

                        findAllRoomSingle.doOnSuccess {
                            view.setRoomList(rooms = it, currentRoom = basicLesson?.room)
                        }
                                .subscribe()

                        view.initLayout()

                        if (basicLesson == null) {
                            view.goneDeleteButton()
                        } else {
                            view.visibleDeleteButton()
                        }
                    }
                }
                .subscribe()
    }

    override fun onClickSaveButton() {

        if (view.useNewRoom()) {

            if (view.inputNewRoomName().isEmpty()) {
                view.showValidateError("教室が未入力です")
                return
            }

            Completable
                    .fromAction {
                        basicTimeTableService.addOrUpdateBasicLessonUseNewRoom(
                                periodAtDayOfWeekInSemesterInSchoolYear = view.periodAtDayOfWeekInSemesterInSchoolYear(),
                                subjectId = view.selectSubjectId(),
                                roomName = view.inputNewRoomName())
                    }
                    .subscribeOn(Schedulers.newThread())
                    .observeOn(AndroidSchedulers.mainThread())
                    .doOnComplete { view.saveSuccess() }
                    .subscribe()

        } else {

            Completable
                    .fromAction {
                        basicTimeTableService.addOrUpdateBasicLessonUseExistRoom(
                                periodAtDayOfWeekInSemesterInSchoolYear = view.periodAtDayOfWeekInSemesterInSchoolYear(),
                                subjectId = view.selectSubjectId(),
                                roomId = view.selectRoomId()!!)
                    }
                    .subscribeOn(Schedulers.newThread())
                    .observeOn(AndroidSchedulers.mainThread())
                    .doOnComplete {
                        view.saveSuccess()
                    }
                    .subscribe()
        }
    }
}

最後に

ビジネスロジックがある程度あるプログラムを作るのが初めてだったので色々と学びがありました。
あと、全体的にメモリ使いすぎCPU使いすぎだったりするのでここら辺うまくやるのは今後の課題と感じました。

DDDの集約を使おうと思ってやってみたんだけどやり方が違うのかこれはあんまりうまくはまらなかった気がしていて、そこも次回の課題。

あと個人開発なのもあってタスクの切り方がかなり雑だった。個人開発でもタスクとブランチを切って進めることで後半は若干進みが早くなったように思う。

そして一番の問題は単体テストをほぼ書いてないこと。