DDDにCQRSをどう組み込むか~バックエンドアーキテクチャ設計時の考え方

はじめに

こんにちは。テックドクターでバックエンドエンジニアをしている筧と申します。

新規プロダクトのバックエンドで、DDD (Domain-Driven Design) と CQRS (Command Query Responsibility Segregation) を組み合わせたアーキテクチャを採用しました。

DDDの本や記事は、Eric Evans著『Domain-Driven Design』や『実践ドメイン駆動設計』など様々あります。CQRSについてもMartin Fowler氏のCQRSの解説記事などがあります。しかし、DDDにCQRSをどう組み込んでいったかという話はあまり見かけません。

この点について以前より情報収集や試行錯誤を重ねていましたが、今回のプロダクトでようやく納得のいく形で実装ができました。この記事ではそのポイントをご紹介します。特にCQRSを具体的に実装していくApplication層を中心に、他の層とのデータのやりとりや責務分担について詳しく説明したいと思います。

この記事の想定読者とゴール

この記事は、以下のような方を想定しています:

  • DDDの基本(Entity、Value Object、Repository等の概念)は理解している
  • 実際のコードでCQRSをどう導入すればよいかわからない

記事を読み終わったときに、Application層のCommand/QueryHandlerの実装や、他の層とのデータのやりとりを具体的にイメージできるようになることがゴールです。

CQRSとは何か

まず、この記事でのCQRSの定義を明確にしておきます。

CQRSは、データを変更する操作(Command)と、データを読み取る操作(Query)を分離するパターンです。通常のCRUDではデータモデルが読み書き共通ですが、CQRSでは以下のように分けます:

  • Command側(書き込み): ビジネスルールの検証を重視。Entityを経由してデータを更新
  • Query側(読み込み): パフォーマンスと利便性を重視。最適化されたクエリで直接DTOにマッピング

この分離により、それぞれの操作に最適な実装を選択できるようになります。

全体像:レイヤー構成の概略と責務の割り当て

今回のプロダクトは一種のダッシュボードシステムで、データの集計・可視化を行うほか、組織やユーザー情報等の登録も行います。
レイヤー構成は以下の5層としました。

層名 説明
Presentation層 ← API エンドポイント(薄い層)
UseCase層 ← ビジネスフロー + 認可制御
Application層 ← Command/Query Handler(純粋なCRUD)
Domain層 ← Entity、Value Object、Repository Interface
Infrastructure層 ← Repository実装、Query Service

各層の責務を簡単に整理します。

Presentation層はHTTPリクエスト/レスポンス変換のみを担当します。ビジネスロジックは持たせません。

UseCase層は認可チェックや、複数のCommand/Queryを組み合わせたビジネスフローを扱います。

Application層は純粋なCRUD操作のみで、認可処理は持ちません。

Domain層はビジネスルールの中核を担い、他の層に依存しません。

Infrastructure層はデータベースや外部サービスとの実際のやりとりを担当します。

Core層もありますが、DIコンテナや共通設定を担う補助的な層なので本稿では詳しく扱いません。

特に重要なのはUseCase層とApplication層の境界です。Application層のCommand/QueryHandlerは認可処理を持たず、純粋にドメインロジックに集中します。一方、認可や監査ログといった横断的な関心事はUseCase層で扱います。

この設計にした理由は、別プロダクトでUseCase層とその下のService層(今回のApplication層に相当)を分けて成功した経験があったからです。UseCase層にCQRSのCommand/Queryを直接配置すると、認可処理とデータ操作のロジックが混在してしまうため、Application層として分離しました。

Application層におけるCQRS実装

Application層では、Command(書き込み)とQuery(読み込み)を明確に分離しています。

Commandの実装例:組織を作成する

例として、組織を作成するCommandHandlerを見てみましょう。以下は実際のプロダクトのコードを簡略化したサンプルです。

class CreateOrganizationCommand(BaseModel):
    """組織作成コマンド"""
    organization_id: OrganizationId
    name: OrganizationName
    # ... その他のフィールド

class CreateOrganizationCommandResult(BaseModel):
    """組織作成結果"""
    organization: Organization
    created: bool

class CreateOrganizationCommandHandler(
    ICommandHandler[CreateOrganizationCommand, CreateOrganizationCommandResult]
):
    """組織作成コマンドハンドラー"""

    def __init__(self, organization_repository: IOrganizationRepository) -> None:
        self._organization_repository = organization_repository

    async def handle(
        self, command: CreateOrganizationCommand
    ) -> CreateOrganizationCommandResult:
        # 1. 既存チェック
        if await self._organization_repository.exists(command.organization_id):
            raise EntityAlreadyExistsException(...)

        # 2. ドメインモデルでビジネスルール検証
        organization = Organization.create(
            organization_id=command.organization_id,
            name=command.name,
            # ...
        )

        # 3. 永続化
        await self._organization_repository.save(organization)

        return CreateOrganizationCommandResult(
            organization=organization,
            created=True,
        )

このCommandHandlerで最も重要なのは、認可処理を一切持たない点です。「この組織を作成できる権限があるか?」といったチェックはUseCase層の仕事で、Application層は純粋に「組織の作成」というビジネスロジックに集中しています。

具体的な処理の流れは、入力としてCommand(必要なデータのみ)を受け取り、出力としてCommandResult(処理結果)を返します。内部では、ドメインモデルのOrganization.create()を使ってビジネスルール検証を行い(例えば、OrganizationNameというValue Objectで組織名の長さや形式をチェックしています)、最後にRepositoryで永続化します。

こうすることでHandlerがシンプルになり、テストも書きやすくなります。認可を気にする必要がなく、Infrastructure層のDBセッションにも依存しないため、モックのRepositoryを渡すだけで単体テストができました。

Queryの実装例:組織を取得する

次に、組織を取得するQueryHandlerです。

class GetOrganizationQuery(BaseModel):
    """組織取得クエリ"""
    organization_id: OrganizationId

class GetOrganizationQueryResult(BaseModel):
    """組織取得結果"""
    organization: OrganizationDTO | None

class GetOrganizationQueryHandler(
    IQueryHandler[GetOrganizationQuery, GetOrganizationQueryResult]
):
    """組織取得クエリハンドラー"""

    def __init__(self, organization_repository: IOrganizationRepository) -> None:
        self._organization_repository = organization_repository

    async def handle(self, query: GetOrganizationQuery) -> GetOrganizationQueryResult:
        organization = await self._organization_repository.find_by_id(
            query.organization_id
        )

        if organization is None:
            return GetOrganizationQueryResult(organization=None)

        # Entity → DTOへの変換
        organization_dto = OrganizationDTO(
            organization_id=str(organization.organization_id.value),
            name=str(organization.name.value),
            created_at=organization.created_at,
            # ...
        )

        return GetOrganizationQueryResult(organization=organization_dto)

QueryHandlerで重要なのは、Entity→DTOの変換です。入力としてQuery(検索条件)を受け取り、出力としてQueryResult(DTO形式のデータ)を返します。責務はデータ取得とDTO変換のみで、Command側のようなビジネスルール検証は行いません。

DTOに変換する理由は、Presentation層で使いやすい形にするためです。Entityはビジネスロジックを持つ重いオブジェクトですが、DTOは単なるデータ転送用の軽いオブジェクトです。この変換をApplication層で行うことで、Presentation層はシンプルに保てます。

データモデルの使い分け

実装していて最も悩んだのが、「どのデータモデルをどこで使うか」でした。

当初、各データモデルの役割は ”なんとなく” 決まっていたものの、具体的なルールがありませんでした。例えば、DTOの使い回しや、概念の分離ができていなかったりしました。

最終的に、各層の境界を明確にするため、以下のようにデータモデルを整理しました:

データモデル 役割 配置場所 命名の由来
Command/Query リクエストデータ(入力) Application層 CQRSの概念からそのまま
CommandResult
/QueryResult
レスポンスデータ(出力) Application層 Commandの結果、Queryの結果という明確な名前
Projection Infrastructure
→Application層のデータ
Infrastructure層 CQRSの文献で使われている用語(後述)
DTO Application
→Presentation層のデータ
Application層 特に他の名前が思いつかなかった

