Skip to content
CodeSook
CodeSook

03. Convert Employee Repository Promise to Effect


Prisma layer

ใน Effect จะนิยมใช้ Layer ในการจัดการกับ Dependencies ทั้งหลาย
เดี๋ยวค่อยๆดูกันไปนะครับ

เราจะทำ Prisma layer ก่อน

import { PrismaClient } from "@prisma/client"
import { Context, Layer } from "effect"
const prismaClient = new PrismaClient()
export class PrismaClientContext extends Context.Tag("Repository/PrismaClientLayer")<PrismaClientContext, PrismaClient>() {
static Live = Layer.succeed(this, prismaClient)
}
export default PrismaClientContext

จาก code ด้านบนเราสร้าง Layer ที่เป็น class นะครับ โดยจะ Extends มาจาก Context.Tag(<unique tag>)<[Name of the class], [Record of services]>() ซึ่งเราต้องใส่ tag เข้าไปด้วย อย่าให้ซ้ำกับ Tag ของ service อื่นๆนะครับ ตัวนี้จะช่วยให้เรารู้ว่า function ไหนต้องการ Dependencies อะไร

ส่วน static Live ก็คือสร้าง Service จริงๆที่เราจะใช้งานใน production แต่จะยังไม่ได้ใช้ในตอนนี้ เก็บไว้ตรงนี้ก่อน


Schema convert helpers

เราได้ทำ Helper functions สำหรับใช้แปลง object ให้กลายเป็น Effect Schema ไปแล้ว
แต่ว่ามันก็อาจจะมีตอนที่แปลงได้ไม่สำเร็จ ซึ่ง Helpers เหล่านั้นมันจะ throw Error ออกมาเลย ซึ่งเราไม่ได้ต้องการให้มัน Throw Error เราก็จะใช้อีก function หนึ่งให้มันแปลง object เป็น Effect Schema โดยเอา Effect มาครอบไว้ จะได้ใช้ร่วมกับ Effect ส่วนอื่นๆได้แบบ seamless

src/schema/helpers.ts
src/schema/helpers.ts
import * as S from "effect/Schema"
export const fromObjectToSchema = S.decodeUnknownSync
export const fromSchemaToObject = S.encodeSync
export const fromObjectToSchemaEffect = S.decode
export const fromSchemaToObjectEffect = S.encode

Employee and Overtime Repository Errors

เราจะมา define Error กันก่อนในทุกๆ operations ของ Repositories
ทำไมต้องสร้าง Error ด้วย นั่นก็เพราะว่า PrismaClient เป็น Third party library ที่ใช้ Promise-based แต่เราใช้ Effect-based เราไม่สามารถไปแก้ code ของ PrismaClient ได้
ฉนั้นเราจะเขียน function มา wrap function ของ PrismaClient ในทุกๆ operations ที่เราเขียน

file and folders

src/types
├── error.helpers.ts
├── errors
│ ├── employee-errors.ts
│ └── overtime.ts
├── repositories
│ ├── employee.ts
│ └── overtime.ts
└── services
├── employee.ts
└── overtime.ts

เนื่องจากว่า Error เป็น class เราก็เลยสร้าง function ที่ช่วยให้เอาสร้าง Error class ได้ง่ายๆ

src/types/errors.helpers.ts
src/types/errors.helpers.ts
export type ErrorMsg = {
error?: unknown
msg: string
}
export function createErrorFactory<T extends new (err: ErrorMsg) => any>(Self: T) {
return (msg: string) => (error?: unknown) => new Self({ error, msg })
}
src/types/errors/employee-errors.ts
src/types/errors/employee-errors.ts
import { Data } from "effect"
import { createErrorFactory, type ErrorMsg } from "../error.helpers.js"
export class CreateEmployeeError extends Data.TaggedError("CreateEmployeeError")<ErrorMsg> {
static new = (msg?: string) => (error?: unknown) => new CreateEmployeeError({ error, msg })
}
export class FindEmployeeByIdError extends Data.TaggedError("FindEmployeeByIdError")<ErrorMsg> {
static new = createErrorFactory(this)
}
export class FindManyEmployeeError extends Data.TaggedError("FindManyEmployeeError")<ErrorMsg> {
static new = createErrorFactory(this)
}
export class UpdateEmployeeError extends Data.TaggedError("UpdateEmployeeError")<ErrorMsg> {
static new = createErrorFactory(this)
}
export class RemoveEmployeeError extends Data.TaggedError("RemoveEmployeeError")<ErrorMsg> {
static new = createErrorFactory(this)
}

