オニオンアーキテクチャの本質を理解したい
はじめに
設計を十分に検討せずに開発を進めると、後々になって痛い目を見ることがあります(自戒)。オレオレフレームワークで実装されたアプリケーションをしばらくしてから触ると、不透明なデータアクセスの経路、スコープを考慮しない変数宣言、機能の追加・削除が苦しいファイル構成…など、様々な課題が顕在化する可能性があります。このような事態を未然に防ぐために、適切なアーキテクチャの選定は重要な準備の一つです。
本記事では、オニオンアーキテクチャに基づいてGoでWeb APIを実装した経験を通して得た知見を、コード例とともに共有します。
注意
本記事では以下の点については取り扱いません。
- アーキテクチャの歴史的背景や理論的な詳細
- 他のアーキテクチャパターンとの比較検討
また、オニオンアーキテクチャにおけるインターフェースと、Goの言語機能としてのインターフェース(interface)[1]を区別するため、後者について文中で言及する際は"interface"と表記します。
ドメイン駆動設計との関連
実践の背景
概要
今回ご紹介するオニオンアーキテクチャはJeffrey Palermoが2008年に提唱した設計パターンであり[4]、依存性逆転の原則に基づいた実装パターンの一つとして知られています。この構成を用いるメリットとして、以下のようなポイントがよく挙げられます。
- 個々のモジュールの独立性を高める
- 依存関係を明確にする
- テスト容易性を高める
ただ、抽象的な概念の説明なだけに、字面だけを見てもその良さは実感として掴みにくいと思います。私自身、個人開発を中心に行っていた頃は「コードの構成は自分が理解できていれば十分では?」「そもそもテストってどう書けばいいんだ?」といった疑問を持っていました。
しかし、業務としてWebアプリケーションの運用保守に携わるようになると、バグ修正や機能追加の必要に迫られるようになり、堅牢な設計を学ぶ重要性を感じました。そこで今回、上記のメリットを実現できるオニオンアーキテクチャを採用してアプリケーション開発に取り組みました。
この経験をもとに、以前の私と同じような疑問を持たれている方に向けて、このアーキテクチャの利点を具体的に嚙み砕いて共有することを目的としています。
実装したWebアプリ
今回実装したアプリケーションは、大阪公立大学で運用している利用開始手続きシステムのバックエンドです。大阪公立大学の学生および職員の方々を対象としています。
利用開始手続きのトップページ
このアプリは、入学手続きが行われる春にアクセスが集中します。もし動作に不具合があると、数百人以上の手続きが滞ってしまいます。そのため、不具合の原因特定や解消はできる限り早く実施できることが望ましいです。
PHPで書かれた従来のアプリでいくつか挙がっていた問題点を解消するにあたって、今後の保守を見据えてリファクタリングを行いました。記事の後半(実際に扱ったコードの一例)で比較を紹介します。
基本構造
まず、アプリケーションの基本構造を見てみます。
example-backend/
├── internal/
│ ├── application/ # アプリケーション層:ビジネスロジックの実装
│ │ ├── dto/
│ │ └── service/
│ ├── domain/ # ドメイン層:アプリケーションの中核
│ │ ├── model/
│ │ └── repository/
│ ├── infrastructure/ # インフラストラクチャ層:技術的な実装
│ │ ├── config/
│ │ ├── database/
│ │ ├── ldap/
│ │ └── persistence/
│ ├── interface/ # インターフェース層:外部との接点
│ │ ├── exception/
│ │ ├── handler/
│ │ ├── middleware/
│ │ └── router/
│ ├── registry/ # DIコンテナ
│ └── util/ # ユーティリティ関数
├── go.mod
├── go.sum
└── main.go # エントリポイント
以下のように、責務ごとにディレクトリを区切ります。
-
application
:アプリケーション固有のロジックを実装 -
domain
:ビジネスロジックの核となるモデルと操作を定義[5] -
infrastructure
:永続化や外部サービスとの連携を実装 -
interface
:HTTPリクエスト/レスポンスの処理などを担当 -
registry
:各層を初期化して依存性を注入
オニオンアーキテクチャでは、ドメイン層を中心に据え、外側の層から内側の層への一方向の依存のみを許可します。つまり、インターフェース層はアプリケーション層に依存でき、アプリケーション層はドメイン層に依存できますが、その逆は許可されません。これにより、アプリケーションの中核となるビジネスロジックを外部の実装詳細から守ることができます。
オニオンアーキテクチャを同心円構造で表した図
実践から得られた知見
1. interfaceを活用した関心の分離
オニオンアーキテクチャを実践を通じて感じた強みの一つが、interfaceを活用した抽象化です。以下に一つのコード例を提示します。
// interfaceを使わない実装
// user_service.go
type UserService struct {
db *gorm.DB // 具体的な実装に直接依存
}
func NewUserService(db *gorm.DB) *UserService {
return &UserService{db: db}
}
// これはデータアクセス
func (s *UserService) GetUser(id uint) (*User, error) {
var user User
result := s.db.Where("id = ?", id).First(&user)
return &user, result.Error
}
// これはビジネスロジック
func (s *UserService) UnlockUser(id uint) error {
user, err := s.GetUser(id)
if err != nil {
return ErrUserNotFound
}
// ...
return nil
}
// user_service_test.go
// テスト
func TestUserService_GetUser(t *testing.T) {
// DB呼び出しを毎回定義
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
service := NewUserService(db)
// テストのたびにDBの準備が必要...
}
上記のコードは、ユーザーの持つIDを入力し、それに該当するユーザーのロックを解除する処理を実装しています。このコードには、次のような問題が挙げられます。
- データアクセスとビジネスロジック(内部処理)が混在しており、責務が明確でない
- DBの実装詳細に直接依存しており、DBの変更があった場合などは変更箇所が多い
- 永続化層(DB、etc)の変更がサービス層の変更を強制する
これに対して、レイヤーを区切って抽象化を施した実装を見てみましょう。
// 1. ドメインサービス層
type UserRepository interface {
FindByID(id uint) (*model.User, error)
UpdateLockStatus(user *model.User) error
}
// 2. インフラ層によるドメインサービス層の永続化
type userRepository struct {
db *gorm.DB
}
func (r *userRepository) FindByID(id uint) (*model.User, error) {
var user model.User
result := r.db.First(&user, id)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, repository.ErrUserNotFound
}
return nil, result.Error
}
return &user, nil
}
func (r *userRepository) UpdateLockStatus(user *model.User) error {
result := r.db.Model(user).Updates(map[string]interface{}{
"is_locked" : user.IsLocked,
"login_attempts": user.LoginAttempts,
})
return result.Error
}
// 3. アプリケーションサービス層のinterface
type UserService interface {
GetUser(id uint) (*dto.UserResponse, error)
UnlockUser(id uint) error
}
// 4. アプリケーションサービスの実装
type userService struct {
repo repository.UserRepository
}
// アプリケーションサービスの初期化関数
func NewUserService(repo repository.UserRepository) UserService {
return &userService{repo: repo}
}
func (s *userService) GetUser(id uint) (*dto.UserResponse, error) {
// リポジトリを通してドメインモデルを取得
user, err := s.repo.FindByID(id)
if err != nil {
return nil, err
}
// ドメインモデルからDTOへの変換
return &dto.UserResponse{
ID: user.ID,
UserID: user.UserID,
IsLocked: user.IsLocked,
}, nil
}
func (s *userService) UnlockUser(id uint) error {
user, err := s.repo.FindByID(id)
if err != nil {
return err
}
// ビジネスロジック
user.LoginAttempts = 0
user.IsLocked = false
return s.repo.UpdateLockStatus(user)
}
// 5. インターフェースの実装
type UserHandler struct {
service service.UserService
}
// ハンドラの初期化関数
func NewUserHandler(service service.UserService) *UserHandler {
return &UserHandler{service: service}
}
// ...
User
に関するアプリケーションサービスのinterfaceをUserService
、内部実装をuserService
としています[6]。Goのinterfaceは、ある型がinterfaceのメソッドをすべて実装していれば、その型はinterfaceを満たすとみなすよう設計されています。この性質により、該当するメソッドをすべて実装したuserService
構造体はUserService
として振る舞うことができます。
この手順でinterfaceをかますことで、外部レイヤーからはinterfaceのみを参照させ、内部への直接参照を禁じます。このようにすることで、依存元(外部)は依存先(内部)の詳細は無視することができます。
メソッドの仕様を変更する場合でも、内部の変更のみの場合は外部に影響はありません。引数や戻り値を変更する場合など、外部に影響を与える場合は、接続点であるinterfaceに関連するコードのみをピンポイントで特定・変更することが可能となります。
2. テスト容易性の向上
先ほどの手順で抽象化を行うと、レイヤーごとの単体テストをより簡潔に記述可能となります。例えば、レイヤーBに依存するレイヤーAを考えます[7]。Aのテストを書く際には、Bが公開するinterfaceを満たす入出力をモック化するだけでよいことになります。なぜなら先述のように、AはBのinterfaceしか知らず、内部でどのように実装されているかを考えずに済んでいるからです。
// リポジトリのモック
type MockUserRepository struct {
mock.Mock
}
// メソッドをモック化
func (m *MockUserRepository) FindByID(id uint) (*model.User, error) {
args := m.Called(id)
return args.Get(0).(*model.User), args.Error(1)
}
// サービスのテスト
func TestUserService_GetUser(t *testing.T) {
mockRepo := new(MockUserRepository) // モックのリポジトリを作成
service := NewUserService(mockRepo) // モックを注入して初期化
// メソッドの引数と戻り値を指定
mockRepo.On("FindByID", 1).Return(&model.User{
ID: 1,
Name: "foo",
Email: "foo0001@example.com",
Password: "example-pass",
}, nil)
// GetUser内で呼ばれるFindByIDがモックした出力を返す
response, err := service.GetUser(1)
assert.NoError(t, err)
assert.Equal(t, "foo", response.Name)
}
// サービスのモック
type MockUserService struct {
mock.Mock
}
func (m *MockUserService) GetUser(id uint) (*dto.UserResponse, error) {
args := m.Called(id)
return args.Get(0).(*dto.UserResponse), args.Error(1)
}
// ハンドラのテスト
func TestUserHandler_GetUser(t *testing.T) {
mockService := new(MockUserService)
handler := NewUserHandler(mockService)
mockService.On("GetUser", 1).Return(&dto.UserResponse{
ID: 1,
Name: "foo",
Email: "foo0001@example.com",
}, nil)
response, err := handler.GetUser(1)
assert.NoError(t, err)
assert.Equal(t, "foo", response.Name)
}
上記のコード例では、userService
のテストは依存するdomain/repository
の入出力の型のみを参照し、ドメインサービスを永続化するinfrastructure/persistence
のロジックは一切考慮する必要がありません。UserHandler
の場合も同様に、依存するapplication/service
のinterfaceをもとに、モックさえ用意すればテストが可能です。
3. レイヤーごとにデータを分離
これはオニオンアーキテクチャ固有の特徴ではないですが、DTOパターンを活用しやすいのもよい点の一つです。
domain/model
ではアプリケーションの中核となるデータ構造を定義しますが、そのデータをそのままクライアントに返さないケースも出てきます。以下のUser
モデルを例に見てみましょう。
type User struct {
ID uint
Name string
Email string
Password string
CreatedAt time.Time
UpdatedAt time.Time
}
このデータに対して、以下のような操作を実装することを考えます。
- ユーザー一覧を取得するエンドポイント(GET)
-
Name
とPassword
で認証を行い、JWTを返すエンドポイント(POST)
1の場合はPassword
をレスポンスに含めるべきではありませんし、2の場合はUser
に含まれない別のデータ構造が要求されることになります。このように複数の用途(アプリケーションビジネスロジック)が発生したとき、ドメイン層に設けたデータ構造とは別に、アプリケーションサービス層でもデータ構造を定義します。ここではapplication/dto
として定義しました。
type UserSearchResponse struct {
ID uint `json:"id" example:"1"`
Name string `json:"name" example:"foo"`
Email string `json:"email" example:"foo0001@example.com"`
CreatedAt time.Time `json:"created_at" example:"2024-01-01T00:00:00Z"`
UpdatedAt time.Time `json:"updated_at" example:"2024-01-01T00:00:00Z"`
}
type UserVerifyRequest struct {
Name string `json:"name" example:"foo"`
Password string `json:"password" example:"example-pass"`
}
type UserVerifyResponse struct {
AccessToken string `json:"access_token" example:"eyJhbG..."`
TokenType string `json:"token_type" example:"Bearer"`
ExpiresIn int `json:"expires_in" example:"10800"`
}
これらの構造体はルールに基づいて、アプリケーションサービス層およびインターフェース層にのみ参照させます。このように各ロジックごとにデータ構造を設け、必要なデータのみを明示的に定義することで、それに関する一連の処理の責務を明確にすることができます。
また、バリデーションをアプリケーションサービス層とドメイン層で分けることもできます。別の例として、ユーザーを作成するとき、リクエストのPassword
は平文で受け付け、DBに格納する際はbcryptハッシュにすることを考えます。このとき、ドメインモデルUser
の持つPassword
は、bcryptハッシュになっていることを保存する前に検証することが望ましいです。
// application/dto/user_dto.go
type UserCreateRequest struct {
Name string `json:"name" example:"foo"`
Email string `json:"email" example:"foo0001@example.com"`
Password string `json:"password" example:"example-pass"`
}
// ozzo-validation を使用
func (dto UserCreateRequest) Validate() error {
return validation.ValidateStruct(&dto,
validation.Field(&dto.Name, validation.Required.Error("ユーザー名は必須です")),
validation.Field(&dto.Email,
validation.Required.Error("メールアドレスは必須です")),
validation.Field(&dto.Password,
validation.Required.Error("パスワードは必須です"),
validation.Match(regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};:'",.<>/?]{10,}$`)).Error("パスワードは半角英数字と記号を含む10文字以上である必要があります")),
)
}
// domain/model/user.go
type User struct {
// ...
}
func isBcryptHash(value interface{}) error {
// bcryptハッシュの評価関数
}
func (u User) Validate() error {
return validation.ValidateStruct(&u,
validation.Field(&u.Name,
validation.Match(regexp.MustCompile(`^[a-zA-Z0-9-]*$`)).Error("ユーザー名は英数字とハイフンのみ使用可能です"),
),
validation.Field(&u.Email,
validation.Match(regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)).Error("有効なメールアドレスを入力してください"),
),
validation.Field(&u.Password, validation.By(isBcryptHash)),
)
}
このようにすると、各レイヤーで要求されるルールを分離することができ、データを厳格に精査することが可能となります。
ただし、どこで何を検証するかのさじ加減はプロジェクトの要件によって決める必要があります。
おまけ : DTOをAPIドキュメント生成に利用
DTOのフィールドにつけるタグにexample
を設定しておくと、 swag のAPIドキュメント生成でExample Valueをセットしてくれてすごく便利です。アノテーションにリクエストやレスポンスの情報を含める際に以下のように記述すると、型情報をdto
パッケージに統一できてすっきりします。
// GetUser godoc
// @Summary ユーザー情報取得
// @Description 指定された`id`のユーザー情報を取得します。
// @Tags users
// @Accept json
// @Produce json
// @Param id path string true "ID"
// @Success 200 {object} dto.GetUserResponse
// @Failure 404 {object} dto.Error404Dto
// @Failure 500 {object} dto.Error500Dto
// @Router /users/{id} [get]
func (h *UserHandler) GetUser(c echo.Context) error {
// ...
return c.JSON(http.StatusOK, response)
}
実際に扱ったコードの一例
ここまでで利点を説明してきましたが、具体的にどのような課題に対して適用すると効果があるのか、 実装したWebアプリ で以前運用されていたコードをもとに作成した例を提示していきます。
Before
以下は、ユーザー登録を行う処理に関するコードの一部です。
移行前
require_once(dirname(__FILE__) . '/../vendor/autoload.php');
use Slim\Factory\AppFactory;
use MyApp\Controller\OtherController;
use MyApp\Exception\RestAPIBaseException;
use MyApp\Utils\RegisterUser;
// ...
function register_user(Request $request, Response $response): Response
{
try {
$body = $request->getParsedBody();
$data = isset($body['data']) ? $body['data'] : [];
$key = isset($body['key']) ? $body['key'] : 'default';
// バリデーション処理
// 登録処理を実行
$str = RegisterUser::exec($data, $key);
$payload = ['message' => $str];
$status = 200;
} catch (RestAPIBaseException $e) {
$payload = ['message' => $e->getMessage()];
$status = $e->getStatusCode();
}
return _create_response($payload, $status, $response);
}
// ...
$app = AppFactory::create();
$app->group('/api', function (RouteCollectorProxy $group) {
// register_userをルーティング
});
$app->run();
namespace MyApp\Utils;
require_once(dirname(__FILE__) . '/../../vendor/autoload.php');
use MyApp\Database\DBClient;
use MyApp\Exception\DBInsertError;
// ...
class RegisterUser
{
/**
* 受け取ったJSONデータからUserのインスタンスを配列として生成
*
* @return User[]
*/
private static function generate_user_array(array $data, string $group_key): array
{
$users = [];
foreach ($data) {
$user = new User();
// ...
$users[] = $user;
}
return $users;
}
/**
* DBに登録する
* バッチインサートを使用。エラーが発生した場合はロールバック
*/
private static function register_db(array $users): string
{
$db = new DBClient();
$pdo = $db->get_driver();
try {
$pdo->beginTransaction();
$db->batch_insert($users);
$pdo->commit();
return "SUCCESS";
} catch (Exception $e) {
$pdo->rollBack();
throw new DBInsertError;
}
}
/**
* JSONデータを受け取ってDBに登録
*/
public static function exec(array $data, string $group_key = 'default'): string
{
$users = self::generate_user_array($data, $group_key);
return self::register_db($users);
}
}
これらのコードはMVCをベースとして構築されたものでしたが、以下のようなつらみポイントがありました。
-
index.php
にController相当の処理が記述されている- 新しいエンドポイントの追加や既存の処理の修正に時間がかかる
- 実はnamespace
MyApp\Controller
はまた別に作られているので、Controllerにある処理とない処理を別々に把握する必要がある
-
Utils
にしては処理が大きい- ビジネスロジックの大部分は
Model
などに切ることが望ましい
- ビジネスロジックの大部分は
テストを書く時なども問題が生じます。インスタンスメソッドであればインスタンス化時にモックオブジェクトを注入できるのですが、RegisterUser::exec
は静的メソッドが直接DBClient
インスタンスを生成しているのでそうもいきません。以下のようにして、DBClient
を直接使用してDBと接続することになります。
移行前のテスト例
class RegisterUserTest extends TestCase
{
public function testExec()
{
$data = [
['id' => '1234', 'password' => 'pass1'],
['id' => '5678', 'password' => 'pass2']
];
// テスト用DBの準備が必要
// DBClientクラスの内部実装に依存
$result = RegisterUser::exec($data, 'test_group');
$this->assertEquals('SUCCESS', $result);
// 登録されたデータの検証のために、また実DBへの接続が必要
$db = new DBClient();
$users = $db->find_all();
$this->assertCount(2, $users);
}
}
このような構成が積み重なると、変更の影響範囲を追うのが難しく、新機能の追加をする際もどこから手を付ければよいのか迷ってしまいます。これをオニオンアーキテクチャで書き換えると、どのようになるでしょうか。
After
今回は、アプリケーションサービス層とインターフェース層の実装に絞って提示します。
移行後
インターフェース層は、HTTPリクエストとレスポンスの一連の処理を担っています。
type UserHandler struct {
userService service.UserService
}
func (h *UserHandler) CreateUsers(c echo.Context) error {
var req dto.UserGroupCreateRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
"リクエストの解析に失敗しました: " + err.Error())
}
if err := req.Validate(); err != nil {
validationErrors, ok := err.(validation.Errors)
if !ok {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return exception.NewValidationHTTPErrors(validationErrors)
}
err := h.userService.CreateUsers(&req)
if err != nil {
if validationErr, ok := err.(validation.Errors); ok {
return exception.NewValidationHTTPErrors(validationErr)
}
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusCreated, map[string]string{
"message": "ユーザーの作成が完了しました",
})
}
インターフェース層から呼び出されるアプリケーションサービス層では、与えられたDTOをもとに、ドメインオブジェクトに変換してインフラ層に出力する処理を実装しています。
type UserGroupCreateRequest struct {
Data []UserCreateDto `json:"data"`
}
type UserCreateDto struct {
UserID string `json:"user_id"`
Password string `json:"password"`
// ...
}
type UserService interface {
CreateUsers(req *UserGroupCreateRequest) error
}
type userService struct {
repo repository.UserRepository
}
func (s *userService) CreateUsers(req *UserGroupCreateRequest) error {
users := make([]model.User, len(req.Data))
for i, userDto := range req.Data {
// パスワードのハッシュ化
hashedPassword, err := bcrypt.GenerateFromPassword(
[]byte(userDto.Password),
bcrypt.DefaultCost,
)
if err != nil {
return err
}
users[i] = model.User{
UserID: userDto.UserID,
Password: string(hashedPassword),
// ...
}
}
// トランザクション内で一括挿入
return s.repo.Transaction(func(tx repository.UserRepository) error {
return tx.BulkInsert(context.Background(), users)
})
}
これらの層をまとめるために、依存性注入を行うDIコンテナを定義します。configも極力ここで注入します。
type Registry struct {
UserHandler *handler.UserHandler
}
func NewRegistry(
modeDev bool,
timeZone string,
dataVolumeDir string,
) (*Registry, error) {
// DBの初期化
db, err := database.NewDB(
config.DatabaseConfig.FilePath,
config.DatabaseConfig.ConnectionPool.MaxOpenConns,
config.DatabaseConfig.ConnectionPool.ConnMaxLifetime,
timeZone,
)
if err != nil {
return nil, fmt.Errorf("DB接続エラー: %w", err)
}
userRepo := persistence.NewUserRepository(db)
userService := service.NewUserService(userRepo)
userHandler := handler.NewUserHandler(userService)
return &Registry{
UserHandler: userHandler,
}, nil
}
このDIコンテナをmain.go
で呼び出して、各ハンドラーを完成させてルーティングを行います。
func main() {
// ...
go func() {
// DIコンテナの初期化
diRegistry, err := registry.NewRegistry(modeDev, timeZone, dataVolumeDir)
if err != nil {
slog.Error("DIの初期化に失敗しました。", "error", err)
os.Exit(1)
}
// Echoインスタンスの作成とルーティング
e := echo.New()
router.SetupRouter(e, diRegistry.UserHandler)
// サーバーの起動
if err := e.Start(":8080"); err != http.ErrServerClosed {
slog.Error("サーバーの起動に失敗しました。", "error", err)
os.Exit(1)
}
}()
// ...
}
このようにして各層を独立させたことで、変更の影響範囲を局所化することができました。
また、各層に対してテストを用意することで、コードのデバッグと修正をセットで実施できるようになったことも大きな恩恵の一つです。
移行後のテスト例
func TestUserHandler_CreateUsers(t *testing.T) {
e := echo.New()
mockService := new(MockUserService)
h := handler.NewUserHandler(mockService)
t.Run("正常系", func(t *testing.T) {
reqBody := `{"data": [{"user_id": "12345", "password": ...}]}`
req := httptest.NewRequest(
http.MethodPost,
"/path/to/create_users",
strings.NewReader(reqBody),
)
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
mockService
.On("CreateUsers", mock.AnythingOfType("*dto.UserGroupCreateRequest"))
.Return(nil)
if assert.NoError(t, h.CreateUsers(c)) {
assert.Equal(t, http.StatusCreated, rec.Code)
}
})
t.Run("バリデーションエラー", func(t *testing.T) {
// ...
})
}
実行結果
user@db1c0ca90b91:/app$ go test -v ./internal/interface/handler
=== RUN TestUserHandler_CreateUsers
=== RUN TestUserHandler_CreateUsers/正常系
=== RUN TestUserHandler_CreateUsers/バリデーションエラー
--- PASS: TestUserHandler_CreateUsers (0.00s)
--- PASS: TestUserHandler_CreateUsers/正常系 (0.00s)
--- PASS: TestUserHandler_CreateUsers/バリデーションエラー (0.00s)
PASS
ok example-backend/internal/interface/handler 0.008s
コード変更後にテストがFAILを返せば、少なくとも実装かテストのどちらかが間違っていることがすぐに把握できるので、実行時エラーを少なくすることができます。
実装を経験しての所感
適切に閉じる実装が難しい
理論上では各レイヤーに内部実装を閉じれば堅牢な作りになりますが、完全に閉じることは難しいと感じました。現実には実装の都合上、ユーティリティ関数やアプリケーション全体で共有する設定(config)に多少依存します。この点には注意を払い、以下のようなことを意識して実装を進めました。
- むやみに
util
パッケージに処理を固めない - 動的に変化するconfigを最小限に抑える
- configは依存性注入(Dependency Injection)のタイミングで与えて振る舞いを決定する
理想的には、すべてのconfigを初期化時に与えられるように切り出すと、システムの仕様変更の一部は.env
から提供される環境変数などの変更のみで対応できるようになります。
使いどころが肝心
オニオンアーキテクチャはアプリケーションを堅牢にすることが可能ですが、この構成が絶対的な正解であるとは考えていません。このアーキテクチャが向いているのは、そのサービスで必要となるデータへの理解が成熟しており、時間や予算に余裕がある場合だと思います。
Ruby on RailsやLaravelのようなフルスタックなフレームワーク(FW)では、インフラ層を密結合にすることでFWの思想に沿った開発を行いやすいよう設計されています。Active Recordパターンで設計されたフルスタックFWが採用される理由は、データベースアクセスなどのインフラ層の実装を抽象化し、開発者が直接扱う必要がないようにしているためです。そのため、このようなFWは開発者側がドキュメントを提供しやすく、開発速度も早くなります。
一方で、インフラ層も自前で管理したい、予め設計されたパターンでは表現力に乏しい、といった要望や課題感が出てくることがあります。そのような状況に対しては、PoEAA(Patterns of Enterprise Application Architecture)に基づくレイヤードアーキテクチャの考え方が基礎となります[8]。この思想を反映した設計パターン[9][10]を採用するメリット・デメリットは、フルスタックFWで挙げたものとトレードオフになります。つまり、ビジネスロジックに対する表現力や実装の柔軟性は高く、その分要求される業務知識やシステム設計の理解が増えることになります。そのため、運用開始までに必要な期間は長くなる傾向にあります。
これらはどちらかが絶対的に優れているか、といった評価軸で語れるものではなく、文字通り「時と場合による」と思います。例えば、0→1の開発で各レイヤーのモデリングやレイヤー間の疎結合が云々を話し出すと、アプリケーションの全体像やリリースまでの見通しが立てづらくなります。また、多少密結合になっても負債化しない開発規模や体制であれば、モデリングにかけるコストの方がそれによって得られる恩恵よりも大きくなると予想されます。このような検討を行った上で、よりメリットの大きい方を選択することが必要となります。
まとめ
まとめると、この設計のメリットは以下のようになります。
- 各層はデータ構造またはinterfaceに依存するため、実装の詳細が上位層に漏れない
- 変更の影響範囲が限定され、段階的なリファクタリングが可能
- 依存性注入が容易であり、単体テストが書きやすい
そして、これらのメリットとトレードオフになる点もお伝えしました。総括すると、オニオンアーキテクチャをはじめとするレイヤー化の手法を採用する本質は、細部まで管理したい重要なプロジェクトに対して、 アプリケーションの保守性やテスト容易性、表現力の向上が、その実現に払うコストよりも魅力的か という点にあると言えます。オニオンアーキテクチャは、長期的な保守や品質管理が重要なプロジェクトにおいて、その価値を発揮するでしょう。
参考
-
一般的に抽象型と呼ばれる型システムです。 ↩︎
-
戦術的設計と呼ばれる部分になります。戦略的設計(ドメインの境界や関係性の設計)を採用せず、戦術的設計(実装パターン)のみを取り入れた開発手法は軽量DDDと呼ばれることもあります。 ↩︎
-
DDDについての参考記事(https://panda-program.com/posts/what-is-ddd)。 ↩︎
-
Jeffrey氏の公開するWebサイトに掲載された、オニオンアーキテクチャに関する一連のページ(https://jeffreypalermo.com/tag/onion-architecture/)。 ↩︎
-
アプリケーションサービスとして
service
パッケージを定義しているので、Domain Serviceは便宜的にrepository
としています。 ↩︎ -
GoはUpperCamelCaseで定義された値のみをエクスポート可能です。 ↩︎
-
例えば、handler(A)がservice(B)に依存する関係を指します。 ↩︎
-
日本語翻訳が掲載されているサイト(https://bliki-ja.github.io/pofeaa/)。 ↩︎
-
PoEAAのレイヤードアーキテクチャを発展させたものとして、ヘキサゴナルアーキテクチャ、オニオンアーキテクチャ、クリーンアーキテクチャなどがあります。 ↩︎
-
PoEAAとDDDの違いがわかりやすくまとめられたWebページ(https://panda-program.com/posts/what-is-ddd)。 ↩︎
Discussion