Projectionという名前について補足します。当初はXXResultという名前も検討しましたが、CommandResult/QueryResultと名前が被ってしまうこと、そしてRepositoryの結果とQuery Serviceの結果の両方があり「どちらの名前を取るか」という論争が起きそうだったため不採用にしました。

CQRSの文献を調べたところ、読み取り側のデータモデルとして「Projection」という用語が使われていること(参考1参考2)がわかり、この名前を採用しました。

これらを明確に分けることで、各層の関心事が混ざらないようにできています。特に名前付けには苦労しましたが、役割が明確になってからはコードの見通しが格段に良くなりました。

UseCase+Infrastructure:認可とデータアクセスをどう接続したか

Application層のCommand/QueryHandlerは純粋なCRUD操作だけを扱うので、認可処理はUseCase層で行います。

UseCaseでの認可統合:Command実行前に権限チェック

UseCase層では、Application層のHandlerをラップして認可処理を追加します。実装例を見てみましょう。

class CreateOrganizationUseCase:
    """組織作成UseCase(認可付き)"""

    def __init__(
        self,
        permission_checker: PermissionChecker,
        create_organization_handler: CreateOrganizationCommandHandler,
    ):
        self._permission_checker = permission_checker
        self._create_organization_handler = create_organization_handler

    async def execute(
        self,
        request: CreateOrganizationRequest,
        user_claims: JWTClaims
    ) -> Organization:
        # 1. 認可チェック(UseCase層の責務)
        await self._permission_checker.verify_role(user_claims, required_roles=["org-admin"])

        # 2. RequestからCommandへの変換
        command = CreateOrganizationCommand(
            organization_id=OrganizationId.generate(),
            name=OrganizationName(request.name),
            # ...
        )

        # 3. Application層のHandlerを実行
        result = await self._create_organization_handler.handle(command)

        return result.organization

このように、認可チェックとCommand実行を分離することで、いくつかのメリットがあります。

まず、Application層はビジネスロジックに集中できます。「組織を作成する」というドメインロジックに認可処理が混ざらないので、コードが読みやすくなります。

次に、認可ロジックを一箇所に集約できます。権限チェックの方法を変更したいとき、UseCase層だけ修正すれば済みます。

そして何より、テストが書きやすくなります。Application層はビジネスロジックの単体テストに集中でき、UseCase層は認可のテストとして分離できました。

認可処理の設計

認可処理の実装について、今回のプロダクトでは細かいロール設定をバックエンド側で担当することにしました。これにより、より柔軟な権限管理を実現しています。

Query Serviceでのデータ取得最適化

読み取り側において、複雑なクエリはInfrastructure層のQuery Serviceで最適化します。

Query Serviceとは、最適化されたクエリを実行するための読み取り専用のサービスです。Repositoryパターンとは異なり、複数のテーブルをJOINして1回のクエリで必要なデータを取得することに特化しています。

今回のプロダクトは、ダッシュボードシステムであり、複雑なデータの集計・可視化を行います。Repositoryパターンだけだと大量のクエリが発生し、パフォーマンスが低下する可能性があるため、CQRSを採用し、読み取り側ではQuery Serviceを使って1回のクエリで効率的にデータを取得しています。

具体例を見てみましょう(実際のプロダクトのコードを簡略化したものです):

class OrganizationQueryService:
    """組織クエリサービス(Infrastructure層)"""

    async def get_organization_with_stats(
        self, organization_id: str
    ) -> OrganizationProjection | None:
        # 最適化されたJOINクエリで一度に取得
        query = select(
            OrganizationModel.id,
            OrganizationModel.name,
            func.count(MemberModel.id).label('member_count'),
            func.count(ItemModel.id).label('item_count'),
        ).select_from(
            OrganizationModel
        ).outerjoin(
            MemberModel
        ).outerjoin(
            ItemModel
        ).where(
            OrganizationModel.id == organization_id
        ).group_by(OrganizationModel.id)

        result = await self._session.execute(query)
        row = result.first()

        if not row:
            return None

        # Projectionとして返す(DTO変換はQueryHandlerで)
        return OrganizationProjection(
            id=row.id,
            name=row.name,
            member_count=row.member_count or 0,
            item_count=row.item_count or 0,
        )

ここでのポイントは、Query ServiceがProjectionを返す点です。データフローは次のようになります:

QueryService(Projection) → QueryHandler(DTO) → UseCase → API(Response)

ProjectionはInfrastructure層からApplication層へのデータモデル、DTOはApplication層からPresentation層へのデータモデルと、役割が分かれています。

この設計にしたのは、Infrastructure層にドメイン知識を漏らさないためです。もしQuery ServiceがDTOを直接返すと、Infrastructure層がPresentation層の都合(どんな形式でデータを返すか)を知る必要があり、依存方向が逆転してしまいます。Projectionという中間データモデルを挟むことで、各層の責務を明確に保てています。

Query ServiceとRepositoryの使い分け

具体的な判断基準として、複数回JOINが必要かどうかを見ています。

単純な取得であればRepositoryを使いますが、組織に紐づく複数の関連データを同時に取得する場合など、複数のテーブルをJOINする必要がある場合はQuery Serviceを使います。これにより、クエリ実行回数を抑え、パフォーマンスを向上させています。

Presentation層とCore層の扱い

Presentation層:UseCaseに委譲するだけの薄い層

APIエンドポイントは本当に薄く、UseCaseに処理を委譲するだけです。

@router.post("", response_model=CreateOrganizationResponse, status_code=201)
@require_roles(["org-admin"])
async def create_organization(
    request: CreateOrganizationRequest,
    create_organization_use_case: Annotated[
        CreateOrganizationUseCase, Depends(get_create_organization_use_case)
    ],
    user_claims: Annotated[JWTClaims, Depends(get_current_user)],
) -> CreateOrganizationResponse:
    """組織作成エンドポイント"""
    try:
        # UseCaseに処理を委譲
        organization = await create_organization_use_case.execute(request, user_claims)

        # レスポンスに変換して返すだけ
        return CreateOrganizationResponse(
            organization_id=str(organization.organization_id.value),
            name=str(organization.name.value),
            # ...
        )
    except ValidationException:
        # エラーハンドリング(詳細は省略)
        raise

エンドポイントではビジネスロジックを持たず、HTTPの世界とアプリケーションの世界を繋ぐ責務だけに徹しています。実際のコードを見ても、UseCaseの実行結果をResponseに変換し、例外をHTTPステータスコードに変換しているだけです。

この設計にしてよかったのは、エンドポイントのテストがシンプルになったことです。HTTPリクエストの形式が正しいかだけをテストすればよく、ビジネスロジックのテストはUseCase層で完結します。

Core層

Core層はDI ContainerやJWT検証といった共通機能を提供していますが、アーキテクチャの中心ではないので本稿では詳しく触れません。重要なのは、各層の責務を明確に分けることです。

まとめ:実際に導入してみて感じたメリット

DDD+CQRSを実際のプロダクトに導入してみて、以下のメリットを感じました。

まず、認可ロジックの明確化です。UseCase層に認可処理を集約することで、「誰が何にアクセスできるか」が一目でわかるようになりました。

次に、テストの容易性です。Application層のHandlerがシンプルなので、単体テストが書きやすくなりました。認可処理と分離されているため、ビジネスロジックのテストに集中できます。また、Infrastructure層に依存しないため、DBセッションを用意する必要もありません。

そして、保守性の向上です。各層の責務が明確なので、変更の影響範囲が予測しやすく、コードの見通しが良くなりました。新しいメンバーがジョインしたときも、「この機能はどこを見ればいい?」という質問に明確に答えられるようになっています。

特にApplication層とUseCase層を分けたことで、ビジネスロジックと認可処理が混ざらず、それぞれに集中できるようになりました。当初は「層が多すぎて複雑では?」と心配したのですが、実装を進めるうちに明確なルールが整備され、責務が明確な分、むしろシンプルに感じています。

