Clean Architectureを用いたAPIサーバーの設計

Heruoji
Goalist Blog
Published in
20 min readDec 12, 2023

先日、Clean Architectureという本を読みました。

https://amzn.asia/d/byIs1qu

いろんな設計原則がまとまっていて非常に勉強になったので、読んだことがない方はぜひ読んで欲しいです。以下ブログもあります。

Clean Architectureの説明に関しては上記の本やブログにお任せして、今回は実際にClean Architectureの思想を用いてAPIサーバーを実装してみたいと思います。

まず前提として、変更や拡張に対して強いシステムを作るためには、異なる理由や頻度で変更されるものごとにシステムを分離して、それぞれの依存性をコントロールする必要があります。ここで変更されにくいものが変更されやすいものに依存してはいけません。

Clean Architectureは、システムをビジネスロジックやインターフェース(ユーザインターフェースやシステムインターフェースのこと)などのレイヤーに分離し、それぞれの依存関係を正しくコントロールすることで、システムの保守性、柔軟性の向上、テストの簡素化などを実現するアーキテクチャです。システムのユースケースをアーキテクチャの中心とし、フレームワークやウェブ、データベースなど周辺の環境は詳細なものとしてユースケースから切り離します。

今回はなるべくシンプルな形でClean Architectureの思想を取り入れることを目標にAPIサーバーを実装してみました。

コード例

以下のリポジトリが今回作成したデモソースコードになります。Twitter(現X)のようなミニ記事投稿システムを想定しています。Userはアプリケーションの利用者、PostはTwitterのツイート(Xでもツイート?)に当たります。

レイヤー構成

今回、core、http、dbの三つのレイヤーを用意しました。

coreレイヤーはビジネスルールのレイヤーになっており、アーキテクチャの中心部分となります。httpはウェブのレイヤー、dbはデータベース接続のためのレイヤーになります。まずは、それぞれのレイヤーの概要を説明して、最後に全体の依存関係を見たいと思います。

coreレイヤー

Post関連のクラスを例に説明します。coreレイヤーはビジネスルールのレイヤーであり、クリーンアーキテクチャの中心になります。このレイヤーの主な登場クラスとしてエンティティとユースケースが挙げられます。

package com.example.minipost.core.post;

import com.example.minipost.core.user.User;

import java.time.LocalDateTime;

public class Post {
private Long id;
private User author;
private String content;
private Integer likes;
private LocalDateTime createdAt;

public static Post initializePost(User author, String content) {
Post post = new Post();
post.author = author;
post.content = content;
post.createdAt = LocalDateTime.now();
post.likes = 0;
return post;
}

public void addLike() {
if (likes == null)
likes = 0;
likes++;
}

public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}

public Long getId() {
return id;
}

public User getAuthor() {
return author;
}

public String getContent() {
return content;
}

public LocalDateTime getCreatedAt() {
return createdAt;
}

public Integer getLikes() {
return likes;
}

public void setId(Long id) {
this.id = id;
}

public void setAuthor(User author) {
this.author = author;
}

public void setContent(String content) {
this.content = content;
}

public void setLikes(Integer likes) {
this.likes = likes;
}
}

こちらはエンティティクラスです。ここで言うエンティティは、Springにおけるデータベースのテーブルを表すクラスのエンティティとは別物で、最重要ビジネスルールをカプセル化したJavaのクラスです。エンティティはもっとも中心にあるコアクラスのため、他のレイヤーやクラスで変化が起きた場合でも、変更が最も生じにくいクラスです。根本的なビジネス要件が変わった場合は、こちらも修正されます。

package com.example.minipost.core.post;

import com.example.minipost.core.exception.InvalidRequestException;
import com.example.minipost.core.user.User;
import com.example.minipost.core.user.UserRepository;

import java.util.List;

