diff --git a/packages/server/src/CatsApiImpl.ts b/packages/server/src/CatsApiImpl.ts index c427c15..88f28d2 100644 --- a/packages/server/src/CatsApiImpl.ts +++ b/packages/server/src/CatsApiImpl.ts @@ -1,5 +1,5 @@ import { Effect } from "effect"; -import { CatsService } from "./CatsService.ts"; // Import CatsService +import { CatsServicePort } from "./CatsServicePort.ts"; // Import CatsServicePort import { HttpApiBuilder } from "@effect/platform"; import { api } from "@effect-cats/domain"; @@ -8,19 +8,19 @@ export const catsApiLiveGroup = HttpApiBuilder.group( "cats", (handlers) => Effect.gen(function* (_) { - const catsService = yield* _(CatsService); // Use CatsService + const catsService = yield* _(CatsServicePort); // Use CatsServicePort return handlers - .handle("getAllCats", () => catsService.getAllCats) // Use CatsService method - .handle("getCatById", ({ path: { id } }) => catsService.getCatById(id)) // Use CatsService method + .handle("getAllCats", () => catsService.getAllCats) // Use CatsServicePort method + .handle("getCatById", ({ path: { id } }) => catsService.getCatById(id)) // Use CatsServicePort method .handle( "createCat", ({ payload: { name, breed, age } }) => - catsService.createCat(name, breed, age), // Use CatsService method + catsService.createCat(name, breed, age), // Use CatsServicePort method ) .handle( "updateCat", - ({ path: { id }, payload }) => catsService.updateCat(id, payload), // Use CatsService method + ({ path: { id }, payload }) => catsService.updateCat(id, payload), // Use CatsServicePort method ) - .handle("deleteCat", ({ path: { id } }) => catsService.deleteCat(id)); // Use CatsService method + .handle("deleteCat", ({ path: { id } }) => catsService.deleteCat(id)); // Use CatsServicePort method }), ); diff --git a/packages/server/src/CatsService.test.ts b/packages/server/src/CatsApplicationService.test.ts similarity index 60% rename from packages/server/src/CatsService.test.ts rename to packages/server/src/CatsApplicationService.test.ts index ce1fa1a..23d9820 100644 --- a/packages/server/src/CatsService.test.ts +++ b/packages/server/src/CatsApplicationService.test.ts @@ -1,37 +1,29 @@ -import { Effect, Layer, Schema } from "effect"; // Removed Context, Data as they might not be needed directly -import { assert, assertEquals } from "jsr:@std/assert"; // Corrected assert import +import { Effect, Layer, Schema } from "effect"; +import { assert, assertEquals } from "jsr:@std/assert"; import { describe, it } from "jsr:@std/testing/bdd"; -// Domain imports - assuming CatNotFound is exported from domain now import { Cat, CatId, CatNotFound } from "@effect-cats/domain"; -// Service and ACTUAL Repository Tag imports -import { CatsService, CatsServiceLive } from "./CatsService.ts"; +// Service Port and Application Service imports +import { CatsServicePort } from "./CatsServicePort.ts"; +import { CatsApplicationServiceLive } from "./CatsApplicationService.ts"; import { CatsRepositoryPort } from "./CatsRepositoryPort.ts"; -// The mock implementation's type should ideally match the actual service interface provided by CatsRepositoryPort -// This line assumes CatsRepositoryPort has an 'of' static method and its first parameter is the service impl -// If CatsRepositoryPort is just a Tag, this will need adjustment. -// For now, let's define a similar structure to what CatsRepositoryPort.of might expect. - -// We'll use this Partial type for providing mocks. +// We'll use this Partial type for providing mocks for the repository. +type MockRepoPartial = Partial; const runEffectTest = ( - effectToRun: Effect.Effect, // The effect needs CatsService - // UPDATE: Use Partial - mockRepoPartialImpl: Partial = {}, // Default to empty mock + effectToRun: Effect.Effect, // The effect now needs CatsServicePort + mockRepoPartialImpl: MockRepoPartial = {}, // Default to empty mock ) => { - // Create a full mock implementation by merging partial mock with defaults that throw - // UPDATE: Use CatsRepository["Type"] + // Create a full mock implementation for the repository const fullMockImpl: CatsRepositoryPort["Type"] = { getAll: Effect.die("getAll not implemented in mock"), getById: (id: CatId) => Effect.die(`getById(${id}) not implemented in mock`), create: (name: string, breed: string, age: number) => - // Added types Effect.die(`create(${name}, ${breed}, ${age}) not implemented in mock`), update: (id: CatId, data: Partial>) => - // Added types Effect.die( `update(${id}, ${JSON.stringify(data)}) not implemented in mock`, ), @@ -39,27 +31,30 @@ const runEffectTest = ( ...mockRepoPartialImpl, // Override defaults with provided mocks }; - // This is the critical part: - // It assumes CatsRepositoryPort is a Tag for a service that can be constructed with CatsRepositoryPort.of() - // or if CatsRepositoryPort is Tag, then it should be CatsRepositoryPort (the Tag itself) - // and the second argument is the implementation (fullMockImpl). - // The instruction `CatsRepositoryPort.of(fullMockImpl)` implies CatsRepositoryPort is a class or object with `of`. - // Corrected to directly use fullMockImpl as CatsRepositoryPort is a Context.Tag + // Layer for the mock repository const mockCatsRepositoryLayer = Layer.succeed( CatsRepositoryPort, - CatsRepositoryPort.of(fullMockImpl), // Construct the service implementation + CatsRepositoryPort.of(fullMockImpl), + ); + + // Provide CatsApplicationServiceLive which depends on CatsRepositoryPort, + // and then provide the mock repository layer to CatsApplicationServiceLive. + // CatsApplicationServiceLive provides the implementation for CatsServicePort. + const testLayer = Layer.provide( + CatsApplicationServiceLive, // This provides CatsServicePort + mockCatsRepositoryLayer, // This provides CatsRepositoryPort to CatsApplicationServiceLive ); - const testLayer = Layer.provide(CatsServiceLive, mockCatsRepositoryLayer); + // Provide the testLayer (which includes the service and its mock dependency) to the effect to run. const providedEffect = Effect.provide(effectToRun, testLayer); return Effect.runPromise(providedEffect); }; -describe("CatsService (Refined)", () => { +describe("CatsApplicationService (using CatsServicePort)", () => { it("getAllCats should return an empty array when repository is empty", async () => { const testEffect = Effect.gen(function* (_) { - const service = yield* _(CatsService); + const service = yield* _(CatsServicePort); // Use CatsServicePort const cats = yield* _(service.getAllCats); assertEquals(cats.length, 0); }); @@ -86,7 +81,7 @@ describe("CatsService (Refined)", () => { ]; const testEffect = Effect.gen(function* (_) { - const service = yield* _(CatsService); + const service = yield* _(CatsServicePort); // Use CatsServicePort const cats = yield* _(service.getAllCats); assertEquals(cats, sampleCats); }); @@ -97,7 +92,7 @@ describe("CatsService (Refined)", () => { }); it("getCatById should return a cat when found", async () => { - const catId = Schema.decodeUnknownSync(CatId)(3); // Using casting for CatId + const catId = Schema.decodeUnknownSync(CatId)(3); const sampleCat = new Cat({ id: catId, name: "Felix", @@ -106,7 +101,7 @@ describe("CatsService (Refined)", () => { }); const testEffect = Effect.gen(function* (_) { - const service = yield* _(CatsService); + const service = yield* _(CatsServicePort); // Use CatsServicePort const cat = yield* _(service.getCatById(catId)); assertEquals(cat, sampleCat); }); @@ -120,16 +115,15 @@ describe("CatsService (Refined)", () => { }); it("getCatById should return CatNotFound error when cat is not found", async () => { - const nonExistentCatId = Schema.decodeUnknownSync(CatId)(99); // Using casting for CatId + const nonExistentCatId = Schema.decodeUnknownSync(CatId)(99); const testEffect = Effect.gen(function* (_) { - const service = yield* _(CatsService); + const service = yield* _(CatsServicePort); // Use CatsServicePort return yield* _(service.getCatById(nonExistentCatId)); }).pipe( Effect.match({ onFailure: (error) => { assertEquals(error._tag, "CatNotFound"); - // Ensure CatNotFound has an 'id' property if this assertion is to pass if (error._tag === "CatNotFound") { assertEquals((error as CatNotFound).id, nonExistentCatId); } else { @@ -149,7 +143,6 @@ describe("CatsService (Refined)", () => { ); await runEffectTest(testEffect, { - // Ensure new CatNotFound({id: ...}) matches the actual error structure from the domain getById: (_id: CatId) => Effect.fail(new CatNotFound({ id: nonExistentCatId })), }); diff --git a/packages/server/src/CatsApplicationService.ts b/packages/server/src/CatsApplicationService.ts new file mode 100644 index 0000000..3305502 --- /dev/null +++ b/packages/server/src/CatsApplicationService.ts @@ -0,0 +1,81 @@ +import { Cat, CatId, CatNotFound } from "@effect-cats/domain"; +import { Effect, Layer } from "effect"; +import { CatsRepositoryPort } from "./CatsRepositoryPort.ts"; +import { CatsServicePort, CatsServicePortType } from "./CatsServicePort.ts"; + +// Implement the service +export class CatsApplicationService implements CatsServicePortType { + constructor(private readonly repository: CatsRepositoryPort["Type"]) {} + + readonly getAllCats = Effect.logDebug("getAllCats called").pipe( + Effect.flatMap(() => this.repository.getAll), + Effect.tap((cats) => Effect.logInfo(`Retrieved ${cats.length} cats`)), + Effect.withSpan("CatsApplicationService/getAllCats"), + ); + + readonly getCatById = (id: CatId) => + Effect.logDebug(`getCatById called with id: ${id}`).pipe( + Effect.flatMap(() => this.repository.getById(id)), + Effect.tap((cat) => Effect.logInfo(`Retrieved cat: ${cat.name}`)), + Effect.tapErrorTag( + "CatNotFound", + (e) => Effect.logWarning(`Cat with id: ${e.id} not found`), + ), + Effect.withSpan("CatsApplicationService/getCatById", { + attributes: { "cat.id": id }, + }), + ); + + readonly createCat = (name: string, breed: string, age: number) => + Effect.logDebug(`createCat called with name: ${name}`).pipe( + Effect.flatMap(() => this.repository.create(name, breed, age)), + Effect.tap((cat) => + Effect.logInfo(`Created cat: ${cat.name} with id: ${cat.id}`) + ), + Effect.withSpan("CatsApplicationService/createCat", { + attributes: { + "cat.name": name, + "cat.breed": breed, + "cat.age": age, + }, + }), + ); + + readonly updateCat = (id: CatId, data: Partial>) => + Effect.logDebug(`updateCat called with id: ${id}`).pipe( + Effect.flatMap(() => this.repository.update(id, data)), + Effect.tap((cat) => Effect.logInfo(`Updated cat: ${cat.name}`)), + Effect.tapErrorTag( + "CatNotFound", + (e) => + Effect.logWarning(`Cat with id: ${e.id} not found during update`), + ), + Effect.withSpan("CatsApplicationService/updateCat", { + attributes: { "cat.id": id, "cat.updateData": true }, + }), + ); + + readonly deleteCat = (id: CatId) => + Effect.logDebug(`deleteCat called with id: ${id}`).pipe( + Effect.flatMap(() => this.repository.remove(id)), + Effect.tap(() => + Effect.logInfo(`Attempted to delete cat with id: ${id}`) + ), + Effect.tapErrorTag( + "CatNotFound", + (e) => + Effect.logWarning(`Cat with id: ${e.id} not found for deletion`), + ), + Effect.withSpan("CatsApplicationService/deleteCat", { + attributes: { "cat.id": id }, + }), + ); +} + +export const CatsApplicationServiceLive = Layer.effect( + CatsServicePort, // Provide for the CatsServicePort Tag + Effect.gen(function* (_) { + const repository = yield* _(CatsRepositoryPort); + return new CatsApplicationService(repository); + }), +); diff --git a/packages/server/src/CatsService.ts b/packages/server/src/CatsService.ts deleted file mode 100644 index 14979a9..0000000 --- a/packages/server/src/CatsService.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Cat, CatId, CatNotFound } from "@effect-cats/domain"; -import { Context, Effect, Layer } from "effect"; -import { CatsRepositoryPort } from "./CatsRepositoryPort.ts"; - -// Define the interface for our service -export class CatsService extends Context.Tag("Cats/Service")< - CatsService, - { - readonly getAllCats: Effect.Effect, never>; - readonly getCatById: (id: CatId) => Effect.Effect; - readonly createCat: ( - name: string, - breed: string, - age: number, - ) => Effect.Effect; - readonly updateCat: ( - id: CatId, - data: Partial>, - ) => Effect.Effect; - readonly deleteCat: (id: CatId) => Effect.Effect; - } ->() {} - -// Implement the service -export const CatsServiceLive = Layer.effect( - CatsService, - Effect.gen(function* (_) { - const repository = yield* _(CatsRepositoryPort); - - return { - getAllCats: Effect.logDebug("getAllCats called").pipe( - Effect.flatMap(() => repository.getAll), - Effect.tap((cats) => Effect.logInfo(`Retrieved ${cats.length} cats`)), - Effect.withSpan("CatsService/getAllCats"), - ), - getCatById: (id: CatId) => - Effect.logDebug(`getCatById called with id: ${id}`).pipe( - Effect.flatMap(() => repository.getById(id)), - Effect.tap((cat) => Effect.logInfo(`Retrieved cat: ${cat.name}`)), - Effect.tapErrorTag( - "CatNotFound", - (e) => Effect.logWarning(`Cat with id: ${e.id} not found`), - ), - Effect.withSpan("CatsService/getCatById", { - attributes: { "cat.id": id }, - }), - ), - createCat: (name: string, breed: string, age: number) => - Effect.logDebug(`createCat called with name: ${name}`).pipe( - Effect.flatMap(() => repository.create(name, breed, age)), - Effect.tap((cat) => - Effect.logInfo(`Created cat: ${cat.name} with id: ${cat.id}`) - ), - Effect.withSpan("CatsService/createCat", { - attributes: { - "cat.name": name, - "cat.breed": breed, - "cat.age": age, - }, - }), - ), - updateCat: (id: CatId, data: Partial>) => - Effect.logDebug(`updateCat called with id: ${id}`).pipe( - Effect.flatMap(() => repository.update(id, data)), - Effect.tap((cat) => Effect.logInfo(`Updated cat: ${cat.name}`)), - Effect.tapErrorTag( - "CatNotFound", - (e) => - Effect.logWarning(`Cat with id: ${e.id} not found during update`), - ), - Effect.withSpan("CatsService/updateCat", { - attributes: { "cat.id": id, "cat.updateData": true }, - }), - ), - deleteCat: (id: CatId) => - Effect.logDebug(`deleteCat called with id: ${id}`).pipe( - Effect.flatMap(() => repository.remove(id)), - Effect.tap(() => - Effect.logInfo(`Attempted to delete cat with id: ${id}`) - ), - Effect.tapErrorTag( - "CatNotFound", - (e) => - Effect.logWarning(`Cat with id: ${e.id} not found for deletion`), - ), - Effect.withSpan("CatsService/deleteCat", { - attributes: { "cat.id": id }, - }), - ), - }; - }), -); diff --git a/packages/server/src/CatsServicePort.ts b/packages/server/src/CatsServicePort.ts new file mode 100644 index 0000000..885a7df --- /dev/null +++ b/packages/server/src/CatsServicePort.ts @@ -0,0 +1,24 @@ +import { Cat, CatId, CatNotFound } from "@effect-cats/domain"; +import { Context, Effect } from "effect"; + +// Define the interface for our service (Inbound Port) +export interface CatsServicePortType { + readonly getAllCats: Effect.Effect, never>; + readonly getCatById: (id: CatId) => Effect.Effect; + readonly createCat: ( + name: string, + breed: string, + age: number, + ) => Effect.Effect; + readonly updateCat: ( + id: CatId, + data: Partial>, + ) => Effect.Effect; + readonly deleteCat: (id: CatId) => Effect.Effect; +} + +// Create a context tag for the service port +export class CatsServicePort extends Context.Tag("Cats/ServicePort")< + CatsServicePort, + CatsServicePortType +>() {} diff --git a/packages/server/src/main.ts b/packages/server/src/main.ts index 7627572..71c791c 100644 --- a/packages/server/src/main.ts +++ b/packages/server/src/main.ts @@ -7,7 +7,7 @@ import { import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; import { Config, Effect, Layer } from "effect"; -import { CatsServiceLive } from "./CatsService.ts"; // Import CatsServiceLive +import { CatsApplicationServiceLive } from "./CatsApplicationService.ts"; // Import CatsApplicationServiceLive import { CatsRepositoryAdapterInMemoryLive } from "./CatsRepositoryAdapter.ts"; import { catsApiLiveGroup } from "./CatsApiImpl.ts"; import { healthApiLiveGroup } from "./HealthApiImpl.ts"; @@ -15,7 +15,7 @@ import { api } from "@effect-cats/domain"; // Create a combined layer for the application services const AppLive = Layer.provide( - CatsServiceLive, + CatsApplicationServiceLive, // Use CatsApplicationServiceLive CatsRepositoryAdapterInMemoryLive, ); // This will be the main export for the server to build the API