DDDもCQRSも学習コストは高いですが、実際に手を動かして設計してみると、その価値が実感できます。この記事が、これからDDD+CQRSを導入しようとしている方の参考になれば幸いです。

似顔絵
書いた人:筧

UI生成ツールv0でゼロイチ開発が変わった話~AI製プロトタイプの「捨てやすさ」が仮説検証を加速する

こんにちは。テックドクターでプロダクトマネージャーをしている田向です。

テックドクターでは各種AIツールを積極的に導入し、プロダクト開発のプロセス改善に取り組んでいます。

中でもUIデザイン生成ツールv0の導入は、単に個々のプロトタイプの実装を効率化するだけでなく、プロトタイプ開発全体を大きく加速させてくれました。

本記事では、v0の概要から具体的な導入効果までをご紹介します。

v0とは

v0は、Vercelが開発したAIツールです。自然言語のプロンプトや画像、Figmaファイルなどをもとに、WebやモバイルアプリのUIを簡単に作成できます。

v0.app

v0を選んだ理由

他にもUIを生成できるAIツールはありますが、今回は下記の理由からv0を選びました。

非エンジニアでも使える手軽さ

v0の大きな魅力は、自然言語のプロンプトでUIを生成できる点です。

大まかな指示でも一定の品質のものが作成できますが、プロンプトで要件を細かく指定したり参考画像を提示することで、さらに質が高く、意図に近いプロトタイプを仕上げることができます。

簡易的な指示で作成したサンプル。所要時間は1分程度!プロンプトは「プロジェクト管理のSaaSのダッシュボード画面を作成してください」

shadcn/ui との親和性

現在開発中のプロダクトでは、UIコンポーネントライブラリにshadcn/uiを採用しています。
v0はshadcn/uiを使ってUIを生成するため、プロトタイプとして作成したコードを本番の実装にも活かすことができ、開発のリードタイム短縮に繋がります。

※shadcn/uiとは

shadcn/uiは、Radix UIとTailwind CSSを組み合わせて構築された、カスタマイズ性に優れたコンポーネントライブラリです。
従来のライブラリと異なり、必要なコンポーネントのコードを直接自分のプロジェクトにインストールする方式のため、デザインの微調整や機能追加を自由に行えるのが特徴です。

v0の導入効果

v0を導入することで、様々なメリットがありました。

プロトタイプ開発の高速化とコスト削減

v0の導入前、プロトタイプを開発するには、①エンジニアやデザイナーに要件を伝え、②仕様書を作成し、③開発する……という工程が必要でした。関わる人数も多くなりますし、開発期間もアイデアから検証までに数週間かかることが当たり前でした。

v0導入後は、プロダクトマネージャーが自然言語の指示により自分でプロトタイプを作成できるようになりました。プロトタイプ作成にくわえフィードバックを受け改善するサイクルまでが1人で完結するようになったことで、プロトタイプ開発のコストが大幅に削減されました。

認識のズレと開発の手戻り防止

従来、仕様の共有はドキュメントや静的なワイヤーフレームで行っていましたが、静的なドキュメントだけでは関係者がそれぞれ頭の中で挙動を想像しながら議論することになり、認識のズレが生まれがちです。
その結果、実装後に「イメージと違う…」という手戻りが発生することも少なくありませんでした。

v0導入後はv0で作成したプロトタイプを中心に議論することで、認識のズレに起因する開発の手戻りを未然に防げるようになりました。

「捨てやすさ」が仮説検証を加速する

プロトタイプの目的は、あくまでアイデアの仮説検証です。

仮説検証の結果、もしユーザーにとってそのアイデアに価値がないと分かれば、すぐに捨てて次のアイデアを考えるのが正しいアプローチです。

しかし、エンジニアが時間をかけて作ったプロトタイプは、「せっかく作ってもらったのだから…」というサンクコストバイアスに陥りやすく、客観的な判断を鈍らせる原因になり得ます。

その点、v0で作成したプロトタイプはひとりで短時間で作成したものです。検証結果が思わしくないときは、何の心理的な負い目も感じることなく、躊躇なく捨てることができます。

このことが、プロトタイプ開発における仮説検証のサイクル全体を大きく加速させてくれました。

今後の展望

現在はshadcn/uiのレジストリからコンポーネントを配布し、v0のプロジェクトに適用する検証を進めています。
本番環境と同等のコンポーネントを使うことで、質の高いプロトタイプをより短時間で作成できるようになることを目指しています。

ui.shadcn.com

今回はAIツール導入の一例として、v0の導入効果についてご紹介しました。参考になれば幸いです。

似顔絵
書いた人:田向

SaaS型翻訳サービスWeglotでReact SPAを多言語化した

はじめに

こんにちは。エンジニアリングマネージャの星野です。今回はReactを用いたSPAの他言語化についての事例を紹介します。

テックドクターで開発している臨床研究支援システム「SelfBase」において、海外案件への対応をきっかけに管理画面の多言語化が必要となりました。

フロントエンドはReactを用いたSPA(Single Page Application)で構築されており、自前で実装する場合は react-i18next などを利用したi18n(国際化)対応を行うことになります。そのためには数ヶ月単位の少なくない開発工数が見込まれました。

そこで、「自前での実装」という選択肢だけでなく、「SaaS型の翻訳サービス」の導入を本格的に検討することにしました。


フロントエンド技術スタックと検討ポイント

多言語化を検討するにあたり、技術スタックとSaaS選定のポイントについて説明します。

フロントエンド技術スタック

フロントエンドの主要な技術スタックは以下の通りです。

  • UI: React
  • 言語: TypeScript
  • ルーティング: React Router
  • スタイリング: MUI + Emotion

SaaS選定における検討のポイント

SaaSを選定するにあたっては、開発工数をかけずに多言語化を実現するため、いくつかの点を重視しました。

  1. SPAに対応していること。
  2. 翻訳管理が容易であること。たとえば、翻訳された文言の一元管理機能ができる、ユーザーデータのような翻訳不要な要素を柔軟に除外できる、など。
  3. 予算内で導入できるコストであるか。
  4. SOC2やGDPRなどのセキュリティ基準を満たしているか。
  5. 国内外での十分な導入実績があるか。

SaaSによる多言語化の比較検討

上記のポイントに基づき、いくつかのSaaSを比較検討しました。

サービス名 SPA対応 価格帯 特徴
shutto翻訳 実績不明 安価 国内サービス。SPAでの利用実績が確認できず、今回の要件には合致しませんでした。
WOVN.io 対応可能 高額(要問合せ) 日本国内での大手企業による採用実績が豊富。ただし、コストが比較的高額でした。アプリの翻訳機能もあり、予算があるなら利用したかった。
ConveyThis 対応可能 安価 比較的安価で導入しやすそう。ただ、後述するWeglotと比較すると実績やシェアの面で見劣りしていました。
Weglot 対応可能 手頃 SPA対応が可能。料金も手頃で、セキュリティ基準も満たしていました。調べた範囲ではPV数ベースの集計において高いシェアを持っているようでした。

検討結果

比較検討の結果、Weglotを導入することにしました。理由は下記です。

  • ReactなどのSPAプロジェクトへの導入がヘルプに記載されており、実績があると判断できました。
  • 実際にテストで動作させてみても、問題なく対応していることが確認できました。
  • 翻訳結果を管理するダッシュボードがあり、非エンジニアでも直感的に翻訳の修正や管理が可能でした。
  • AIを利用した翻訳が利用でき、翻訳の修正工数を減らせると期待しました。
  • SOC2およびGDPRに準拠しており、エンタープライズレベルのセキュリティ要件を満たしています。
  • Google検索やWappalyzerなどの情報から、ある程度シェアがあると判断。国外では大手での導入実績もあるようでした。
  • 必要な機能を備えつつ、料金が手頃で、予算感に最もマッチしていました。

実装と運用の工夫