Employee Repository interface

เราจะมาทำ Interface สำหรับ Employee Repository กัน เราไม่ได้เขียนใหม่นะ เราแค่แก้ types ให้ไปเป็น Effect

import type { Effect } from "effect"
import type { NoSuchElementException } from "effect/Cause"
import type { ParseError } from "effect/ParseResult"
import type { Branded, EmployeeSchema, EmployeeWithRelationsSchema } from "../../schema/index.js"
import type * as Errors from "../errors/employee-errors.js"
type Employee = EmployeeSchema.Employee
export type EmployeeRepository = {
create: (data: EmployeeSchema.CreateEmployeeEncoded) => Effect.Effect<Employee, Errors.CreateEmployeeError | ParseError>
findById: (id: Branded.EmployeeId) => Effect.Effect<Employee, Errors.FindEmployeeByIdError | ParseError | NoSuchElementException>
findByIdWithRelations: (id: Branded.EmployeeId) => Effect.Effect<EmployeeWithRelationsSchema.EmployeeWithRelations, Errors.FindEmployeeByIdError | ParseError | NoSuchElementException>
findMany: () => Effect.Effect<EmployeeSchema.EmployeeArray, Errors.FindManyEmployeeError>
findManyWithRelations: () => Effect.Effect<EmployeeWithRelationsSchema.EmployeeWithRelationsArray, Errors.FindManyEmployeeError>
update: (id: Branded.EmployeeId, data: EmployeeSchema.UpdateEmployeeEncoded) => Effect.Effect<Employee, Errors.UpdateEmployeeError | ParseError>
updatePartial: (id: Branded.EmployeeId, data: Partial<EmployeeSchema.UpdateEmployeeEncoded>) => Effect.Effect<Employee, Errors.UpdateEmployeeError>
remove: (id: Branded.EmployeeId) => Effect.Effect<Employee, Errors.RemoveEmployeeError>
hardRemove: (id: Branded.EmployeeId) => Effect.Effect<Employee, Errors.RemoveEmployeeError>
}

จะเห็นว่า Error ที่ใส่จะมี

  • Errors ของ operations นั้นๆ
  • ParseError อันนี้คือ เรา Parse Effect Schema ไม่ผ่าน
  • NoSuchElementException อันนี้จะเป็น Error ที่ใช้บอกว่าไม่มี element นั้นๆ ผมใช้อันนี้แทน null ใครจะใช้ Option แทนก็ได้

ตรงนี้สำหรับมือใหม่อาจจะยากที่จะบอกว่า Errors มันเป็นอะไรได้บ้าง success type เป็นอะไรได้บ้าง
เราอาจจะใช้วิธีสร้าง function นั้นๆขึ้นมาก่อน แล้วดูว่า return type เป็นอะไร แล้วค่อยเอามาใส่ใน interface อีกทีนึงก็ได้
แต่ถ้าทีมขนาดใหญ่อาจจะทำแบบนั้นได้ยาก

Employee Repository Create service

เรามาสร้าง function ที่เอาไว้ create Employee กัน โดยใช้ interface ที่ได้แก้ไปใช้ Effect และ function นี้ก็เปลี่ยนมาใช้ Effect เช่นกัน

