GraphQL で Clean Architecture をやる
GraphQL を本格的に触る機会があったので、まあできるか試すよね、ということでやってみたメモ。
先にできたもの
実際にデプロイして動いているので多分大丈夫。
解説とか
元ネタはこれ。
要は field への resolver にロジックを組み込める、という話し。であれば、そのロジックを Clean Architecture でいうところの Entity で定義して呼び出してやればいい。
Entity & Usecase
Entity は次のように定義している。クラスを使ってもいいが、いちいち new
するのがめんどくさいので構造体とそれを第一引数として受け取る関数群という形でドメインを記述する。
export interface Entity {
id: string;
title: string;
isCompleted: boolean;
createdAt: Date;
updatedAt: Date;
}
// 忘れられているっぽいものは true
export function isAbandoned(entity: Entity): boolean {
if (entity.isCompleted) {
return false;
}
const now = new Date().getTime();
const delta = (now - entity.updatedAt.getTime()) / 1000 / 60 / 60 / 24;
if (delta > 14) {
return true;
}
return false;
}
こういう形であればテストも書きやすい。
import { isAbandoned } from "./todo.js";
describe("Todo", () => {
describe("isAbandoned", () => {
it("should be abandoned if it is not completed and updated more than 2 weeks ago", () => {
const todo = {
id: "foo",
title: "test",
isCompleted: false,
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 15),
updatedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 15),
};
expect(isAbandoned(todo)).toBe(true);
});
// other tests
}
}
Todo アプリでは薄すぎて Usecase がないが、複数の Entity を操作しながらあるていど複雑なことをする処理は Usecase として定義すればよいだろう。
Interface
データベースや GraphQL など、フレームワークとの接点は Interface として閉じ込める。
export function todoRepositoryFactory(databaseUrl: string): TodoRepository {
const prisma = prismaClientFactory(databaseUrl);
async function getTodoById(id: string) {
const todo = await prisma.todo.findUnique({
where: { id },
});
if (!todo) {
throw new Error(`Todo with id ${id} is not found`);
}
return todo;
}
return {
getTodoById,
// ...
};
}
外部からパラメーターを渡すために Factory パターンを使っている。 repository という言葉は DDD 的だが、まあ良いだろう。
GraphQL は TypeScript で書いていると少し型安全を諦めねばならない。 codegen が生成してくれる QueryResolvers
の定義を使って次のように resolver を書くのが自然だが、返り値に computed field が含まれていないぞ、と怒られる。
const getTodoById: QueryResolvers["getTodoById"] = async (_parent, args) => {
return todoRepository.getTodoById(args.id);
};
Type '(_parent: {}, args: RequireFields<QueryGetTodoByIdArgs, "id">) => Promise<Entity>' is not assignable to type 'Resolver<Maybe<ResolverTypeWrapper<Todo>>, {}, any, RequireFields<QueryGetTodoByIdArgs, "id">> | undefined'.
Type '(_parent: {}, args: RequireFields<QueryGetTodoByIdArgs, "id">) => Promise<Entity>' is not assignable to type 'ResolverFn<Maybe<ResolverTypeWrapper<Todo>>, {}, any, RequireFields<QueryGetTodoByIdArgs, "id">>'.
Type 'Promise<Entity>' is not assignable to type 'Maybe<ResolverTypeWrapper<Todo>> | Promise<Maybe<ResolverTypeWrapper<Todo>>>'.
Type 'Promise<Entity>' is not assignable to type 'Promise<Todo>'.
Property 'isAbandoned' is missing in type 'Entity' but required in type 'Todo'.ts(2322)
generated.ts(62, 3): 'isAbandoned' is declared here.
というわけで、返り値の型については自前で定義してやるか、思い切ってなしとしても良い。
async function getTodoById(
_parent: ResolversParentTypes["Query"],
args: QueryGetTodoByIdArgs
) {
return todoRepository.getTodoById(args.id);
}
足りていなかった computed field 、ここでは isAbandoned
については次のように定義してやる。
const isAbandoned: TodoResolvers["isAbandoned"] = async (parent) => {
return Todo.isAbandoned(parent);
};
そして、次のように resolver として渡してやる。これでバッチリ。
const resolvers = {
Query: {
// some definitions
},
Mutation: {
// some definitions
},
Todo: {
isAbandoned,
},
};
Discussion