Weglotの導入はスムーズでしたが、実際のプロダクトで運用するためにはいくつかの工夫が必要でした。

導入方法

  • Weglotでのプロジェクト作成

Weglotでアカウントを作成し、翻訳元(日本語)と翻訳先の言語(英語など)を設定します。

  • インテグレーションの方式の選択

サブドメイン方式とサブディレクトリ方式があります
SEOなどの観点からサブディレクトリ方式が推奨されていますが、今回はサブドメイン方式を選択しました。
理由としては、既存サイト全体がWeglot経由で配信されようになるため、影響範囲を抑える目的でサブドメイン方式を選択しました。

  • DNSレコードの設定

WeglotからCNAMEに登録すべきURLが提供されるので、利用しているDNSプロバイダー(Amazon Route 53、Google Cloud DNSなど)から、指定したサブドメインをCNAMEレコードとして、追加します。
この設定により、例えば、en.example.comへのアクセスがWeglotのサーバーに向けられ、翻訳されたコンテンツが配信されるようになります。

  • JavaScriptスニペットの埋め込み

発行されたAPIキーを含むJavaScriptスニペットをWebサイトに埋め込みます。
これは主に、翻訳単語の抽出や言語切り替えボタン(言語スイッチャー)の表示、言語によるサイトの自動切り替えなどに利用します。

  • SPA特有の設定

デフォルトでは動的に描画されるコンテンツは翻訳されません。
翻訳対象に含めるため、Weglotのプロジェクト設定にある Dynamic Element にて、 body セレクタを指定します。

キャプチャ
動的要素の翻訳設定画面

実装上の注意点

Weglotは手軽に導入できる反面、自由度は低いと感じました。
そのため運用にあたっては、以下のような工夫が必要でした。

翻訳対象の除外設定:

Weglotはデフォルトでページ上の全テキストを翻訳しようとします。翻訳が不要な箇所については、翻訳対象から除外する設定を行う必要がありました。

  • ユーザー名などのユーザが入力した内容

本来翻訳の対象としたくない部分ですし、翻訳の管理上もユーザが新しい単語を入力するために訳語の設定が必要となってしまうのは好ましくありません。
また、翻訳語数ベースの課金モデルにおいてコストを抑える意図もあります。

対策としては、翻訳したくない要素をラップするコンポーネントをReactで作成、特定のCSSセレクタを持つ要素を翻訳対象から除外する機能と組み合わせ、自動翻訳を抑止しました。

export const NoTranslate = forwardRef<HTMLElement, BoxProps>(
  ({ component = 'span', ...boxProps }, ref) => (
    <Box
      ref={ref}
      className="no-translate"
      translate="no"
      component={component}
      {...boxProps}
    >
      {boxProps.children}
    </Box>
  ),
)

NoTranslate.displayName = 'NoTranslate'

NoTranslateコンポーネント実装イメージ
 

キャプチャ
Weglotの自動翻訳抑止機能
  • 日付や数値のローカライズ

日付や数値は翻訳ではなく、各言語の文化に合わせたフォーマットが必要です。これらは翻訳対象から除外し、Intl.DateTimeFormatIntl.NumberFormatといったブラウザ標準APIで対応しました。

動的テキストの制約(語順と単数/複数形):

例えば {count}日間以上デバイスのデータがアップロードされてない場合 のような動的に数値を埋め込むテキストを翻訳する場合、{count} 部分は翻訳から除外するため、数字を除いた「日間以上デバイスのデータがアップロードされてない場合」というテキストをもとに翻訳が行われます。

これにより、2つの大きな問題が生じます。

1つ目は語順の問題です。
翻訳後のテキストは、翻訳されなかった {count} の後ろに単純に連結されます。しかし、多くの言語では日本語と語順が異なります。例えば、英語で「For more than {count} days...」のような {count} の前に前置詞が来るケースがありますし、他にも文法上、数字が文中に入ってしまう場合もあります。こういった文章は自然に作ることができません。

2つ目は単数/複数形の切り替えです。`{count}`の値に応じて `1 day` / `2 days` のように単語の形を変化させることができません。

今回はこれらの制約を許容し、妥協案として {count} day(s) without device data uploaded のような、英語表記で対応しました。

1つ目の問題は翻訳元の文に変数プレースホルダーを挿入する「variables」機能を利用することで解決はできそうでしたが、今回はそこまで対応することができませんでした。

文脈に応じた翻訳の難しさ

Weglotでは、基本的に一つの単語は一つの訳語に対応します。例えば「日」という単語を day(s) と翻訳した場合、別のページで「日曜日」の文脈で使われていても day(s) と訳されてしまう可能性があります。これを防ぐには、元の日本語の単語を文脈に応じてより具体的に(例:「日付」「日曜日」など)使い分けるといった、原文側での工夫が必要になります。

Weglotよりもさらに厳密な表現や文脈に合わせた翻訳が求められる場合は、やはり react-i18next のようなi18nライブラリによる本格的な対応が必要と感じました。

キャプチャ
該当部分の翻訳前
キャプチャ
該当部分の翻訳後

翻訳の質と翻訳管理について

自動翻訳の品質は、まずまずといった程度でしたが、AI機能を使うとかなり不自然な内容は減りました。

長文では、手動での言い換えが必要になる場合も多少ありましたが、短いフレーズや単語レベルの翻訳は正確でした。全体としては十分に実用的な品質といえます。

また、専門用語や製品名などについては、翻訳するかどうかも含めて辞書登録できる点が便利でした。

キャプチャ
会社名や、製品名を登録した例

さらに、翻訳結果にレビュー済みかどうかが分かる仕組みが用意されており、今後画面修正などが発生した際にも、追加翻訳をスムーズに行えるようになっています。

キャプチャ
フィルタ機能で、自動翻訳対象のテキストを検索している様子。(すべて翻訳が登録されているので結果は0件)

i18nライブラリのように翻訳者が翻訳ファイルを直接編集するわけではないため、翻訳者と開発者の役割分担が明確になり、運用がスムーズになる点もメリットと感じています。

キャプチャ
コラボレーションするための画面。日本語と英語訳が表示される

導入後の感想

Weglotの導入を決定したことで、当初3ヶ月以上かかると想定していた多言語化対応が、1ヶ月程度で完了する見込みとなりました!
これにより、SaaS導入は期待通りにエンジニアの工数を大幅に削減でき、非常に助かりました。

課題点としては、初回アクセス時は一瞬だけ日本語が表示されてから英語に切り替わるという動作になっています。
この点は、当初から理解していて実用上は問題ないと判断していましたが、不自然さは感じてしまいます。

今後について

今回は、まずはいったん多言語サイトをサービス提供することが重要だと考えて、SaaSという選択肢を選びました。
ですが、一瞬日本語で表示されてしまう点や翻訳の制限などから、どこかでi18nライブラリを用いた本格的な多言語化基盤の構築も必要だと感じています。

その際には、今回見送った react-i18next によるi18n基盤の構築を再度検討し、翻訳ファイルの管理に特化したSaaS(Lokalise, Transifexなど)へ移行することも検討したいと考えています。

まずは、Weglotで翻訳の知見を溜めつつ、翻訳精度の向上や用語の統一を行うことでグローバルに通用するサービスの第一歩目としていきます。


似顔絵
書いた人:星野

ストレスが続くと肌が荒れる?:ウェアラブルデータで解き明かす皮脂と自律神経活動の関係

こんにちは。データサイエンスチームの望月です。

みなさんは、ストレスが続くと肌荒れや吹き出物が増えたり、寝不足のときに肌の調子が崩れたりした経験はありませんか?

「肌は心を映す鏡」と表現されるように、古くから肌と心(自律神経)の状態は密接に関わっていると考えられてきました。
ストレスや睡眠不足が肌にあらわれる──その背景には自律神経の乱れやホルモン分泌の変化が関わっています。

肌荒れのイメージイラスト

