コメント通知実装で学ぶDjangoプロパティとサービスクラス

# はじめに
:::note warn
この記事は以下の記事の続きになっています。

https://qiita.com/Yuya_baseball/items/a7417666ebd88ca3d3bc

DB設計等、前提を記載していますので一読してから本記事を読んでください。
:::

前回は通知モデルの構成を考え
リポスト、いいね通知の実装までをアウトプットしました。
今回はコメント通知実装についてアウトプットしていきます。

# 目的
コメント通知機能を実装する

# 通知モデル
```apps/notifications/models.py
from django.db import models
from django.core.exceptions import ValidationError
from apps.comments.services import CommentService

# Create your models here.

class Notification(models.Model):
    ACTION_TYPE = [
        ('repost', 'リポスト'),
        ('like', 'いいね'),
        ('comment', 'コメント'),
    ]

    post = models.ForeignKey(
        "posts.Post",
        verbose_name="ポストID",
        on_delete=models.CASCADE,
        related_name="notifications"
    )
    actor = models.ForeignKey(
        "accounts.CustomUser",
        verbose_name="ユーザーID",
        on_delete=models.CASCADE,
        related_name="notifications"
    )
    action_type = models.CharField(
        choices=ACTION_TYPE,
        verbose_name='アクションタイプ',
        max_length=50,
    )
    created_at = models.DateTimeField(
        verbose_name="作成日時",
        auto_now_add=True
    )
    updated_at = models.DateTimeField(
        verbose_name="更新日時",
        auto_now=True
    )

    class Meta:
        ordering = ["-id"]
        db_table = "notifications"
        constraints = [
            # DBレベルでpost, actor, action_type の重複を防ぐ
            models.UniqueConstraint(
                fields=['post', 'actor', 'action_type'],
                name=('unique_post_actor_action_type')
            )
        ]

    def get_notification_icon(self):
        """
        通知アイコンを管理する関数

        Returns:
            str: SVGアイコンのHTML文字列。該当するアイコンがない場合は空文字を返す
        """
        icons = {
            'repost': '''<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="#49BF00" class="bi bi-arrow-down-up" viewBox="0 0 16 16">
                        <path fill-rule="evenodd" d="M11.5 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L11 2.707V14.5a.5.5 0 0 0 .5.5m-7-14a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L4 13.293V1.5a.5.5 0 0 1 .5-.5"/>
                        </svg>''',
            'like': '''<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="red" class="bi bi-heart-fill" viewBox="0 0 16 16">
                        <path fill-rule="evenodd" d="M8 1.314C12.438-3.248 23.534 4.735 8 15-7.534 4.736 3.562-3.248 8 1.314"/>
                        </svg>''',
        }
        return icons.get(self.action_type, '')

    # アプリレベルで二重通知を防ぐ
    def clean(self):
        if Notification.objects.filter(post=self.post, actor=self.actor, action_type=self.action_type).exists():
            raise ValidationError("この通知は既に実行済みです")

    def __str__(self):
        return f"UserID:{self.actor.id} -> {self.post.id} is {self.action_type}"
```
現状は上記のコードになっています。

# リポスト、いいね通知の場合
![スクリーンショット 2025-06-04 22.28.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/bb73e14d-db14-4c33-8190-1b2cb1e73d5e.png)

リポストといいね通知の場合、誰がログインユーザーのポストにアクション（リポスト or いいね）したか分かればいいので `actor` から情報を取得することが可能です。
実際に `views.py`で`queryset`を定義して取得しています。
```apps/notifications/views.py
def get_queryset(self):
        return Notification.objects.filter(post__user=self.request.user).select_related(
            'actor',  # ここからアクションしたユーザーの情報を取得できる
            'actor__profile',
            'post'
        ).order_by('-created_at')
```

誰が該当ポストに対して「リポスト・いいね」したか分かればいいので
`actor`のリレーションを活用して情報を取得できます。

# コメント通知は内容を出力する必要がある
![スクリーンショット 2025-06-05 15.42.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3918502/d92bab12-970e-471c-9817-4c1dbb609ca6.png)
コメントの場合、コメントしたユーザーの情報に加えてどんなコメントをしたかを取得する必要がありました。

# View の `get_queryset` から取得する
初めはリポスト、いいねと同様に`get_queryset`から取得すればよくないか？と考え実装しようと考えていました。

リレーションの経路はこのようになります。
**通知 → 投稿 → コメント群 → 該当ユーザーのコメント取得**

なるほど、これをquerysetに含めればいいのか！！

