朝専用SNS ”朝の実(Asanomi)” 開発記録 #5 〜投稿時間帯ロジックの詳細設計・実装〜

朝の実(Asanomi)ロゴ アプリ開発

朝専用SNS「朝の実」では、投稿とコメントの確定を 4:00〜8:59 に制限しています。
朝の時間だけ使う前提のアプリなので、この時間制約は UI 上の演出ではなく、アプリの中心にあるルールです。

時間制御というと、まずは「時間外はボタンを押せなくする」という形を思い浮かべやすいと思います。
ただ、実際に整理してみると、8:59→9:00 の境界や、画面表示と確定処理のズレ、端末ローカル時刻をどこまで信じるかなど、考えるポイントがいくつかありました。

本記事では、この制約のうち投稿側の時間帯ロジックをどう設計し、どう実装したのかを整理します。コメント側にも同じ考え方を適用していますが、ここでは投稿を中心に扱います。


1. UI + ドメインの二重防御

結論から書くと、今回の投稿時間制御は UI + ドメインの二重防御 にしました。
理由は、UX と規約強制を両立したかったからです。

UIだけの制御にすると、時間外であることは画面上で伝えやすい一方で、別経路から確定処理が呼ばれたときの抜け道が残ります。
逆にドメインだけにすると、規約は必ず守れますが、ユーザーは押してから失敗を知ることになるので、UXを損ねる要因になります。

UI側とドメイン側の役割をまとめると

  • UIは「今は押せるかどうか」を先に伝える
  • ドメインは「その投稿を本当に確定してよいか」を最後に止める

この2段構えで、使い勝手とルール強制を両立させます。

2. 検討した3案

最終的に採用した方式は UI + ドメインの二重防御 でしたが、
ここでは、UIのみの場合、ドメインのみの場合、そして UI + ドメインの二重防御を比較したときの判断材料を紹介します。

  • UIガードのみ
  • ドメインガードのみ
  • UI + ドメインの二重防御

それぞれのトレードオフは次の通りです。

  • UIガードのみ: 実装は速いですが、別画面や将来追加される導線から確定処理が呼ばれたときの抜け道を防ぎ切れない。
  • ドメインガードのみ: 強制力は高いですが、ユーザーは押してから失敗を知ることになり、時間外の体験としては不親切。
  • 二重防御: 実装点は増えますが、画面上では先回りして案内でき、最後はドメインで必ず守れるので、UX・安全性の両立ができる。

さらに、「サーバー時刻を使って判定するか」も検討しました。ただ、現時点では改ざん耐性よりも UX と実装のシンプルさを優先し、端末時刻を前提にしています。サーバー時刻基準にすると、通信待ちやオフライン時の扱いまで設計対象が広がるため、今回は不採用にしました。

この判断は ADR に残し、採用しなかった案も含めて後から追えるようにしました。

3. 実装

ファイルのつながりは、PostView.swiftPostViewModel.swift を呼び、PostViewModel.swiftPostDomainPolicy.swift を呼ぶ形です。
この流れの中で、UI ガードは View と ViewModel が担当し、ドメインガードは PostDomainPolicy.swift が担当します。

(ここでは投稿時間帯ロジックに関係する部分だけを抜粋しているため、保存処理や一部の引数・生成処理は省略しています。)

まず View では、ViewModel が持つ状態を画面に反映し、ボタン押下時に ViewModel の投稿処理を呼び出します。
対象ファイルは PostView.swift です。

// PostView.swift

@StateObject private var viewModel: PostViewModel

@MainActor
init(viewModel: PostViewModel? = nil) {
    // 外から渡されなければ標準の ViewModel を生成する
    _viewModel = StateObject(wrappedValue: viewModel ?? PostViewModel())
}

// ... 画面表示は省略 ...

// ViewModel が持つ補足メッセージを画面に表示する
if let infoMessage = viewModel.subInfoMessage {
    Text(infoMessage)
        .foregroundStyle(.orange)
}

private var postButton: some View {
    Button {
        Task {
            // ボタン押下時は ViewModel の投稿処理を呼び出す
            await viewModel.didTapPostButton()
        }
    } label: {
        if viewModel.isPosting {
            ProgressView()
        } else {
            Text("投稿")
        }
    }
    // UI ガード: ViewModel の判定結果に応じてボタンを無効化する
    .disabled(!viewModel.isPostButtonEnabled || viewModel.isPosting)
}

次に ViewModel では、入力状態と現在時刻から UI ガード用の状態を更新し、確定時にはドメイン側の検証ロジックを呼び出します。
対象ファイルは PostViewModel.swift です。

// PostViewModel.swift

// 入力中の本文
@Published var text: String = ""

// 画面に表示する補足メッセージ
@Published private(set) var subInfoMessage: String?

// 投稿ボタンを押せるかどうか
@Published private(set) var isPostButtonEnabled: Bool = false