とりわけ肌の状態を左右する大きな要因のひとつが「皮脂」です。
皮脂が過剰に分泌されると肌荒れやニキビ、テカリ、化粧崩れの原因となり、逆に不足すると乾燥につながります。
皮脂分泌にはホルモン、自律神経、生活習慣といったさまざまな要因が関わると考えられており、皮膚科学領域でも注目されてきました。

しかし、ウェアラブルデバイスで測定した心拍変動(HRV)や睡眠といった客観的なデータを用いて、皮脂量との関連を統計的に解析した例はほとんどありません。
私たちは、この「肌と心の関係」をより科学的に捉えるため、ウェアラブルデータ(※)と皮脂測定を組み合わせて解析しました。その結果、小規模データながらも皮脂量と自律神経活動に確かな関連が見えてきました。不規則な生活習慣やストレス、ホルモンバランスの変動で皮脂量が増加する可能性が見えてきたのです。

今回は、その詳細をご紹介します。

※ウェアラブルデータ……スマートウォッチを代表とするウェアラブル端末で測定された生体情報等のデータ

研究概要

今回の解析には、女性5名・男性3名の合計8名にご協力いただき、14日間にわたりデータを収集しました。

皮脂量の測定

皮脂の量は、皮膚研究で用いられる皮脂チェッカーを使用し、毎朝の起床時に額と頬の2か所から採取しました。
皮脂チェッカーはシート状の測定器具で、肌に押し当てると皮脂が付着した部分が黒くなります。その写真を参加者にスマートフォンで撮影してもらい収集、画像を白黒に変換して数値化し、皮脂レベルとして解析に利用しています。

皮脂レベル解析の手順

生体データの取得

生体データ(心拍数や睡眠、活動量など)は Fitbit を用いて取得しました。日中の活動データとその夜の睡眠データを、翌朝の皮脂レベルに対応させて関係を調べます。

Fitbitの写真
Fitbitの例(Fitbit Inspire 3。Googleストアより引用)

解析方法

解析を行い、皮脂量と関係する生体データがあるかを調べました。
対象とした生体データは、心拍数、睡眠、そして活動量に関するものです。

まず、心拍数データから心拍変動指標(HRV) を算出し、睡眠中の平均心拍数や、自律神経の活動を評価する複数のHRV指標を解析に取り入れました。

さらに、睡眠指標として、総睡眠時間や各睡眠ステージ(覚醒、レム睡眠、浅い睡眠、深い睡眠)の時間を解析に加えました。

これらのデータと皮脂量との関連を統計的に調べるため、皮脂量を中央値で2群(低皮脂量群と高皮脂量群)に分け、各種パラメータを比較・評価しました。また、皮脂量には性差があることが知られているため、男女を分けてそれぞれ解析を行いました。

解析結果

皮脂量の性差・部位差

皮脂量には性差と部位差が見られました。
全体として、男性は女性より皮脂量が多く、また額は頬に比べて多い傾向が見られました。

グラフ

睡眠中の自律神経活動(HRV)と皮脂量

自律神経活動は「心拍変動(HRV)」から推定することができます。HRVとは、心拍のゆらぎの大きさを表す指標で、リラックス時など副交感神経が優位なときに大きく、緊張時など交感神経が優位なときに小さくなる特徴があります。

解析の結果、皮脂量が多いほど睡眠中に交感神経優位の状態にある可能性が示されました。
具体的には、皮脂の多い群では男女ともに、睡眠中のHRV指標であるRMSSDが低く、交感神経の活動を示すCSIが高く、副交感神経の活動を示すCVIが低い傾向が見られました。

睡眠中RMSSD

グラフ

睡眠中CSI

グラフ

睡眠中CVI

グラフ

睡眠指標と皮脂量

睡眠指標との関係を調べたところ、女性では皮脂の多い群で総睡眠時間とレム睡眠が有意に短く、深い睡眠も短い傾向が見られました。
一方、男性では皮脂の多い群で深い睡眠が有意に短いことが分かりました。
このことから、睡眠の質の低下も皮脂量の増加に関わっている可能性があると考えられます。

睡眠時間

グラフ

レム睡眠時間

グラフ

深い睡眠時間

グラフ

まとめ

こうした肌と心の関係はこれまで皮膚科学や精神皮膚医学の分野では研究されてきましたが、今回のようにウェアラブルデータで皮脂量と自律神経活動のつながりを検証した例はほとんどありません。

解析の結果から、皮脂量が多い群では睡眠中の副交感神経活動(RMSSD、CVI)が低く、交感神経活動(CSI)が高い傾向が見られました。さらに、女性では総睡眠時間・レム睡眠・深い睡眠が短く、男性でも深い睡眠が短いという特徴が明らかになりました。

一般に、交感神経が優位な状態では、腎臓の上にある副腎皮質からコルチゾール(代表的なストレスホルモン)やアドレナリンといったホルモンが分泌されます。その際、男性ホルモンの一種であるアンドロゲンも増加することが知られており、このアンドロゲンは皮脂分泌を促す作用を持っています。
今回の結果は、不規則な生活習慣(睡眠時間や睡眠の質)、ストレス、ホルモンバランスの変動によって交感神経が優位になり、その結果として皮脂量が増加するという可能性を支持しています。

小規模の解析ではありますが、ウェアラブルデバイスによる客観的なデータを用いて、皮脂量と自律神経活動との関連を統計的に示した初めての試みの一つといえるかもしれません。今後の研究により、肌と生活習慣、自律神経の関係がさらに解明されていくことが期待されます。

最後に

将来的には、自律神経や睡眠といったウェアラブルデータから皮脂量の増減を予測することで、スキンケアの方法を変えたり、生活習慣を見直すきっかけをユーザーに提供できる可能性があります。

「自分の肌の変化を先取りしてケアする」そんな未来が、データサイエンスによって実現に近づいています。


書いた人:望月

ウェアラブルデバイス(Fitbit) × Apache Hudiによるデータ収集基盤の設計案

こんにちは。テックドクターのエンジニア、伊藤です。

テックドクターでは、心拍や睡眠などの日常の生体データを取得するためにウェアラブルデバイスを活用しています。これらのデバイスで集めたデータを使って、健康管理やフィットネスの向上、さらには医療分野への応用を目指しています。

いま世の中に出回っているウェアラブルデバイスの例としては、 Apple Watch、Garmin、Fitbit などがあります。これらは(理想的には) 24 時間 365 日データを取得し続けることができます。そのデータは同期している iPhone のストレージや各社のサーバーに保存されますが、活用のためにデータを取得したいときはWeb API などを通じて取得することになります。

その際、日付などの条件を指定してデータを取得する以外に、(仕組みが提供されていれば)Webhook などを活用してリアルタイムに近い形でデータを取得することも可能です。

後者のようなリアルタイム/準リアルタイムのデータ取得は、ユーザーの行動変化に即座に対応したい場合や、継続的なモニタリングが必要な場合に特に有用です。ただし、それに対応したデータ収集基盤の構築も必要となってきます。

この記事では、 Fitbit に対してリアルタイムでデータ収集をする際の基盤設計案を紹介したいと思います。なお、今回は Fitbit を例としますが、似たような構成の API や Webhook 機能を提供しているサービスであれば、同様のアーキテクチャを適用可能だと思います。

Fitbitデータ収集における課題

まずは、今回のデータ取得の課題について整理しておきましょう。ウェアラブルデバイスという特性上発生する課題と、 Fitbit API による課題があります。

課題
  • データ更新の頻度: ユーザーの日次データ(歩数、睡眠時間等)は一日中継続的に更新される
  • 遅延データの到着: デバイス同期のタイミングにより、過去日時のデータが後から送信される場合がある
  • レート制限: Fitbit API には厳格な制限があり、効率的なデータ収集戦略が必要
  • データ一貫性: リアルタイム性とデータ整合性のバランス
  • スケーラビリティ: ユーザー数の増加に対応可能な設計
  • コスト最適化: 不要な API 呼び出しの削減

