堅牢なウェブアプリケーションを求めて(backend編)
概要
柔軟なプロダクトデザインは堅牢なソフトウェアから生まれる by じぶん
競争力の高いプロダクトを生み出すためには、デザインを柔軟に変更し、試行錯誤していく必要があります。
それを実現するためには「堅牢なソフトウェア」が必要です。
現在私が所属する営業製作所株式会社では、新規プロダクト開発にあたって、堅牢なソフトウェア開発を目指している最中です。
一区切りついてきたので、一度整理がてらアウトプットします。
知見のある方にご助言をいただけると嬉しいです。まさかりは勘弁です。まさかりダメ、ぜったい。
なお、この記事で紹介しているコードは下記のGitHubリポジトリから参照可能です。
対象読者
どれかひとつでも当てはまる方
- CleanArchitectureに興味がある
- インタフェースの役割に興味がある
- 堅牢なバックエンド開発に興味がある
コンセプト
さて、堅牢なアプリケーションとはどういうものでしょうか?
これに適切に回答するのは非常に難しいことだと思います。コンテキストによっても大きく左右されるでしょう。ウェブアプリケーションのバックエンドAPIに絞って考えます。
- フロントエンドから受け取る入力値を適切に処理できる
- フロントエンドに返す出力値が明確である
- フロントエンドに接する部分を変更しても処理が壊れない
これらを満たすバックエンド開発を目指していきます。
利用技術
- TypeScript
- Nest.js
- GraphQL
- Prisma ORM
- Neverthrow
コンセプトの部分で述べたとおり入力値や出力値が明確であることが重要なので、それを実現することを意識した技術選定となっています。
コードファーストに書きつつ型のサポートも受けられるということで、言語はTypeScriptです。
フロントエンドがTypeScriptということもあり、統一しています。
フロントエンドとの疎通を明確にしたいので、GraphQLを利用します。本記事では触れませんが、graphql-codegenを利用すれば、フロントエンド用の型定義の生成も簡単にできます。
ORMにはPrisma ORMを採用します。理由としては型定義が強力なことです。例えばですが、関連するテーブルを取得するように指定して関数を実行すると、戻り値が関連テーブルのプロパティが存在する型で返ってきます。取得しないようにすれば、含まれません。
NeverthrowはいわゆるResult型を実現するためのライブラリです。try catchだとどういう例外が発生しうるかなどの情報が抜け落ちてしまい、明確さが損なわれます。例外発生時にはthrowせずに、Error型で戻り値を返します。
サンプルアプリケーションの仕様
よくあるサンプルアプリケーションの構成です。2つのテーブル、User, Post が存在し、親子関係となっております。
なお、Userの作成、メールアドレス変更、削除処理しか実装していません。
Postの方はまた日を改めて追記するかもしれません。
構成
以下のクラスで構成していきます。
- Entity
ドメインオブジェクトを表すクラス - Command / Query
ユースケースを表すクラス - ObjectType / InpuType
フロントエンドとのインタフェースを表すGraphQL特有のクラス - Resolver
リクエストをレスポンスに変換する流れを取り扱うGraphQL特有のクラス(いわゆるContoller的なもの) - Repository
Entityを永続化させる処理を取り扱うクラス
実装 🚧
本記事では、クラスの役割やインタフェースの実装を重要視しているため、GraphQLやORM、Neverthrowといった各種技術の説明については省略させていただきます。悪しからずご了承ください。
それでは、ユーザー作成処理を作っていきます。
Entity実装
まずはEntityを実装していきます。
Entityは依存関係の最上位にあたるため、基本的にEntityからしか実装は始められません。
import { genUUID } from 'src/lib/uuid';
import { z } from 'zod';
import { err, ok } from 'neverthrow';
// Userが満たすべきインタフェースを定義する
// あとでObjectTypeでこのインタフェースを実装する
export interface IUser {
id: string;
email: string;
name: string | null;
createdAt: Date | null;
updatedAt: Date | null;
}
// Userを作成するために必要な入力値インタフェースを作成
export interface ICreateUserEntity {
email: string;
name?: string;
}
export class User implements IUser {
// TypeScriptのコンストラクタって名前付き引数ないのが厳しい
// privateにすることで他所からnewするのを禁止する
private constructor(
public id: string = genUUID(),
public email: string,
public name: string | null = null,
public createdAt: Date | null,
public updatedAt: Date | null,
) {}
// UserEntityが常に満たすべき不変条件をzodスキーマで表現
private readonly schema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().optional(),
createdAt: z.date().nullable(),
updatedAt: z.date().nullable(),
});
static create(input: ICreateUserEntity) {
// コンストラクタに名前付き引数の仕組みがないと順番間違える可能性があるのでかなり怖い
const user = new User(
genUUID(),
input.email,
input.name ?? null,
null,
null,
);
// validation結果はResult型にすることで明確なインタフェースを実現する
return ok(user).andThen(user.validate);
}
private validate(user: User) {
return ok(user).andThen(() => {
const result = user.schema.safeParse(user);
if (result.success) {
return ok(user);
} else {
return err(result.error);
}
});
}
}
Command実装
Entityの一つ下のユースケースレイヤーにあたる、Commandを実装していきます。
import { fromSafePromise, ok } from 'neverthrow';
import { ICreateUserEntity, IUser, User } from '../entities/user.entity';
import { ValidationFailedError } from 'src/lib/exceptions/validation-failed.exception';
import { Inject, Injectable } from '@nestjs/common';
// UserEntityで定義したUser作成インタフェースを継承して、ユースケースの入力インタフェースを定義
// 今回はたまたま同じなので、継承するだけでおしまい
export interface ICreateUserInput extends ICreateUserEntity {}
// 永続化処理のインタフェースを定義する
// あとでUserRepositoryで実装する
export interface IUserRepositoryCreateUser {
save(user: User): Promise<IUser>;
}
@Injectable()
export class CreateUserCommand {
constructor(
@Inject('UserRepository')
private readonly userRepository: IUserRepositoryCreateUser,
) {}
// Neverthrowを使っているとメソッドチェーンが安全かつ簡単に記述できる
execute(input: ICreateUserInput) {
return ok(input)
.andThen(this.toEntity.bind(this))
.asyncAndThen(this.save.bind(this));
}
private toEntity(input: ICreateUserInput) {
return ok(input)
.andThen(User.create)
.mapErr((zodError) => {
// カスタム例外クラスは本筋とは関係ないので説明省略します
return new ValidationFailedError({
responseMessage: zodError.errors.map((e) => e.message).join('\n'),
options: {
cause: zodError.cause,
},
});
});
}
private save(user: User) {
return fromSafePromise(this.userRepository.save(user)).map(() => user);
}
}
InputType実装
GraphQL Requestのインタフェースとなるインプットタイプを実装します。
前項のCommandで定義したインタフェースを実装する形となります。
import { Field, InputType } from '@nestjs/graphql';
import { ICreateUserInput } from '../commands/create-user.command';
@InputType()
export class CreateUserInput implements ICreateUserInput {
@Field(() => String)
email: string;
@Field(() => String, { nullable: true })
name?: string;
}
ObjectType実装
GraphQL Responseのインタフェースとなるオブジェクトタイプを実装します。
先立って、IDとタイムスタンプを持つことを強制するインタフェースを用意しておきます。
オブジェクトタイプはこのインタフェースを実装することとします。
import { Field, ID, InterfaceType } from '@nestjs/graphql';
@InterfaceType({ isAbstract: true })
export abstract class Entity {
@Field(() => ID, { nullable: false })
id: string;
@Field(() => Date, { nullable: false })
createdAt: Date;
@Field(() => Date, { nullable: false })
updatedAt: Date;
}
それではUserObjectを作成します。
このクラスは先ほどのEntityインタフェース、UserEntityで定義したIUserインタフェース、またPrisma ORMが生成した型定義をインタフェースとして実装します。これはPrisma ORMで取得した値をそのまま返却する構成にすることで開発効率を上げたいという狙いがあります。
import { Field, ObjectType } from '@nestjs/graphql';
import { Entity } from 'src/lib/grpahql/interfaces/entity';
import { IUser } from '../entities/user.entity';
import { User } from '@prisma/client';
@ObjectType('User')
export class UserObject extends Entity implements IUser, User {
@Field(() => String, { nullable: false })
email: string;
@Field(() => String, { nullable: true })
name: string | null;
}
Resolver実装
いわゆるコンローラーに近い役割を持つResolverを実装していきます。
リクエストを受け取り、処理を実行し、レスポンスを返却します。
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { UserObject } from '../objects/user.object';
import { err, fromSafePromise, ok } from 'neverthrow';
import { CreateUserInput } from '../inputs/create-user.input';
import { CreateUserCommand } from '../commands/create-user.command';
// レスポンスの型をここで定義する
export type CreateUserResult = UserObject | ValidationFailed;
@Reolver()
export class UserResolver {
private readonly createUserCommand: CreateUserCommand,
@Mutation(() => CreateUserResult)
async createUser(
@Args('input', { type: () => CreateUserInput }) input: CreateUserInput,
): Promise<CreateUserResult> {
const result = await this.createUserCommand.execute(input);
// Commandの戻り値をレスポンスの形式に変換する
if (result.isErr()) {
// 説明を省略していますが、カスタム例外クラスにはtoGraphqlを用意しています
return result.error.toGraphql();
} else {
return result.value;
}
}
}
ObjectType実装 その2
Resolverで定義した CreateUserResult は実はこのままではGraphQLで取り扱えません。
Union型でオブジェクトタイプを実装した場合、与えられた値をもとにどの型で返却するのかを解決する関数を定義してあげる必要があります。
import { createUnionType } from '@nestjs/graphql';
import { UserObject } from './user.object';
import { ValidationFailed } from 'src/lib/exceptions/validation-failed.exception';
export const CreateUserResult = createUnionType({
name: 'CreateUserResult',
types: () => [UserObject, ValidationFailed] as const,
resolveType(value: UserObject | ValidationFailed) {
if ('errorName' in value) {
return ValidationFailed;
} else {
return UserObject;
}
},
});
Repository実装
最後に、永続化処理を実装します。
import { Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma.service';
import { DeletableUser, User } from '../entities/user.entity';
import { IUserRepositoryCreateUser } from '../commands/create-user.command';
@Injectable()
export class UserRepository implements IUserRepositoryCreateUser {
constructor(private readonly prisma: PrismaService) {}
async save(user: User) {
return await this.prisma.user.upsert({
where: {
id: user.id,
},
create: {
id: user.id,
email: user.email,
name: user.name,
},
update: {
email: user.email,
name: user.name,
},
});
}
}
まとめ
ここまで書くのに思いの外に体力と時間を使ってしまったので、本記事は一旦ここでクローズします。
ポイントとしては、依存の順序を意識して実装していくことです。
処理の順序で実装してしまうと、依存関係がおかしなことになってしまいます。
<依存関係の順序>
Entity > Usecase > Repository, Contoller
(今回の例においてはRepository, ControllerはUsecaseのひとつ下のレイヤーで同列)
<処理の順序>
Contoller > Usecase > Repository > Entity
比べてみると全然違いますね。
そんなに問題なのかなと思う方も中にはいらっしゃるかもしれませんが、これはとても重要な違いです。
単純な機能なら大した問題はないのですが、少し複雑な機能になると、UIの影響でバックエンドとフロントエンドのインタフェースが変わることが珍しくありません。というか結構あります。
依存関係の順序が正しく実装できていると、フロントエンドの要求が変わっても、UsecaseやEntityの中核処理には影響を与えません。もちろん、UsecaseやEntityが要求するインタフェースを満たせなくなるケースはその限りではありませんが。その場合でも、インタフェースの差分を吸収する層を持たせる対応も選択肢に加わります。
- フロントエンドから受け取る入力値を適切に処理できる
- フロントエンドに返す出力値が明確である
- フロントエンドに接する部分を変更しても処理が壊れない
本記事の実装はユーザーを新規作成するというだけの処理ではありますが、上記の3点を実現できています。フロントエンドから受け取る入力値はEntityやUsecaseの要求を常に満たしています。例外もすべてResult型で取り扱う設計のため、フロントエンド側でもどういう例外が発生するのかが明確にわかります。そして何より、フロントエンドに接する部分を変更しても処理は壊れません。
冒頭の言葉のとおりで 柔軟なプロダクトデザインは堅牢なソフトウェアから生まれる はずです。
堅牢なソフトウェアを作り、柔軟なプロダクトデザインを可能にし、ビジネス的な競争力を高める。
そして顧客により良い体験を届け、社会を豊かにしていける。そんなエンジニアでありたい。
さいごに
私が所属する営業製作所株式会社ではエンジニアを募集しています!
日本が誇る製造業をもう一度輝かせるという志に賛同いただけるエンジニアの方がいらっしゃったら、ぜひご応募を!!!
それ以外でも話を聞いてみたいという方がいらっしゃいましたら、カジュアル面談だけでも構いませんので、どうぞお気軽にお声がけください 🙌
Discussion