public class PostUseCase {

private PostRepository postRepository;
private UserRepository userRepository;

public PostUseCase(PostRepository postRepository, UserRepository userRepository) {
this.postRepository = postRepository;
this.userRepository = userRepository;
}

public PublishPostResult publishPost(PublishPostRequest request) {
User author = userRepository.findById(request.authorId);
if (author == null) {
throw new InvalidRequestException("User with ID " + request.authorId + " not found");
}
Post post = Post.initializePost(author, request.content);
Post savedPost = postRepository.save(post);
return PostMapper.toPublishPostResponse(savedPost);
}

public PaginatedPostResult getPaginatedPosts(int limit, int offset) {
if (!isLimitValid(limit)) {
throw new InvalidRequestException("Invalid limit");
}
if (!isOffsetValid(offset)) {
throw new InvalidRequestException("Invalid offset");
}
List<Post> posts = postRepository.getPostsBy(limit, offset);
return PostMapper.toPaginatedPostResult(posts);
}

private boolean isLimitValid(int limit) {
return limit >= 1 && limit <= 100;
}

private boolean isOffsetValid(int offset) {
return offset >= 0;
}

public void likePost(Long id) {
Post post = postRepository.findById(id);
if (post == null) {
throw new InvalidRequestException("Post with ID " + id + " not found");
}
post.addLike();
postRepository.save(post);
}
}

次に、ユースケースです。ユースケースは、システムのユースケースをカプセル化したものになります。例えば今回だと、システムのユースケースは以下になります。

・Postを投稿する
・Post一覧を取得する
・Postをいいねする

上記のユースケースの目標を達成できるように、エンティティへの入出力を管理したりエンティティに指示を出したりするクラスになります。

このように、coreレイヤーは、エンティティとユースケースを用いてシステムのビジネスルールを表すレイヤーになります。coreレイヤーはhttp、dbレイヤーには依存していません。すなわち、coreレイヤーにはウェブフレームワークやデータベースに関するコードは登場しません。そのため、このレイヤーの開発やテストのためにウェブフレームワークを起動する必要もデータベースに接続する必要もなく、独立して動作させることができます。

httpレイヤー

httpレイヤーは、ウェブアプリケーションとしてシステム全体を動かし、ユーザからのHTTPリクエストの処理を行うレイヤーです。

package com.example.minipost.http.api.post;

import com.example.minipost.core.post.PaginatedPostResult;
import com.example.minipost.core.post.PublishPostResult;
import com.example.minipost.http.config.CustomUserDetails;
import com.example.minipost.http.api.post.dto.CreatePostRequest;
import com.example.minipost.http.api.post.dto.CreatePostResponse;
import com.example.minipost.core.post.PostUseCase;
import com.example.minipost.core.post.PublishPostRequest;
import com.example.minipost.http.api.post.dto.GetPostsResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

@RestController
public class PostController {
private final PostUseCase postUseCase;

public PostController(PostUseCase postUseCase) {
this.postUseCase = postUseCase;
}

@PostMapping("/posts")
public ResponseEntity<CreatePostResponse> createPost(@RequestBody CreatePostRequest request) {
Long userId = getUserId();
if (userId == null) {
return new ResponseEntity<>(HttpStatus.UNAUTHORIZED);
}
PublishPostRequest useCaseRequest = PostMapper.toPublishPostRequest(request, userId);
PublishPostResult useCaseResponse = postUseCase.publishPost(useCaseRequest);
CreatePostResponse response = PostMapper.toCreatePostResponse(useCaseResponse);
return new ResponseEntity<>(response, HttpStatus.CREATED);
}

@GetMapping("/posts")
public ResponseEntity<GetPostsResponse> getPosts(
@RequestParam(defaultValue = "10") int limit,
@RequestParam(defaultValue = "0") int offset
) {
PaginatedPostResult userCaseResponse = postUseCase.getPaginatedPosts(limit, offset);
GetPostsResponse response = PostMapper.toGetPostsResponse(userCaseResponse);
return new ResponseEntity<>(response, HttpStatus.OK);
}

@PostMapping("/posts/{id}/like")
public ResponseEntity<Void> likePost(@PathVariable Long id) {
postUseCase.likePost(id);
return new ResponseEntity<>(HttpStatus.OK);
}


private Long getUserId() {
return getUserDetails().getId();
}

private CustomUserDetails getUserDetails() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return (CustomUserDetails) auth.getPrincipal();
}
}

上記controllerクラスがhttpレイヤーの主なクラスになります。httpレイヤーでは、システムユーザからHTTPリクエストを受け取り、それをcoreレイヤーのユースケースにとって適切な形のインプットに変換し、ユースケースを呼び出して処理を行います。その後、ユースケースからアウトプットを受け取り、それをHTTPレスポンスに変換してシステムユーザに返しています。このように、ウェブはあくまでシステムユーザーのリクエストをユースケースに渡すだけのインターフェースとして扱われます。今回はなるべくシンプルな構成にしたかったので、Spring bootの設定やDI処理、Spring Securityを用いた認証・認可処理もこちらのレイヤーにまとめて実装しています。