ここで注目してほしいのが最初の2つ「データ更新の頻度」「遅延データの到着」です。実はこれらの特性にぴったりの強みを持ったプラットフォームがあります。それが Apache Hudi です。

Apache Hudi とは?

Apache Hudi が何であるか、まずは公式ドキュメントから引用します。

What is Apache Hudi

Apache Hudi (pronounced "hoodie") pioneered the concept of "transactional data lakes", which is more popularly known today as the data lakehouse architecture. Today, Hudi has grown into an open data lakehouse platform, with a open table format purpose-built for high performance writes on incremental data pipelines and fast query performance due to comprehensive table optimizations.

引用元:Apache Hudi 公式ドキュメント

全訳すると長くなるのでかいつまんで説明すると、Hudi(フーディ)はデータレイクハウスアーキテクチャを実現するためのオープンソースプロジェクトです。インクリメンタルなデータパイプラインに適していると説明されています。

また、AWSのドキュメントでは、センサーやIoTデバイスからのストリーミングデータに対して、特定のデータ挿入と更新イベントが必要な場合にHudiが適していると述べられています。

Working with streaming data from sensors and other Internet of Things (IoT) devices that require specific data insertion and update events.

引用元:AWS EMR Hudi

Hudiのこれらの強みは、まさに今回の用途、ウェアラブルデータを「準リアルタイム」に取得し「同一レコードの頻繁な更新」をする際にとても有効です。

Hudiは他にもタイムトラベル機能やスキーマ進化のサポートなど、データレイクハウスの運用に必要な機能もいろいろと備えています。

というわけで、今回はマイクロサービスアーキテクチャと Apache Hudi を組み合わせ、リアルタイム通知をマイクロバッチ処理へ変換する仕組みを考えてみることにしました。

※ただし、本記事は実装前の設計案であり、実際の運用環境での検証は今後の課題です。

Fitbit Subscription APIについて

Subscription APIとは

アーキテクチャ全体の説明に入る前に、Fitbit 側のAPIについても説明しておきます。

Fitbit Subscription APIは、ユーザがFitbitアプリを通じてFitbitサーバーにデータを同期したときに、事前に登録したサーバーにWebhookを通じて通知を送信するAPIです。これを利用することで、データの変更を即座に検知し、必要なデータ取得をトリガーできます。

参考:Fitbit Web API Documentation Using Subscriptions

通知データの構造

Subscription APIからの通知は以下のJSONフォーマットで受け取ります。

[
    {
        "collectionType": "foods",
        "date": "2010-03-01",
        "ownerId": "USER_1",
        "ownerType": "user",
        "subscriptionId": "1234"
    }
]


主要フィールドの説明

フィールド 説明
collectionType データの種類 `foods`, `activities`, `sleep`, `heartrate`
date データの日付 `2010-03-01`
ownerId ユーザー識別子 `USER_1`
ownerType オーナータイプ `user` (固定値)
subscriptionId サブスクリプション識別子 `1234`

注目して欲しい点は、通知自体には詳細なデータは含まれず、あくまで「どのユーザーのどのデータタイプが更新されたか」の情報のみであることです。実際のデータ取得にはユーザー・collectionType・日付に基づいて、別途 API 呼び出しが必要です。

Subscription API利用時の制約事項

前述したように、API 呼び出しには制限があります。

レート制限:
150 requests/hour/user

レスポンスタイムアウト:

  • Fitbitは通知送信時に5秒のタイムアウトを設定
  • エンドポイントは迅速な応答(204 No Content)が必要
  • これらが守られない場合、最終的にサブスクリプションを無効化する可能性がある

以上が Fitbit Subscription API の概要です。

アーキテクチャの全体像

全体のシステム構成図

いよいよシステムの全体像です。

大きく以下の2つの部分に分けられます。
①Fitbit Subscriptionからの通知を受け取り永続化する
②ミニバッチでAPI呼び出しを行い、データをApache Hudi形式で永続化する

クラウド基盤としては Google Cloud を使用します。

構成図

各コンポーネントについてまとめます。

①通知処理部分

インフラ (コンポーネント名) 役割 主な責務
Cloud Run Gateway Notification 受信専用 高速応答、Pub/Sub 転送
Cloud Pub/Sub メッセージング基盤 非同期処理
Cloud Run Ingestor 永続化サービス NotificationのGCS への書き込み、メタデータ付与
Cloud Storage (GCS) Notification 永続化 Notification の蓄積

②ミニバッチ部分

インフラ (コンポーネント名) 役割 主な責務
Cloud Scheduler 定期実行 バッチ処理トリガー
Cloud Run Batch バッチ処理サービス GCS からのデータ取得、API 呼び出し
Dataproc + Apache Hudi データレイクハウス 変換、保存
Cloud Storage ウェアラブルデータ永続化 ウェアラブルデータの蓄積

このような構成です。
次の項でそれぞれもう少し詳しくご説明していきます。

アーキテクチャ設計の詳細

Notification 受信層(Cloud Run + Pub/Sub)

構成図

Fitbit Subscription API の制約として、5 秒以内に 204 No Content を返すサービスを実装する必要があります。
そのためここでは通知の受信と Pub/Sub への Publish のみに特化した軽量サービスを Cloud Run で構築します。

処理の流れは以下の通りです。

  1. Fitbit からの Webhook 通知を HTTP POST で受信
  2. 受信した JSON データを最小限の検証のみ実施
  3. タイムスタンプを付与して Pub/Sub メッセージとして即座に Publish
  4. Fitbit に対して 204 No Content レスポンスを返却(5 秒以内)

Notificationデータ 永続化層(Cloud Run + GCS)

構成図

この層では Pub/Sub から受信したメッセージを構造化して GCS に保存します。バッチ処理に適した形でデータを整理し、後続のバッチジョブが効率的に処理できるようにします。

処理の流れは以下の通りです。

  1. Pub/Sub からメッセージを受信
  2. メッセージのパースと検証
  3. バッチ ID と受信タイムスタンプを付与
  4. GCS に日付・時間ベースでパーティショニングして保存


GCS 保存例:

/notifications/
├── year=2025/
│   ├── month=08/
│   │   ├── day=01/
│   │   │   ├── hour=00/
│   │   │   │   └── 01K1GFYJHR7EKM5HN9PPD2AG9V.json
│   │   │   │   ├── 01K1GFYKHKJ3KHKVY0RDGNE2GZ.json
│   │   │   └── hour=01/
│   │   └── day=02/
│   └── month=09/


保存データ形式:

{
  "original_notifications": [...],
  "metadata": {
    "received_at": "2025-08-01T14:00:00Z",
    "batch_id": "01K1GFYJHR7EKM5HN9PPD2AG9V"
  }
}

バッチ処理層(Cloud Run + Dataproc + Apache Hudi)

構成図

この層では蓄積された通知情報をもとに、ミニバッチで API 呼び出しを行います。その結果を Apache Hudi 形式でデータレイクハウスとして保存します。

処理の流れは以下の通りです。

  1. 未処理通知の発見: GCS から新しい通知ファイルを検出
  2. データのグルーピング: ユーザー・日付・データタイプで最適化されたバッチを作成
  3. Fitbit API の呼び出し: レート制限を考慮した効率的なリクエスト実行
  4. Apache Hudi での書き込み: Upsert 操作によるデータレイクハウスへの永続化

データ管理のポイントとしては簡単に以下のようなものがあります。

  • パーティショニング戦略: 日付ベースでのデータ配置最適化
  • 主キー制約: ユーザーID, データタイプ, 日付の組み合わせによる一意性保証
  • 更新時刻管理: 最新データを保持

アーキテクチャ全体の説明は以上です。

考慮事項と今後の課題

ここまでで全体的な設計案を示しましたが、実際の運用に向けてはより詳細な検討が必要になる点もあります。

例えばバッチ処理層では、単なるAPI呼び出しを行うような書き方をしましたが、実際には蓄積された通知をグルーピングして API 呼び出しを行う際にレート制限を超えないようにするための工夫が必要です。