## 実際のコード
```python
def get_queryset(self):
    notifications = Notification.objects.filter(
        post__user=self.request.user
    ).select_related(
        'actor',
        'actor__profile', 
        'post'
    )
    
    # 通知IDとコメントのマッピングを作成
    notification_ids = list(notifications.values_list('id', flat=True))
    comments_map = {}
    
    for notification in notifications:
        if notification.action_type == 'comment':
            comment = Comment.objects.filter(
                user=notification.actor,
                post=notification.post
            ).first()
            comments_map[notification.id] = comment
    
    # 通知オブジェクトにコメントを紐付け
    for notification in notifications:
        notification._cached_comment = comments_map.get(notification.id)
    
    return notifications
```

テンプレート側では以下のように書けます。
```html
{% for notification in notifications %}
    {{ notificaion._cached_comment.message }}
{% endfor %}
```

# もっと直感的にする
現状の`queryset`では以下の問題点があります。
1. パフォーマンスの問題
→ N+1問題が発生する可能性 
1. コードの複雑性
→ 20行以上のコードでバグの元になりやすい
1. 拡張性の問題
→ 新しい通知タイプ追加時に大幅修正が必要
→ ビジネスロジックがViewに混在

`get_queryset`から取得するよりも、もっと容易に取得できわかりやすいコードにすることができます。

## `Notification` モデルから `CommentService` を呼び出す
先に結論のコードを書きます
```apps/notifications/models.py
class Notification(models.Model):
    ...
    @property
    def comment(self):
        """
        通知のコメントを取得する関数
    
        通知のアクションタイプが'comment'の場合に
        該当する投稿に対するアクターのコメントを取得。
    
        Returns:
            Comment | None: 通知に対応するコメントオブジェクト。存在しない場合はNone
        """
        return CommentService.get_comment_by_notification(self)
    ...
```
```apps/comments/services.py
...
from .models import Comment

class CommentService:
    ...
    @staticmethod
    def get_comment_by_notification(notification):
        """
        通知に対応するコメントを取得する

        Args:
            notification (Notification): 通知オブジェクト

        Returns:
            Comment | None: 通知に対応するコメントオブジェクト。存在しない場合はNone
        """
        return Comment.objects.filter(user=notification.actor, post=notification.post).first()
```
```html
{% for notification in notifications %}
    {{ notificaion.comment.message }}
{% endfor %}
```
## このコードがなぜいいのか
### 可読性が良い
`get_queryset` に20行以上のコードを書くより
- テンプレートで呼び出し
- `Notification` モデルでデータアクセスの窓口を作る
- `CommentService` でビジネスロジックを記載（コメントを取得）

このようにコードを辿ることが容易になります。

また、テンプレートでコードを書いた時に直感的でわかりやすいです。
```python
notification.comment   # この通知のコメントが欲しい

notification._cached_comment   # 何がしたいのかわからない。なぜcachedがついているのか？
```

### 責任の分離
`get_queryset`に全て書くことも可能ですが、データアクセスはモデル、ビジネスロジックはサービスとすることで責任の分離をすることができます。
```python
# models.py - データアクセスの窓口のみ
@property
def comment(self):
    return CommentService.get_comment_by_notification(self)

# services.py - ビジネスロジック
@staticmethod
def get_comment_by_notification(notification):
    return Comment.objects.filter(user=notification.actor, post=notification.post).first()
```

```python
# views.py - 全部ごちゃ混ぜ
def get_queryset(self):
    # データ取得 + マッピング + キャッシュ処理が全部混在
    notifications = Notification.objects.filter(...)
    comments_map = {}
    for notification in notifications:
        # ビジネスロジックがViewに...
```
### パフォーマンス（遅延評価）
プロパティを使うことで必要な時だけクエリが実行することができます。
```python
# 必要な時だけクエリ実行
{% for notification in notifications %}
    {% if notification.action_type == 'comment' %}
        {{ notification.comment.message }}  <!-- ここで初めてクエリ -->
    {% endif %}
{% endfor %}
```
get_querysetで一括処理すると、全通知分のコメントを一度に取得・保持するため使わないものも取得してしまいます。

# まとめ
今回はコメント通知の実装について、モデルプロパティとサービスクラスを使った設計をアウトプットしました。
## 設計のポイント

可読性の向上: notification.commentで直感的にアクセス可能
責任の分離: データアクセスとビジネスロジックを分離
パフォーマンス: 遅延評価で必要な分だけリソース使用

## 学んだこと
最初はget_querysetで全部処理すればいいと考えていましたが
実装してみると複雑になりすぎることがわかりました。

モデルプロパティとサービスクラスに分離することで
コードがシンプルで保守しやすくなることを実感しています。

特に「どこに何の責任を持たせるか」を考えることの重要性を学びました。
データアクセスはモデル、ビジネスロジックはサービスという
役割分担を意識することで拡張性の高い設計ができると思います。