private func updatePostButtonState() {
    let trimmed = PostDomainPolicy.trim(text)
    let isWithinTime = PostDomainPolicy.isWithinPostingTime(
        now: nowProvider(),
        calendar: calendar
    )
    let hasText = !trimmed.isEmpty

    // UI ガード: 「時間内」かつ「本文あり」のときだけ押せる
    isPostButtonEnabled = isWithinTime && hasText

    // UI ガード: 時間外なら案内文を表示する
    if !isWithinTime {
        subInfoMessage = PostDomainError.postingTimeOutsideWindow.userMessage
    } else {
        subInfoMessage = nil
    }
}

func didTapPostButton() async {
    let draft = PostDraft(body: text)

    do {
        // 確定時はドメイン側のルールを通して Post を生成する
        let post = try PostDomainPolicy.makePost(
            draft: draft,
            authorId: authorIdProvider(),
            now: nowProvider(),
            calendar: calendar
        )

        // ... repository.save(post) など保存処理は省略 ...
    } catch let domainError as PostDomainError {
        // ... エラーメッセージ表示処理は省略 ...
        showError(message: domainError.userMessage)
    } catch {
        // 想定外のエラーは汎用メッセージで扱う
        showError(message: "投稿の処理中にエラーが発生しました。")
    }
}

最後にドメイン側では、確定直前に本文必須と投稿可能時間を検証します。
対象ファイルは PostDomainPolicy.swift です。

// PostDomainPolicy.swift

static func makePost(
    draft: PostDraft,
    authorId: String,
    now: Date,
    calendar: Calendar = .current
) throws -> Post {
    // ドメインガード: Post を作る前にルールを検証する
    try validate(draft: draft, now: now, calendar: calendar)

    // ... Post の生成処理は省略 ...
}

static func validate(
    draft: PostDraft,
    now: Date,
    calendar: Calendar = .current
) throws {
    let trimmed = trim(draft.body)

    // ドメインガード: 本文が空なら投稿不可
    guard !trimmed.isEmpty else {
        throw PostDomainError.bodyRequired
    }

    // ドメインガード: 投稿可能時間外なら確定を拒否する
   // 4:00〜8:59 かどうかを返す関数の中身は省略
    guard isWithinPostingTime(now: now, calendar: calendar) else {
        throw PostDomainError.postingTimeOutsideWindow
    }
}

このように、PostView.swiftPostViewModel.swift を呼び、PostViewModel.swiftPostDomainPolicy.swift を呼ぶ流れにしています。
そのうえで、UI では「今押せるかどうか」を先に伝え、ドメインでは「本当に確定してよいか」を最後に判定する形にしました。

4. 動作確認

設計判断を説明だけで終わらせないために、今回は境界時刻での UI の振る舞いも確認しました。
投稿可能な 8:59 と、投稿不可に切り替わる 9:00 で、画面上の状態とドメインルールが守られていることを確認します。

UI ガード

8:59 と 9:00 時点のスクリーンショットです。

朝の実 8:59 で投稿可能な画面
朝の実 9:00 で投稿不可な画面

境界の前後で投稿ボタンの状態と案内文が切り替わることを確認しました。
次に、境界をまたいだ時に UI が更新されなくてもドメインルールから逸脱しないことを確認します。

ドメインガード

8:59の時点では投稿ボタンは活性です。そのまま 9:00 をまたいだ瞬間に、画面表示が即時で追従するとは限りません。

この状態で UI ガードのみだと、「本来は投稿できないけどできてしまう」というズレが起きます。そこで、UIガードに加えて、確定直前にもドメインルール側で「時間外」として拒否する構成にしています。

以下のスクリーンショットは時間外に切り替わった後に投稿ボタンを押下した時のエラーメッセージ表示です。

朝の実 9:00 で投稿ボタンを押下したさいのエラーメッセージ

時間外になったあとに投稿処理が走っても、最後はドメイン側で止められることが確認できました。
今回の時間制御では、この最終判定を UI とは別に持つことで、境界時刻のずれに備えています。

5. まとめ

時間制御は単なるボタン活性の話ではなく、UI、ドメイン、境界条件をどう分担するかの設計問題でした。
今回の実装では、ルールを確実に守りつつ UX も損なわないように、UI は先に伝え、ドメインは最後に守る形にしました。

UI のみ、ドメインのみ、サーバー時刻基準も含めて比較した結果、現時点では UI + ドメインの二重防御が最も無理のない選択でした。
特に、8:59→9:00 の境界で画面表示と確定処理がずれうることを考えると、最後にドメイン側で止める設計は外せないと考えています。

sho shimizu

SwiftUI を用いたiOS アプリの個人開発を行っています。
これまでに2本のアプリを App Store にリリースしました。

現在は、朝専用SNS「朝の実(Asanomi)」を開発中です。
CloudKitやSwift Concurrencyを用いた実装を進めながら、
設計や実装上で整理した内容を技術記事にまとめています。

sho shimizuをフォローする
アプリ開発朝の実開発記録
シェアする
sho shimizuをフォローする