また、繰り返しになりますがFitbit は「データが更新された」という通知のみを送信し、実際のデータは別途 API で取得する仕組みです。しかし、他のウェアラブルデバイスでは、通知と同時に実際の測定データ(心拍数、歩数など)をストリームとして直接送信する場合があります。その場合、このアーキテクチャに沿って単にそのデータを保存するだけだと、障害などでデータを取り逃がした際にデータ損失を起こす可能性があります。別途API での再取得機能を実装したり、データの完全性を担保する仕組みが必要になるでしょう。

以上、参考になれば幸いです。

第22回日本うつ病学会にて、抑うつ気分の予測に関するポスター発表を行いました

こんにちは、データサイエンス部の深見です。

テックドクターのデータサイエンスチームでは、定期的に学会への参加や登壇を行なっています。今回は7/11-12に浜松町で開催された第22回日本うつ病学会総会にてポスター発表を行なってきましたので、その内容を紹介いたします。

私自身はちょうど一年前の日本睡眠学会での登壇以来、久しぶりの学会参加でした。またポスター発表となると本当に久しぶりで、最後は学生時代だったかもしれません。

私以外にもテックドクター社としては、これまでにも日本リウマチ学会や国際医薬経済・アウトカム研究会(ISPOR)など国内外の様々な学会に参加して研究の成果を報告しています。

www.technology-doctor.com

今回の発表内容は、いわゆるケーススタディという形式で、一人の被験者の長期に渡るウェアラブルバイスのデータを解析したものです。

ウェアラブルバイスを使うと、これまでは病院に来院したタイミングでしか分からなかった被験者の状態が、連続的にかつ細かな粒度で把握できます。このことが医療の質を上げるのに役立つと期待されています。

特にスマートフォンウェアラブルバイスから得られる生体データをもとにした指標「デジタルバイオマーカー」は、疾患の有無や病状の変化を客観的に評価することができる可能性を秘めており、その開発は国内でも注目が高まっています。

※デジタルバイオマーカーの詳細については弊社の下記サイトを参照してください。
デジタルバイオマーカー | TechDoctor

研究概要

演題:
双極性障害患者におけるウェアラブルバイスを用いたデジタルバイオマーカーの探索」

被験者:
2010年に診断を受けた双極性障害患者1名(40代・男性)

目的:
双極性障害の症状(特に抑うつ気分)に関連するデジタルバイオマーカーの発見。

対象データ

収集したデータは、ウェアラブルバイスFitbit Charge 6を使った生体データ(測定値)と、eMoodsというアプリを使った主観データ(自己申告の症状記録)です。それぞれどんな項目が対象かは、下記の表を見てください。

収集データの一覧表

期間としては2024年2月から11月まで9ヶ月間のデータを解析対象としました。こちらは現在も収集が続いており、今後も継続して解析を実施する予定です。

解析方法

主観データの中でも、特に「抑うつ気分」に着目、この指標と連動する生体データがないか調べました。

具体的には、

心拍数データをもとに算出した心拍変動指標
例)
・睡眠中の平均心拍数
・自律神経の活動を評価する複数の心拍変動

睡眠指標
例)
・ 睡眠時間
・各睡眠ステージ(覚醒/レム睡眠/浅い睡眠/深い睡眠)の時間や比率

などを使用しました。

これらの指標を1日ごとに算出、大きく変動した値(全期間の平均と標準偏差から、平均値±標準偏差以上、離れているもの)を異常値として抽出しました。

解析結果

副交感神経の活動を評価するRMSSD(Root Mean Square of Successive Differences)という心拍変動指標があります。一般的に、RMSSDの数値が大きくなると副交感神経が優位になる=リラックスした状態、逆に値が小さくなるとリラックスできていない状態と考えることができます。

今回の研究により、睡眠中のRMSSDの値に抑うつ気分との関連が見られることがわかりました。
実際のデータを見てみましょう。

睡眠中のRMSSDの値と抑うつ気分との関連グラフ

特に期間の後半、抑うつ気分の高まり(図中、青棒グラフが2点以上)とともにRMSSDのトレンド成分(図中、赤線グラフ)が下降していました。

また期間中にRMSSDが平均より大きく低下(1.5SD以上)した日が14日ありましたが、そのうち12日にその後1週間以内に抑うつ気分スコアの上昇が確認されました。

これらの結果は、睡眠中の副交感神経活動の低下を見ることでその後の抑うつ気分を予測できる可能性を示しています。もっと研究を進めることで、抑うつ気分を推定するバイオマーカーとして有用と判明するかもしれません。

今回の発表では一人の被験者に対して抑うつ気分との関連を調査しましたが、抑うつ気分以外の主観指標と生体データの関連の解析や、大人数を対象にした検証も実施しています。今後さらに内容を充実させた上で論文を投稿する予定です。

発表を通しての印象

ポスター発表では演題が60以上あり、演題番号が奇数のものが初日に、偶数のものが2日目にそれぞれ30分発表を行う形式でした。

私の発表は2日目の土曜日の14時からと比較的足を運びやすい時間帯だったこともあってか、時間中ひっきりなしに質問があり、5名程度の方に説明をすることができました。それ以外にも足を止めてくださる方、遠目に見ていただける方が何名かいらっしゃいましたのでそれなりに興味を持っていただけたのではないかと思います。
※会場内撮影禁止でしたので残念ながら写真はありません…

またポスター発表の他にも、父親の産後うつに関するセッションや、精神疾患の治療において薬を使った場合と精神療法を用いた場合の比較に関するセッションに参加し、最新の研究に触れることができました。こういった知見を日々の研究に活かしていきたいと思います。

まとめ

いかがでしたでしょうか。テックドクターは、研究成果を今回のような学会発表や論文投稿という形で発表することで、医学の発展への貢献を目指しています。また対外的な発表以外にも特許という形で成果を残すこともあります。今後も学会発表や論文投稿した際には随時発信していきたいと思います。

似顔絵
書いた人:深見

クライアントとサーバーで、APIの型・バリデーションルールを一元化する

こんにちは。株式会社 TechDoctor でソフトウェアエンジニアをしている大瀧です。

突然ですが、アプリケーション開発で、クライアントとサーバーの API の型やバリデーションルールが食い違い、予期せぬバグに繋がった経験はないでしょうか。

このような課題の解決策として、本記事では、OpenAPI スキーマをもとに、API の型とバリデーションルールを一元管理する方法を紹介します。

ここで紹介するアプローチは、 OpenAPI スキーマを作成できる限り特定の言語やフレームワークに依存しませんが、今回は具体例として FastAPI、Pydantic、Zod、openapi-zod-client を使った方法を解説します。

なお本記事のサンプルコードは、以下のリポジトリで確認できます。(Kiro と Claude Code が作ってくれました)
GitHub - shutootaki/sync-schema-demo

1. 背景と課題

課題 ①:API の型の二重管理

API のインターフェースの型が、バックエンドとフロントエンドでそれぞれ定義されていると、二重管理になってしまいます。

この状態では、仕様変更のたびに両方のコードを手動で同期させる必要が生まれます。

その際に片方で修正漏れが起きると、データの不整合や実行時エラーに直結します。結果として、ランタイムエラーが発生したり、「このフィールドは null 許容でしたっけ?」といった本来不要なコミュニケーションが頻発してしまいます。

課題 ②:バリデーションロジックの分散

優れた UX のためにはクライアント側での事前バリデーションが必要です。そしてデータの整合性とセキュリティのためにはサーバー側でのバリデーションも不可欠です。

しかし、これらのバリデーションルールをそれぞれ別のリポジトリで管理していると、いつの間にか仕様が乖離しがちです。

結果として、課題 ① と同様にエラーや不要なコミュニケーションコストが発生してしまいます。

2. 解決アプローチ

この課題を解決するために、弊社では以下のアプローチを採用しました。

バックエンドを信頼できる唯一の情報源(SSoT)にする

バックエンド(FastAPI + Pydantic)を、型とバリデーションルールにおける信頼できる唯一の情報源と位置づけます。