src/repositories/employees/creates.ts
src/repositories/employees/creates.ts
export function create(prismaClient: PrismaClient): EmployeeRepository["create"] {
return async (data) => {
const result = await prismaClient.employee.create({
data,
})
return Helpers.fromObjectToSchema(EmployeeSchema.Schema)(result)
}
}
export function create(prismaClient: PrismaClient): EmployeeRepository["create"] {
return data => Effect.tryPromise({
catch: Errors.CreateEmployeeError.new(),
try: () => prismaClient.employee.create({
data,
}),
}).pipe(
Effect.andThen(Helpers.fromObjectToSchemaEffect(EmployeeSchema.Schema)),
Effect.withSpan("create.employee.repository"),
)
}

เนื่องจากเราเรียก function จาก third-party lib เราก็เลยจะใช้ Effect.tryPromise เข้าไปด้วย
การทำงานก็ยังเหมือนเดิม แต่ผมแยกการ parse schema ออกไปอีก function หนึ่ง แล้วเอามาใช้ที่ Effect.andThen() การทำงานของ function create() นี้ก็จะประกอบไปด้วย function เล็กๆหลายๆ functions มาทำงานต่อๆกันเป็นลำดับ
ถ้ามี function อื่นๆอีกก็ใช้ Effect.andThen() ไปเรื่อยๆ

ส่วน Effect.withSpan() เป็นการสร้าง tracing span เอาไว้เดี๋ยวจะอธิบายอีกครั้งในภายหลังว่ามันเอาไว้ทำอะไร ในหัวข้อ Telemetry

Employee Repository Find, Update, Remove

มาทำ employee repository ที่เหลือกัน

find

src/repositories/employees/finds.ts
src/repositories/employees/finds.ts
import type { PrismaClient } from "@prisma/client"
import type { EmployeeRepository } from "../../types/repositories/employee.js"
import { Effect } from "effect"
import { EmployeeSchema, EmployeeWithRelationsSchema, Helpers } from "../../schema/index.js"
import * as Errors from "../../types/errors/employee-errors.js"
export function findMany(prismaClient: PrismaClient): EmployeeRepository["findMany"] {
return () => Effect.tryPromise({
catch: Errors.FindManyEmployeeError.new(),
try: () => prismaClient.employee.findMany({
where: {
deletedAt: null,
},
}),
}).pipe(
Effect.andThen(Helpers.fromObjectToSchema(EmployeeSchema.SchemaArray)),
Effect.withSpan("find-many.employee.repository"),
)
}
export function findManyWithRelations(prismaClient: PrismaClient): EmployeeRepository["findManyWithRelations"] {
return () => Effect.tryPromise({
catch: Errors.FindManyEmployeeError.new(),
try: () => prismaClient.employee.findMany({
include: {
overtimes: true,
},
where: {
deletedAt: null,
},
}),
}).pipe(
Effect.andThen(Helpers.fromObjectToSchema(EmployeeWithRelationsSchema.SchemaArray)),
Effect.withSpan("find-many-with-relations.employee.repository"),
)
}
export function findById(prismaClient: PrismaClient): EmployeeRepository["findById"] {
return id => Effect.tryPromise({
catch: Errors.FindEmployeeByIdError.new(),
try: () => prismaClient.employee.findUnique({
where: {
deletedAt: null,
id,
},
}),
}).pipe(
Effect.andThen(Effect.fromNullable),
Effect.andThen(Helpers.fromObjectToSchema(EmployeeSchema.Schema)),
Effect.withSpan("find-by-id.employee.repository"),
)
}
export function findByIdWithRelations(prismaClient: PrismaClient): EmployeeRepository["findByIdWithRelations"] {
return id => Effect.tryPromise({
catch: Errors.FindEmployeeByIdError.new(),
try: () => prismaClient.employee.findUnique({
include: {
overtimes: true,
},
where: {
deletedAt: null,
id,
},
}),
}).pipe(
Effect.andThen(Effect.fromNullable),
Effect.andThen(Helpers.fromObjectToSchemaEffect(EmployeeWithRelationsSchema.Schema)),
Effect.withSpan("find-by-id-with-relations.employee.repository"),
)
}

update