dbレイヤー

dbレイヤーは、実際のデータベースの接続処理を担当するレイヤーになります。

package com.example.minipost.db.mysql.post;

import com.example.minipost.core.post.Post;
import com.example.minipost.core.post.PostRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;

import java.util.List;

public class PostRepositoryImpl implements PostRepository {
private final JpaPostRepository jpaPostRepository;

public PostRepositoryImpl(JpaPostRepository jpaPostRepository) {
this.jpaPostRepository = jpaPostRepository;
}

@Override
public Post save(Post post) {
PostRecord postRecord = PostMapper.toRecord(post);
postRecord = jpaPostRepository.save(postRecord);
return PostMapper.toEntity(postRecord);
}

@Override
public List<Post> getPostsBy(int limit, int offset) {
Pageable pageable = PageRequest.of(offset / limit, limit);
Page<PostRecord> postRecords = jpaPostRepository.findAll(pageable);
return postRecords.getContent().stream().map(PostMapper::toEntity).toList();
}

@Override
public Post findById(Long id) {
PostRecord postRecord = jpaPostRepository.findById(id).orElse(null);
if (postRecord == null) {
return null;
}
return PostMapper.toEntity(postRecord);
}
}

coreレイヤーから送られてくるエンティティをデータベースのテーブルクラスに変換し、実際にデータベースとやりとりします。coreレイヤー側にリポジトリのインターフェースを用意し、dbレイヤー側でその実装クラスを用意することで、処理の流れ(ビジネスユースケース→データベース接続)と依存関係の流れ(coreレイヤー←dbレイヤー)を逆転させています。このように、オブジェクト指向のポリモーフィズムを用いることで、コンポーネント間の依存関係を自由に操ることができます。

以上がそれぞれのレイヤーの簡単な説明になります。それぞれのレイヤーの詳細は実際のコードを見てもらえればと思います。

それぞれのレイヤーの役割と全体の依存関係をまとめます。

coreレイヤー
純粋なシステムのビジネスルール、ユースケースを表すレイヤー。アーキテクチャの中心。

httpレイヤー
ウェブアプリケーションとしてシステムを動作させ、HTTPリクエストの処理を行うレイヤー。フレームワークもこのレイヤーに置いた。

dbレイヤー
実際のデータベース接続処理を担当するレイヤー。

三つのレイヤーの依存関係

httpレイヤーとdbレイヤーはcoreレイヤーに依存しており、coreレイヤーはどのレイヤーにも依存せず完全に独立して存在しています。ここでは、httpレイヤーとdbレイヤーはcoreレイヤーに対するプラグインのように扱われています。すなわち、httpレイヤーや dbレイヤーを取り替えることで、中心のビジネスルールには全く影響を与えることなくウェブアプリケーションをコンソールアプリケーションに変更したり、データベースをmysqlからpostgresqlに変更することができます。周辺のウェブフレームワークやデータベースに関して一切知らない状態で、システムの中心ロジックを開発、テストすることができます。

以下に今回のアーキテクチャのメリットをまとめます。

・システムのユースケースそのものが中心レイヤーにまとまっているので、何を行うシステムか簡単に理解できる
・システムのユースケース単体で独立して開発、テストが可能
・ウェブやフレームワーク、データベースなどの面倒な詳細の周辺環境は後回しにでき、ビジネスロジックに影響を与えることなく後から変更可能

また、デメリットとしては、一般的なコントローラー、サービス、リポジトリの3層構造のレイヤードアーキテクチャと比較してレイヤー間のデータ構造の変換と受け渡しが発生するため、クラス数が増加しコードが冗長になってしまう点があります。慣れていないと煩わしく感じると思うので、もし実際のプロジェクトに取り入れる際は、コード自動生成を取り入れたり、チームに対しての説明したりすることは大切になりそうです。

まとめ

以上、Clean Architectureを読んで実際のAPIサーバーを実装してみました。システムをレイヤーに分割して依存性を適切にコントロールすることで、ビジネスユースケースそのものがアーキテクチャの中心となり、ウェブフレームワークやデータベースを外部の取り替え可能なパーツとしてプラグインのように扱うことができ、保守性の高いシステムを実現することができます。ぜひ次回はAPIサーバー以外のシステムでも試してみようと思います。

--

--