そのために、コーディング時はPydantic でリクエスト/レスポンスの型とバリデーションルールを定義します。
あとは自動的に同期が行われる仕組みにします。

コード生成による型の自動同期

同期のしくみはこうです。

概念図

Pydanticでのスキーマ定義をもとに、FastAPI が OpenAPI スキーマを自動生成します。
フロントエンド側では、openapi-zod-client を使って、OpenAPI ドキュメントをもとにZod スキーマを自動生成します。

この仕組みにより、バックエンドでスキーマを変更してもコマンド 1 つでフロントエンドの型を同期できるため、APIの型とフロントエンド側の実装との不整合を簡単に検知でき、バリデーションロジックの二重実装も不要になります。

3. 具体的な実装方法

基本的なユーザー作成 API を例に、具体的な実装方法を解説します。

① Pydantic モデルの定義と FastAPI によるドキュメント生成

バックエンドでは、Pydantic を使いリクエストとレスポンスのスキーマを定義します。

基本的なスキーマ定義
from pydantic import BaseModel, Field, EmailStr
from typing import Optional
from enum import Enum

class UserRole(str, Enum):
    USER = "user"
    ADMIN = "admin"

class UserCreateRequest(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    email: EmailStr
    age: int = Field(..., ge=18, le=120)
    role: UserRole = UserRole.USER

class UserResponse(BaseModel):
    id: int
    name: str
    email: str
    age: int
    role: UserRole
FastAPI ルーターでのスキーマの使用

定義した Pydantic スキーマを、FastAPI のエンドポイントで利用します。

from fastapi import APIRouter, HTTPException
from app.models.user import UserCreateRequest, UserResponse
import random

router = APIRouter(tags=["Users"])

users_db: dict = {}

@router.post("/users", response_model=UserResponse)
async def create_user(user_data: UserCreateRequest) -> UserResponse:
    new_user = {
        "id": generate_user_id(),
        "name": user_data.name,
        "email": user_data.email,
        "age": user_data.age,
        "role": user_data.role,
    }

    users_db[new_user["id"]] = new_user
    return UserResponse.model_validate(new_user)
FastAPI アプリケーションの設定

main.py で、OpenAPI ドキュメント生成の設定を行います。

from fastapi import FastAPI
from app.routers import users

app = FastAPI(
    title="User API",
    version="1.0.0",
    description="シンプルなユーザー管理API"
)

app.include_router(users.router, prefix="/api/v1")
生成される OpenAPI スキーマ

上記の Pydantic モデルと FastAPI の設定から、次のような OpenAPI スキーマが自動生成されます。(一部抜粋)

{
  "openapi": "3.1.0",
  "info": {
    "title": "User API",
    "version": "1.0.0"
  },
  "components": {
    "schemas": {
      "UserCreateRequest": {
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 100,
            "minLength": 1,
            "title": "Name"
          },
          "email": {
            "type": "string",
            "format": "email",
            "title": "Email"
          },
          "age": {
            "type": "integer",
            "maximum": 120,
            "minimum": 18,
            "title": "Age"
          },
          "role": {
            "$ref": "#/components/schemas/UserRole",
            "default": "user"
          }
        },
        "title": "UserCreateRequest",
        "description": "ユーザー作成リクエストのスキーマ"
      },
      "UserRole": {
        "type": "string",
        "enum": ["user", "admin"],
        "title": "UserRole"
      }
    }
  }
}

画面キャプチャ

このスキーマには、minLength=1ge=18 といったバリデーションルール、UserRoleEnum 定義、必須フィールドの指定、整数・文字列・メール形式といった詳細な型情報が含まれています。

openapi-zod-clientによるスキーマファイルの生成

フロントエンド側では、FastAPI が生成した OpenAPI スキーマをもとに、openapi-zod-clientを使って Zod スキーマを自動生成します。

openapi-zod-clientのインストールと設定

まず、フロントエンドプロジェクトに必要なパッケージをインストールします。

pnpm install -D openapi-zod-client

次に、プロジェクトのルートに設定ファイルを作成します。

// schema-format.hbs

// This file is automatically generated. Do not change it manually.
import { z } from "zod";

{{#each schemas}}
export const {{@key}} = {{{this}}};
export type {{@key}} = z.infer<typeof {{@key}}>;
{{/each}}

export const schemas = {
{{#each schemas}}
	{{@key}},
{{/each}}
};

上記はシンプルな設定例です。詳細は公式ドキュメントを参照してください。
GitHub - astahmer/openapi-zod-client: Generate a zodios (typescript http client with zod validation) from an OpenAPI spec (json/yaml)


package.jsonへのスクリプト追加

開発ワークフローを効率化するため、package.jsonスクリプトを追加します。

{
  "scripts": {
    "codegen": "openapi-zod-client -o ./src/lib/api/schema/api_schemas.ts -t ./schema-format.hbs ${OPEN_API_JSON:-<http://localhost:8000/openapi.json>}"
  }
}

バックエンドサーバーが起動している状態で以下のコマンドを実行すると、Zod スキーマが自動で生成されます。

pnpm run codegen

実際には以下のような Zod スキーマが自動で生成されます。(一部抜粋)
OpenAPI スキーマをもとに UserCreateRequest の型とバリデーションルールが、Zod のスキーマに変換されています。

// src/lib/api/schema/api_schemas.ts
// This file is automatically generated. Do not change it manually.
import { z } from "zod";

export const UserRole = z.enum(["user", "admin"]);
export type UserRole = z.infer<typeof UserRole>;
export const UserCreateRequest = z
  .object({
    name: z.string().min(1).max(100),
    email: z.string().email(),
    age: z.number().int().gte(18).lte(120),
    role: UserRole.optional(),
  })
  .passthrough();
export type UserCreateRequest = z.infer<typeof UserCreateRequest>;
React Hook Form との連携

生成された Zod スキーマは、React Hook Form のようなフォームの状態管理をするライブラリと簡単に連携できます。

以下のように、src/lib/api/schema/api_schemas.ts に出力された Zod スキーマを import して、useForm と zodResolver にセットするだけで、先程 Pydantic で定義した型とバリデーションルールを再利用することができます。

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { UserCreateRequest } from "@/lib/api/schema/api_schemas";

export const UserForm = ({ onSubmit, isLoading }) => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<UserCreateRequest>({
    resolver: zodResolver(UserCreateRequest),
    defaultValues: { role: "user" },
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name")} placeholder="名前" />
      {errors.name && <p>{errors.name.message}</p>}

      <input {...register("email")} type="email" placeholder="メール" />
      {errors.email && <p>{errors.email.message}</p>}

      <input {...register("age", { valueAsNumber: true })} type="number" />
      {errors.age && <p>{errors.age.message}</p>}

      <button type="submit" disabled={isLoading}>
        {isLoading ? "作成中..." : "ユーザーを作成"}
      </button>
    </form>
  );
};

実際にサンプルコードのフロントエンドサーバーを起動して、フォームに不正な値を設定すると、以下のように期待通りバリデーションを実行してくれていることがわかります。

画面キャプチャ

4. まとめ

本記事では、FastAPI、Pydantic、Zod、openapi-zod-client を組み合わせ、API の型とバリデーションルールを一元管理する方法を紹介しました。弊社では、この取り組みによって以下のメリットを得ることができました。

  • 型の不一致によるバグを予防
    • バックエンドで定義した OpenAPI スキーマからフロントエンドの型を生成するため、型の不一致によるバグを大幅に減少させることができました。
  • バリデーションロジックを再利用
    • Zod へ変換されたスキーマを React Hook Form などに直接適用できるため、同一のルールを重複して実装する必要がなくなりました。
  • 変更への追従が容易
    • バックエンドのスキーマモデルを修正した後に、コード生成コマンドを実行するだけで API の型定義・バリデーションロジックが更新されるので、開発者の負担を減らすことができました。

API の型・バリデーションロジックの管理に課題感を持っている方がいましたら、ぜひ導入を検討してみてください!



書いた人:大瀧