src/repositories/employees/updates.ts
src/repositories/employees/updates.ts
import type { PrismaClient } from "@prisma/client"
import type { EmployeeRepository } from "../../types/repositories/employee.js"
import { Effect } from "effect"
import { EmployeeSchema, Helpers } from "../../schema/index.js"
import * as Errors from "../../types/errors/employee-errors.js"
export function update(prismaClient: PrismaClient): EmployeeRepository["update"] {
return (id, data) => Effect.tryPromise({
catch: Errors.UpdateEmployeeError.new(),
try: () => prismaClient.employee.update({
data,
where: {
deletedAt: null,
id,
},
}),
}).pipe(
Effect.andThen(Helpers.fromObjectToSchema(EmployeeSchema.Schema)),
Effect.withSpan("update.overtime.repository"),
)
}
export function updatePartial(prismaClient: PrismaClient): EmployeeRepository["updatePartial"] {
return (id, data) => Effect.tryPromise({
catch: Errors.UpdateEmployeeError.new(),
try: () => prismaClient.employee.update({
data,
where: {
deletedAt: null,
id,
},
}),
}).pipe(
Effect.andThen(Helpers.fromObjectToSchema(EmployeeSchema.Schema)),
Effect.withSpan("update-partial.overtime.repository"),
)
}

remove

src/repositories/employees/removes.ts
src/repositories/employees/removes.ts
import type { PrismaClient } from "@prisma/client"
import type { EmployeeRepository } from "../../types/repositories/employee.js"
import { Effect } from "effect"
import { EmployeeSchema, Helpers } from "../../schema/index.js"
import * as Errors from "../../types/errors/employee-errors.js"
export function remove(prismaClient: PrismaClient): EmployeeRepository["remove"] {
return id => Effect.tryPromise({
catch: Errors.RemoveEmployeeError.new(),
try: () => prismaClient.employee.update({
data: {
deletedAt: new Date(),
},
where: {
id,
},
}),
}).pipe(
Effect.andThen(
Helpers.fromObjectToSchema(EmployeeSchema.Schema),
),
Effect.withSpan("remove.overtime.repository"),
)
}
export function hardRemoveById(prismaClient: PrismaClient): EmployeeRepository["hardRemove"] {
return id => Effect.tryPromise({
catch: Errors.RemoveEmployeeError.new(),
try: () => prismaClient.employee.delete({
where: {
id,
},
}),
}).pipe(
Effect.andThen(
Helpers.fromObjectToSchema(EmployeeSchema.Schema),
),
Effect.withSpan("hard-remove.overtime.repository"),
)
}

Employee Repository Context

เมื่อมี functions ครบแล้วเราจะมาสร้าง Context หรือ Layer สำหรับ EmployeeRepository กัน

หาก service ไหนต้องการติดต่อกับ Employee database ก็ต้องผ่าน Employee Repository นี้ ก็ให้เอา Context ตัวนี้ไปใช้ แทนที่จะเอา interface ไปใช้

src/repositories/employees/index.ts
src/repositories/employees/index.ts
import type { PrismaClient } from "@prisma/client"
import type * as Types from "../../types/repositories/employee.js"
import { Context, Effect, Layer } from "effect"
import PrismaClientContext from "../prisma.js"
import * as Creates from "./creates.js"
import * as Finds from "./finds.js"
import * as Removes from "./removes.js"
import * as Updates from "./updates.js"
export default function initEmployeeRepository(prismaClient: PrismaClient): Types.EmployeeRepository {
function initEmployeeRepository(prismaClient: PrismaClient): Types.EmployeeRepository {
return {
create: Creates.create(prismaClient),
findById: Finds.findById(prismaClient),
findByIdWithRelations: Finds.findByIdWithRelations(prismaClient),
findMany: Finds.findMany(prismaClient),
findManyWithRelations: Finds.findManyWithRelations(prismaClient),
hardRemove: Removes.hardRemoveById(prismaClient),
remove: Removes.remove(prismaClient),
update: Updates.update(prismaClient),
updatePartial: Updates.updatePartial(prismaClient),
}
}
export class EmployeeRepositoryContext extends Context.Tag("repository/Employee")<EmployeeRepositoryContext, Types.EmployeeRepository>() {
static Live = Layer.effect(this, Effect.gen(function* () {
const prismaClient = yield * PrismaClientContext
return initEmployeeRepository(prismaClient)
}))
}

จาก code ด้านบน
ได้สร้าง class EmployeeRepositoryContext ขึ้นมา ซึ่งจะเห็นว่าเราใส่ Tag ด้วย "repository/Employee" จะเป็นคำว่าอะไรก็ได้ ขอแค่ไม่ซ้ำกับ Context อื่นๆ ก็พอ

ใน class มี method Live ที่จะใช้สร้าง Context EmployeeRepositoryContext ตัวนี้แหละ
โดยเราจะสร้างผ่าน Layer.effect(<class name>, <Effect value>) รับ parameters 2 ตัว

  1. ในส่วนของ class name จะใช้ this ที่หมายถึง class EmployeeRepositoryContext
  2. Effect ที่ return interface Types.EmployeeRepository ซึ่งจะถูกกำหนดไว้ด้านหลัง Context.Tag()
    เรามาลงรายละเอียดกันสักนิด
    จากตัวอย่างจะใช้ Effect.gen() ซึ่งไม่จำเป็นต้องใช้ก็ได้นะ แล้วแต่ว่าใครชอบแบบไหน ส่วนตัวผมไม่ค่อยได้ใช้เลยย แต่ใส่ให้ดูว่าใช้วิธีนี้ก็ได้เช่นกัน
    เอามาดูกันต่อละ Effect.gen() เราจะต้องใส่ generator function เท่านั้น function* () {} ที่มันมี * นั่นแหละ
    ภายใน function ก็เขียนเหมือน generator function ปกติ แต่จะมี yield* ที่ต้องใช้กับอะไรก็ตามที่เป็น Effect เท่านั้นเลย ในตัวอย่างก็จะใช้กับ PrismaClientContext
    สุดท้ายจากตัวอย่างนี้เราก็แค่ต้อง return object ที่มี function ต่างๆตาม interface EmployeeRepository โดยส่ง prismaClient ที่ด้จาก PrismaClientContext เข้าไป

Additional info

ส่วนล่างนี้ถ้างงก็ข้ามไปก่อนก็ได้นะ
จากการเขียน method Live ด้านบน
เพิ่มเติมให้อีกนิดนึงว่าเราสามารถเขียนอีกแบบนึงได้ เช่น

export function create(prismaClient: PrismaClient): EmployeeRepository["create"] {
return data => Effect.tryPromise({
catch: Errors.CreateEmployeeError.new(),
try: () => prismaClient.employee.create({
data,
}),
}).pipe(
Effect.andThen(Helpers.fromObjectToSchemaEffect(EmployeeSchema.Schema)),
Effect.withSpan("create.employee.repository"),
)
}
export function create2(data: EmployeeSchema.CreateEmployeeEncoded): Effect.Effect<EmployeeSchema.Employee, Errors.CreateEmployeeError | ParseError, PrismaClientContext> {
return PrismaClientContext.pipe(
Effect.andThen(prismaClient => Effect.tryPromise({
catch: Errors.CreateEmployeeError.new(),
try: () => prismaClient.employee.create({
data,
}),
})),
Effect.andThen(Helpers.fromObjectToSchemaEffect(EmployeeSchema.Schema)),
Effect.withSpan("create.employee.repository"),
)
}

จะเห็นว่าเอา PrismaClientContext มาใช้ตรงๆเลย ไม่ได้รับเป็น argument เข้ามา และก็ได้ผลลัพธ์เหมือนกัน

แต่ผมไม่แนะนำให้ทำแบบนี้ มันจะเกิดสิ่งที่เรียกว่า Requirement Leakage
มันจะทำให้ตอนเขียน test ต้อง mockup dependencies หลายตัวโดยที่ไม่จำเป็น