Files
agentic-dev/docs/superpowers/plans/2026-04-06-plan-2-core-package.md
Danijel Martinek 25e533b6be Add Plan 2: Core Package + DI implementation plan
12 tasks covering: dependencies, vitest config, entities (models + errors),
application interfaces, DI container (InversifyJS), mock infrastructure,
auth use cases + tests (TDD), content use cases + tests (TDD),
auth controllers + tests, content controllers + tests, public API exports.
2026-04-06 14:21:30 +02:00

48 KiB

Plan 2: Core Package + DI — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement the @repo/core clean architecture package with entities, application interfaces, use cases, mock infrastructure, InversifyJS DI, controllers, and unit tests — all following TDD.

Architecture: Single @repo/core package with 5 layers (entities → application → infrastructure → interface-adapters → di). Dependencies point inward only. InversifyJS resolves interfaces to implementations at runtime. Mock implementations enable testing without external services. Use cases call getInjection() to resolve dependencies — they never import infrastructure directly.

Tech Stack: TypeScript 5.x, InversifyJS 6.x, reflect-metadata, Zod, Vitest


File Map

Entities Layer (zero deps)

File Responsibility
packages/core/src/entities/models/user.ts User Zod schema + type
packages/core/src/entities/models/article.ts Article Zod schema + type
packages/core/src/entities/models/session.ts Session Zod schema + type
packages/core/src/entities/models/cookie.ts Cookie type
packages/core/src/entities/models/index.ts Re-exports all models
packages/core/src/entities/errors/auth.ts AuthenticationError, UnauthenticatedError, UnauthorizedError
packages/core/src/entities/errors/common.ts NotFoundError, InputParseError
packages/core/src/entities/errors/index.ts Re-exports all errors
packages/core/src/entities/index.ts Re-exports models + errors

Application Layer (imports entities only)

File Responsibility
packages/core/src/application/repositories/users.repository.interface.ts IUsersRepository
packages/core/src/application/repositories/articles.repository.interface.ts IArticlesRepository
packages/core/src/application/repositories/index.ts Re-exports
packages/core/src/application/services/auth.service.interface.ts IAuthenticationService
packages/core/src/application/services/telemetry.service.interface.ts ITelemetryService
packages/core/src/application/services/index.ts Re-exports
packages/core/src/application/use-cases/auth/sign-in.use-case.ts Sign in logic
packages/core/src/application/use-cases/auth/sign-up.use-case.ts Sign up logic
packages/core/src/application/use-cases/auth/sign-out.use-case.ts Sign out logic
packages/core/src/application/use-cases/content/create-article.use-case.ts Create article
packages/core/src/application/use-cases/content/get-articles.use-case.ts Get articles

Infrastructure Layer (mock implementations)

File Responsibility
packages/core/src/infrastructure/repositories/mock-users.repository.ts In-memory users
packages/core/src/infrastructure/repositories/mock-articles.repository.ts In-memory articles
packages/core/src/infrastructure/services/mock-auth.service.ts Mock sessions
packages/core/src/infrastructure/services/mock-telemetry.service.ts No-op telemetry

Interface Adapters

File Responsibility
packages/core/src/interface-adapters/controllers/auth/sign-in.controller.ts Validate + delegate
packages/core/src/interface-adapters/controllers/auth/sign-up.controller.ts Validate + delegate
packages/core/src/interface-adapters/controllers/auth/sign-out.controller.ts Validate + delegate
packages/core/src/interface-adapters/controllers/content/articles.controller.ts Validate + delegate

DI

File Responsibility
packages/core/src/di/types.ts DI_SYMBOLS + DI_RETURN_TYPES
packages/core/src/di/modules/auth.module.ts Binds auth deps
packages/core/src/di/modules/content.module.ts Binds content deps
packages/core/src/di/container.ts InversifyJS container

Config + Tests

File Responsibility
packages/core/src/config.ts Constants (SESSION_COOKIE, etc.)
packages/core/vitest.config.ts Vitest config with path aliases
packages/core/src/index.ts Public API re-exports

Test files (co-located pattern from reference repo)

File Responsibility
packages/core/tests/unit/use-cases/auth/sign-in.use-case.test.ts Sign in tests
packages/core/tests/unit/use-cases/auth/sign-up.use-case.test.ts Sign up tests
packages/core/tests/unit/use-cases/auth/sign-out.use-case.test.ts Sign out tests
packages/core/tests/unit/use-cases/content/create-article.use-case.test.ts Create article tests
packages/core/tests/unit/use-cases/content/get-articles.use-case.test.ts Get articles tests
packages/core/tests/unit/controllers/auth/sign-in.controller.test.ts Sign in controller tests
packages/core/tests/unit/controllers/auth/sign-up.controller.test.ts Sign up controller tests
packages/core/tests/unit/controllers/auth/sign-out.controller.test.ts Sign out controller tests
packages/core/tests/unit/controllers/content/articles.controller.test.ts Articles controller tests

Task 1: Install dependencies

Files:

  • Modify: packages/core/package.json

  • Modify: packages/core/tsconfig.json

  • Step 1: Add dependencies to packages/core/package.json

{
  "name": "@repo/core",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "scripts": {
    "build": "tsc --noEmit",
    "lint": "eslint .",
    "test": "vitest run",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "inversify": "^6.2.0",
    "reflect-metadata": "^0.2.2",
    "zod": "^3.24.0"
  },
  "devDependencies": {
    "@repo/eslint-config": "workspace:*",
    "@repo/typescript-config": "workspace:*",
    "vitest": "^3.1.0"
  }
}
  • Step 2: Update tsconfig.json to include reflect-metadata
{
  "extends": "@repo/typescript-config/base.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    },
    "types": ["reflect-metadata"]
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}
  • Step 3: Run pnpm install from repo root

Run: pnpm install Expected: Installs inversify, reflect-metadata, zod, vitest. No errors.

  • Step 4: Commit
git add packages/core/package.json packages/core/tsconfig.json pnpm-lock.yaml
git commit -m "feat(core): add dependencies (inversify, reflect-metadata, zod, vitest)"

Task 2: Vitest config + constants

Files:

  • Create: packages/core/vitest.config.ts

  • Create: packages/core/src/config.ts

  • Step 1: Create vitest.config.ts

import { defineConfig } from "vitest/config";
import { fileURLToPath, URL } from "node:url";

export default defineConfig({
  test: {
    globals: true,
    coverage: {
      provider: "v8",
      reportsDirectory: "./tests/coverage",
    },
  },
  resolve: {
    alias: {
      "@": fileURLToPath(new URL("./src", import.meta.url)),
    },
  },
});
  • Step 2: Create src/config.ts
export const SESSION_COOKIE = "session";
  • Step 3: Commit
git add packages/core/vitest.config.ts packages/core/src/config.ts
git commit -m "feat(core): add vitest config and constants"

Task 3: Entities — models

Files:

  • Create: packages/core/src/entities/models/user.ts

  • Create: packages/core/src/entities/models/article.ts

  • Create: packages/core/src/entities/models/session.ts

  • Create: packages/core/src/entities/models/cookie.ts

  • Create: packages/core/src/entities/models/index.ts

  • Step 1: Create user.ts

import { z } from "zod";

export const userSchema = z.object({
  id: z.string(),
  username: z.string().min(3).max(31),
  passwordHash: z.string().min(6).max(255),
});

export type User = z.infer<typeof userSchema>;
  • Step 2: Create article.ts
import { z } from "zod";

export const articleStatusSchema = z.enum(["draft", "published"]);

export const articleSchema = z.object({
  id: z.string(),
  title: z.string().min(1).max(255),
  slug: z.string().min(1).max(255),
  content: z.string(),
  status: articleStatusSchema.default("draft"),
  authorId: z.string(),
  createdAt: z.date(),
  updatedAt: z.date(),
});

export type Article = z.infer<typeof articleSchema>;
export type ArticleStatus = z.infer<typeof articleStatusSchema>;
  • Step 3: Create session.ts
import { z } from "zod";

export const sessionSchema = z.object({
  id: z.string(),
  userId: z.string(),
  expiresAt: z.date(),
});

export type Session = z.infer<typeof sessionSchema>;
  • Step 4: Create cookie.ts
type CookieAttributes = {
  secure?: boolean;
  path?: string;
  domain?: string;
  sameSite?: "lax" | "strict" | "none";
  httpOnly?: boolean;
  maxAge?: number;
  expires?: Date;
};

export type Cookie = {
  name: string;
  value: string;
  attributes: CookieAttributes;
};
  • Step 5: Create index.ts
export { userSchema, type User } from "./user.js";
export {
  articleSchema,
  articleStatusSchema,
  type Article,
  type ArticleStatus,
} from "./article.js";
export { sessionSchema, type Session } from "./session.js";
export type { Cookie } from "./cookie.js";
  • Step 6: Commit
git add packages/core/src/entities/models/
git commit -m "feat(core): add entity models (user, article, session, cookie)"

Task 4: Entities — errors

Files:

  • Create: packages/core/src/entities/errors/auth.ts

  • Create: packages/core/src/entities/errors/common.ts

  • Create: packages/core/src/entities/errors/index.ts

  • Create: packages/core/src/entities/index.ts

  • Step 1: Create auth.ts

export class AuthenticationError extends Error {
  constructor(message: string, options?: ErrorOptions) {
    super(message, options);
  }
}

export class UnauthenticatedError extends Error {
  constructor(message: string, options?: ErrorOptions) {
    super(message, options);
  }
}

export class UnauthorizedError extends Error {
  constructor(message: string, options?: ErrorOptions) {
    super(message, options);
  }
}
  • Step 2: Create common.ts
export class NotFoundError extends Error {
  constructor(message: string, options?: ErrorOptions) {
    super(message, options);
  }
}

export class InputParseError extends Error {
  constructor(message: string, options?: ErrorOptions) {
    super(message, options);
  }
}
  • Step 3: Create errors/index.ts
export {
  AuthenticationError,
  UnauthenticatedError,
  UnauthorizedError,
} from "./auth.js";
export { NotFoundError, InputParseError } from "./common.js";
  • Step 4: Create entities/index.ts
export * from "./models/index.js";
export * from "./errors/index.js";
  • Step 5: Commit
git add packages/core/src/entities/
git commit -m "feat(core): add entity errors (auth, common)"

Task 5: Application — repository and service interfaces

Files:

  • Create: packages/core/src/application/repositories/users.repository.interface.ts

  • Create: packages/core/src/application/repositories/articles.repository.interface.ts

  • Create: packages/core/src/application/repositories/index.ts

  • Create: packages/core/src/application/services/auth.service.interface.ts

  • Create: packages/core/src/application/services/telemetry.service.interface.ts

  • Create: packages/core/src/application/services/index.ts

  • Step 1: Create users.repository.interface.ts

import type { User } from "@/entities/models/user.js";

export interface IUsersRepository {
  getUser(id: string): Promise<User | undefined>;
  getUserByUsername(username: string): Promise<User | undefined>;
  createUser(input: User): Promise<User>;
}
  • Step 2: Create articles.repository.interface.ts
import type { Article } from "@/entities/models/article.js";

export interface IArticlesRepository {
  getArticle(id: string): Promise<Article | undefined>;
  getArticles(options?: {
    status?: string;
    authorId?: string;
    limit?: number;
    offset?: number;
  }): Promise<Article[]>;
  createArticle(input: Article): Promise<Article>;
  updateArticle(id: string, input: Partial<Article>): Promise<Article | undefined>;
}
  • Step 3: Create repositories/index.ts
export type { IUsersRepository } from "./users.repository.interface.js";
export type { IArticlesRepository } from "./articles.repository.interface.js";
  • Step 4: Create auth.service.interface.ts
import type { Cookie } from "@/entities/models/cookie.js";
import type { Session } from "@/entities/models/session.js";
import type { User } from "@/entities/models/user.js";

export interface IAuthenticationService {
  generateUserId(): string;
  hashPassword(password: string): Promise<string>;
  verifyPassword(hash: string, password: string): Promise<boolean>;
  validateSession(
    sessionId: string
  ): Promise<{ user: User; session: Session }>;
  createSession(user: User): Promise<{ session: Session; cookie: Cookie }>;
  invalidateSession(sessionId: string): Promise<{ blankCookie: Cookie }>;
}
  • Step 5: Create telemetry.service.interface.ts
export interface ITelemetryService {
  startSpan<T>(name: string, fn: () => T | Promise<T>): Promise<T>;
}
  • Step 6: Create services/index.ts
export type { IAuthenticationService } from "./auth.service.interface.js";
export type { ITelemetryService } from "./telemetry.service.interface.js";
  • Step 7: Commit
git add packages/core/src/application/
git commit -m "feat(core): add application interfaces (repositories + services)"

Task 6: DI — types, modules, container

Files:

  • Create: packages/core/src/di/types.ts

  • Create: packages/core/src/di/modules/auth.module.ts

  • Create: packages/core/src/di/modules/content.module.ts

  • Create: packages/core/src/di/container.ts

  • Step 1: Create di/types.ts

import type { IAuthenticationService } from "@/application/services/auth.service.interface.js";
import type { ITelemetryService } from "@/application/services/telemetry.service.interface.js";
import type { IUsersRepository } from "@/application/repositories/users.repository.interface.js";
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface.js";

export const DI_SYMBOLS = {
  IAuthenticationService: Symbol.for("IAuthenticationService"),
  ITelemetryService: Symbol.for("ITelemetryService"),
  IUsersRepository: Symbol.for("IUsersRepository"),
  IArticlesRepository: Symbol.for("IArticlesRepository"),
};

export interface DI_RETURN_TYPES {
  IAuthenticationService: IAuthenticationService;
  ITelemetryService: ITelemetryService;
  IUsersRepository: IUsersRepository;
  IArticlesRepository: IArticlesRepository;
}
  • Step 2: Create di/modules/auth.module.ts
import { ContainerModule, interfaces } from "inversify";

import type { IUsersRepository } from "@/application/repositories/users.repository.interface.js";
import type { IAuthenticationService } from "@/application/services/auth.service.interface.js";
import { MockUsersRepository } from "@/infrastructure/repositories/mock-users.repository.js";
import { MockAuthenticationService } from "@/infrastructure/services/mock-auth.service.js";
import { DI_SYMBOLS } from "../types.js";

const initializeModule = (bind: interfaces.Bind) => {
  bind<IUsersRepository>(DI_SYMBOLS.IUsersRepository).to(MockUsersRepository);
  bind<IAuthenticationService>(DI_SYMBOLS.IAuthenticationService).to(
    MockAuthenticationService
  );
};

export const AuthModule = new ContainerModule(initializeModule);
  • Step 3: Create di/modules/content.module.ts
import { ContainerModule, interfaces } from "inversify";

import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface.js";
import { MockArticlesRepository } from "@/infrastructure/repositories/mock-articles.repository.js";
import { DI_SYMBOLS } from "../types.js";

const initializeModule = (bind: interfaces.Bind) => {
  bind<IArticlesRepository>(DI_SYMBOLS.IArticlesRepository).to(
    MockArticlesRepository
  );
};

export const ContentModule = new ContainerModule(initializeModule);
  • Step 4: Create di/container.ts
import "reflect-metadata";
import { Container } from "inversify";

import { AuthModule } from "./modules/auth.module.js";
import { ContentModule } from "./modules/content.module.js";
import { DI_RETURN_TYPES, DI_SYMBOLS } from "./types.js";

const ApplicationContainer = new Container({
  defaultScope: "Singleton",
});

export const initializeContainer = () => {
  ApplicationContainer.load(AuthModule);
  ApplicationContainer.load(ContentModule);
};

export const destroyContainer = () => {
  ApplicationContainer.unload(AuthModule);
  ApplicationContainer.unload(ContentModule);
};

if (process.env.NODE_ENV !== "test") {
  initializeContainer();
}

export function getInjection<K extends keyof typeof DI_SYMBOLS>(
  symbol: K
): DI_RETURN_TYPES[K] {
  return ApplicationContainer.get(DI_SYMBOLS[symbol]);
}

export { ApplicationContainer };
  • Step 5: Commit
git add packages/core/src/di/
git commit -m "feat(core): add InversifyJS DI container with auth and content modules"

Task 7: Infrastructure — mock implementations

Files:

  • Create: packages/core/src/infrastructure/repositories/mock-users.repository.ts

  • Create: packages/core/src/infrastructure/repositories/mock-articles.repository.ts

  • Create: packages/core/src/infrastructure/services/mock-auth.service.ts

  • Create: packages/core/src/infrastructure/services/mock-telemetry.service.ts

  • Step 1: Create mock-users.repository.ts

import { injectable } from "inversify";

import type { IUsersRepository } from "@/application/repositories/users.repository.interface.js";
import type { User } from "@/entities/models/user.js";

@injectable()
export class MockUsersRepository implements IUsersRepository {
  private _users: User[] = [
    { id: "1", username: "alice", passwordHash: "hashed_password_alice" },
    { id: "2", username: "bob", passwordHash: "hashed_password_bob" },
  ];

  async getUser(id: string): Promise<User | undefined> {
    return this._users.find((u) => u.id === id);
  }

  async getUserByUsername(username: string): Promise<User | undefined> {
    return this._users.find((u) => u.username === username);
  }

  async createUser(input: User): Promise<User> {
    this._users.push(input);
    return input;
  }
}
  • Step 2: Create mock-articles.repository.ts
import { injectable } from "inversify";

import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface.js";
import type { Article } from "@/entities/models/article.js";

@injectable()
export class MockArticlesRepository implements IArticlesRepository {
  private _articles: Article[] = [];

  async getArticle(id: string): Promise<Article | undefined> {
    return this._articles.find((a) => a.id === id);
  }

  async getArticles(options?: {
    status?: string;
    authorId?: string;
    limit?: number;
    offset?: number;
  }): Promise<Article[]> {
    let result = [...this._articles];
    if (options?.status) {
      result = result.filter((a) => a.status === options.status);
    }
    if (options?.authorId) {
      result = result.filter((a) => a.authorId === options.authorId);
    }
    const offset = options?.offset ?? 0;
    const limit = options?.limit ?? 50;
    return result.slice(offset, offset + limit);
  }

  async createArticle(input: Article): Promise<Article> {
    this._articles.push(input);
    return input;
  }

  async updateArticle(
    id: string,
    input: Partial<Article>
  ): Promise<Article | undefined> {
    const index = this._articles.findIndex((a) => a.id === id);
    if (index === -1) return undefined;
    this._articles[index] = { ...this._articles[index]!, ...input };
    return this._articles[index];
  }
}
  • Step 3: Create mock-auth.service.ts
import { inject, injectable } from "inversify";

import type { IAuthenticationService } from "@/application/services/auth.service.interface.js";
import type { IUsersRepository } from "@/application/repositories/users.repository.interface.js";
import { UnauthenticatedError } from "@/entities/errors/auth.js";
import { sessionSchema, type Session } from "@/entities/models/session.js";
import type { Cookie } from "@/entities/models/cookie.js";
import type { User } from "@/entities/models/user.js";
import { DI_SYMBOLS } from "@/di/types.js";
import { SESSION_COOKIE } from "@/config.js";

@injectable()
export class MockAuthenticationService implements IAuthenticationService {
  private _sessions: Record<string, { session: Session; user: User }> = {};

  constructor(
    @inject(DI_SYMBOLS.IUsersRepository)
    private _usersRepository: IUsersRepository
  ) {}

  generateUserId(): string {
    return (Math.random() + 1).toString(36).substring(7);
  }

  async hashPassword(password: string): Promise<string> {
    return `hashed_${password}`;
  }

  async verifyPassword(hash: string, password: string): Promise<boolean> {
    return hash === `hashed_${password}`;
  }

  async validateSession(
    sessionId: string
  ): Promise<{ user: User; session: Session }> {
    const result = this._sessions[sessionId];
    if (!result) {
      throw new UnauthenticatedError("Unauthenticated");
    }
    const user = await this._usersRepository.getUser(result.user.id);
    if (!user) {
      throw new UnauthenticatedError("Unauthenticated");
    }
    return { user, session: result.session };
  }

  async createSession(
    user: User
  ): Promise<{ session: Session; cookie: Cookie }> {
    const session = sessionSchema.parse({
      id: "session_" + user.id,
      userId: user.id,
      expiresAt: new Date(Date.now() + 86400000 * 7),
    });
    const cookie: Cookie = {
      name: SESSION_COOKIE,
      value: session.id,
      attributes: {},
    };
    this._sessions[session.id] = { session, user };
    return { session, cookie };
  }

  async invalidateSession(
    sessionId: string
  ): Promise<{ blankCookie: Cookie }> {
    delete this._sessions[sessionId];
    return {
      blankCookie: { name: SESSION_COOKIE, value: "", attributes: {} },
    };
  }
}
  • Step 4: Create mock-telemetry.service.ts
import { injectable } from "inversify";

import type { ITelemetryService } from "@/application/services/telemetry.service.interface.js";

@injectable()
export class MockTelemetryService implements ITelemetryService {
  async startSpan<T>(_name: string, fn: () => T | Promise<T>): Promise<T> {
    return fn();
  }
}
  • Step 5: Commit
git add packages/core/src/infrastructure/
git commit -m "feat(core): add mock implementations (users, articles, auth, telemetry)"

Task 8: Auth use cases + tests (TDD)

Files:

  • Create: packages/core/src/application/use-cases/auth/sign-in.use-case.ts

  • Create: packages/core/src/application/use-cases/auth/sign-up.use-case.ts

  • Create: packages/core/src/application/use-cases/auth/sign-out.use-case.ts

  • Create: packages/core/tests/unit/use-cases/auth/sign-in.use-case.test.ts

  • Create: packages/core/tests/unit/use-cases/auth/sign-up.use-case.test.ts

  • Create: packages/core/tests/unit/use-cases/auth/sign-out.use-case.test.ts

  • Step 1: Write sign-in test

// packages/core/tests/unit/use-cases/auth/sign-in.use-case.test.ts
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import {
  destroyContainer,
  initializeContainer,
} from "@/di/container.js";
import { signInUseCase } from "@/application/use-cases/auth/sign-in.use-case.js";
import { AuthenticationError } from "@/entities/errors/auth.js";

beforeEach(() => {
  initializeContainer();
});

afterEach(() => {
  destroyContainer();
});

describe("signInUseCase", () => {
  it("returns session and cookie for valid credentials", async () => {
    const result = await signInUseCase({
      username: "alice",
      password: "password_alice",
    });
    expect(result).toHaveProperty("session");
    expect(result).toHaveProperty("cookie");
    expect(result.session.userId).toBe("1");
  });

  it("throws AuthenticationError for non-existing user", async () => {
    await expect(
      signInUseCase({ username: "non-existing", password: "any" })
    ).rejects.toBeInstanceOf(AuthenticationError);
  });

  it("throws AuthenticationError for wrong password", async () => {
    await expect(
      signInUseCase({ username: "alice", password: "wrong" })
    ).rejects.toBeInstanceOf(AuthenticationError);
  });
});
  • Step 2: Run test to verify it fails

Run: cd packages/core && pnpm vitest run tests/unit/use-cases/auth/sign-in.use-case.test.ts Expected: FAIL — module not found

  • Step 3: Implement sign-in use case
// packages/core/src/application/use-cases/auth/sign-in.use-case.ts
import { AuthenticationError } from "@/entities/errors/auth.js";
import type { Cookie } from "@/entities/models/cookie.js";
import type { Session } from "@/entities/models/session.js";
import { getInjection } from "@/di/container.js";

export async function signInUseCase(input: {
  username: string;
  password: string;
}): Promise<{ session: Session; cookie: Cookie }> {
  const usersRepository = getInjection("IUsersRepository");
  const authService = getInjection("IAuthenticationService");

  const existingUser = await usersRepository.getUserByUsername(input.username);
  if (!existingUser) {
    throw new AuthenticationError("User does not exist");
  }

  const validPassword = await authService.verifyPassword(
    existingUser.passwordHash,
    input.password
  );
  if (!validPassword) {
    throw new AuthenticationError("Incorrect username or password");
  }

  return await authService.createSession(existingUser);
}
  • Step 4: Run test to verify it passes

Run: cd packages/core && pnpm vitest run tests/unit/use-cases/auth/sign-in.use-case.test.ts Expected: PASS (3 tests)

  • Step 5: Write sign-up test
// packages/core/tests/unit/use-cases/auth/sign-up.use-case.test.ts
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import {
  destroyContainer,
  initializeContainer,
} from "@/di/container.js";
import { signUpUseCase } from "@/application/use-cases/auth/sign-up.use-case.js";
import { AuthenticationError } from "@/entities/errors/auth.js";

beforeEach(() => {
  initializeContainer();
});

afterEach(() => {
  destroyContainer();
});

describe("signUpUseCase", () => {
  it("creates user and returns session, cookie, and user info", async () => {
    const result = await signUpUseCase({
      username: "newuser",
      password: "securepassword",
    });
    expect(result).toHaveProperty("session");
    expect(result).toHaveProperty("cookie");
    expect(result).toHaveProperty("user");
    expect(result.user.username).toBe("newuser");
  });

  it("throws AuthenticationError if username is taken", async () => {
    await expect(
      signUpUseCase({ username: "alice", password: "anypassword" })
    ).rejects.toBeInstanceOf(AuthenticationError);
  });
});
  • Step 6: Implement sign-up use case
// packages/core/src/application/use-cases/auth/sign-up.use-case.ts
import { AuthenticationError } from "@/entities/errors/auth.js";
import type { Cookie } from "@/entities/models/cookie.js";
import type { Session } from "@/entities/models/session.js";
import type { User } from "@/entities/models/user.js";
import { getInjection } from "@/di/container.js";

export async function signUpUseCase(input: {
  username: string;
  password: string;
}): Promise<{
  session: Session;
  cookie: Cookie;
  user: Pick<User, "id" | "username">;
}> {
  const usersRepository = getInjection("IUsersRepository");
  const authService = getInjection("IAuthenticationService");

  const existingUser = await usersRepository.getUserByUsername(input.username);
  if (existingUser) {
    throw new AuthenticationError("Username taken");
  }

  const passwordHash = await authService.hashPassword(input.password);
  const userId = authService.generateUserId();

  const newUser = await usersRepository.createUser({
    id: userId,
    username: input.username,
    passwordHash,
  });

  const { cookie, session } = await authService.createSession(newUser);

  return {
    cookie,
    session,
    user: { id: newUser.id, username: newUser.username },
  };
}
  • Step 7: Run sign-up test

Run: cd packages/core && pnpm vitest run tests/unit/use-cases/auth/sign-up.use-case.test.ts Expected: PASS (2 tests)

  • Step 8: Write sign-out test
// packages/core/tests/unit/use-cases/auth/sign-out.use-case.test.ts
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import {
  destroyContainer,
  initializeContainer,
} from "@/di/container.js";
import { signOutUseCase } from "@/application/use-cases/auth/sign-out.use-case.js";

beforeEach(() => {
  initializeContainer();
});

afterEach(() => {
  destroyContainer();
});

describe("signOutUseCase", () => {
  it("returns a blank cookie", async () => {
    const result = await signOutUseCase("some-session-id");
    expect(result).toHaveProperty("blankCookie");
    expect(result.blankCookie.value).toBe("");
  });
});
  • Step 9: Implement sign-out use case
// packages/core/src/application/use-cases/auth/sign-out.use-case.ts
import type { Cookie } from "@/entities/models/cookie.js";
import { getInjection } from "@/di/container.js";

export async function signOutUseCase(
  sessionId: string
): Promise<{ blankCookie: Cookie }> {
  const authService = getInjection("IAuthenticationService");
  return await authService.invalidateSession(sessionId);
}
  • Step 10: Run all auth tests

Run: cd packages/core && pnpm vitest run tests/unit/use-cases/auth/ Expected: PASS (6 tests total)

  • Step 11: Commit
git add packages/core/src/application/use-cases/auth/ packages/core/tests/
git commit -m "feat(core): add auth use cases with tests (sign-in, sign-up, sign-out)"

Task 9: Content use cases + tests (TDD)

Files:

  • Create: packages/core/src/application/use-cases/content/create-article.use-case.ts

  • Create: packages/core/src/application/use-cases/content/get-articles.use-case.ts

  • Create: packages/core/tests/unit/use-cases/content/create-article.use-case.test.ts

  • Create: packages/core/tests/unit/use-cases/content/get-articles.use-case.test.ts

  • Step 1: Write create-article test

// packages/core/tests/unit/use-cases/content/create-article.use-case.test.ts
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import {
  destroyContainer,
  initializeContainer,
} from "@/di/container.js";
import { createArticleUseCase } from "@/application/use-cases/content/create-article.use-case.js";

beforeEach(() => {
  initializeContainer();
});

afterEach(() => {
  destroyContainer();
});

describe("createArticleUseCase", () => {
  it("creates an article with generated slug and draft status", async () => {
    const result = await createArticleUseCase({
      title: "My First Article",
      content: "Hello world",
      authorId: "1",
    });
    expect(result.title).toBe("My First Article");
    expect(result.slug).toBe("my-first-article");
    expect(result.status).toBe("draft");
    expect(result.authorId).toBe("1");
    expect(result.id).toBeDefined();
  });

  it("uses provided slug if given", async () => {
    const result = await createArticleUseCase({
      title: "Another Article",
      content: "Content here",
      authorId: "1",
      slug: "custom-slug",
    });
    expect(result.slug).toBe("custom-slug");
  });
});
  • Step 2: Implement create-article use case
// packages/core/src/application/use-cases/content/create-article.use-case.ts
import type { Article } from "@/entities/models/article.js";
import { getInjection } from "@/di/container.js";

function generateSlug(title: string): string {
  return title
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "");
}

export async function createArticleUseCase(input: {
  title: string;
  content: string;
  authorId: string;
  slug?: string;
}): Promise<Article> {
  const articlesRepository = getInjection("IArticlesRepository");

  const now = new Date();
  const article: Article = {
    id: crypto.randomUUID(),
    title: input.title,
    slug: input.slug ?? generateSlug(input.title),
    content: input.content,
    status: "draft",
    authorId: input.authorId,
    createdAt: now,
    updatedAt: now,
  };

  return await articlesRepository.createArticle(article);
}
  • Step 3: Run create-article test

Run: cd packages/core && pnpm vitest run tests/unit/use-cases/content/create-article.use-case.test.ts Expected: PASS (2 tests)

  • Step 4: Write get-articles test
// packages/core/tests/unit/use-cases/content/get-articles.use-case.test.ts
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import {
  destroyContainer,
  initializeContainer,
} from "@/di/container.js";
import { createArticleUseCase } from "@/application/use-cases/content/create-article.use-case.js";
import { getArticlesUseCase } from "@/application/use-cases/content/get-articles.use-case.js";

beforeEach(() => {
  initializeContainer();
});

afterEach(() => {
  destroyContainer();
});

describe("getArticlesUseCase", () => {
  it("returns empty array when no articles exist", async () => {
    const result = await getArticlesUseCase();
    expect(result).toEqual([]);
  });

  it("returns created articles", async () => {
    await createArticleUseCase({
      title: "Article One",
      content: "Content one",
      authorId: "1",
    });
    await createArticleUseCase({
      title: "Article Two",
      content: "Content two",
      authorId: "1",
    });

    const result = await getArticlesUseCase();
    expect(result).toHaveLength(2);
  });

  it("filters by status", async () => {
    await createArticleUseCase({
      title: "Draft Article",
      content: "Draft",
      authorId: "1",
    });

    const result = await getArticlesUseCase({ status: "published" });
    expect(result).toHaveLength(0);
  });
});
  • Step 5: Implement get-articles use case
// packages/core/src/application/use-cases/content/get-articles.use-case.ts
import type { Article } from "@/entities/models/article.js";
import { getInjection } from "@/di/container.js";

export async function getArticlesUseCase(options?: {
  status?: string;
  authorId?: string;
  limit?: number;
  offset?: number;
}): Promise<Article[]> {
  const articlesRepository = getInjection("IArticlesRepository");
  return await articlesRepository.getArticles(options);
}
  • Step 6: Run all content tests

Run: cd packages/core && pnpm vitest run tests/unit/use-cases/content/ Expected: PASS (5 tests total)

  • Step 7: Commit
git add packages/core/src/application/use-cases/content/ packages/core/tests/unit/use-cases/content/
git commit -m "feat(core): add content use cases with tests (create-article, get-articles)"

Task 10: Auth controllers + tests (TDD)

Files:

  • Create: packages/core/src/interface-adapters/controllers/auth/sign-in.controller.ts

  • Create: packages/core/src/interface-adapters/controllers/auth/sign-up.controller.ts

  • Create: packages/core/src/interface-adapters/controllers/auth/sign-out.controller.ts

  • Create: packages/core/tests/unit/controllers/auth/sign-in.controller.test.ts

  • Create: packages/core/tests/unit/controllers/auth/sign-up.controller.test.ts

  • Create: packages/core/tests/unit/controllers/auth/sign-out.controller.test.ts

  • Step 1: Write sign-in controller test

// packages/core/tests/unit/controllers/auth/sign-in.controller.test.ts
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import {
  destroyContainer,
  initializeContainer,
} from "@/di/container.js";
import { signInController } from "@/interface-adapters/controllers/auth/sign-in.controller.js";
import { InputParseError } from "@/entities/errors/common.js";
import { AuthenticationError } from "@/entities/errors/auth.js";

beforeEach(() => {
  initializeContainer();
});

afterEach(() => {
  destroyContainer();
});

describe("signInController", () => {
  it("returns cookie for valid input", async () => {
    const cookie = await signInController({
      username: "alice",
      password: "password_alice",
    });
    expect(cookie).toHaveProperty("name");
    expect(cookie).toHaveProperty("value");
  });

  it("throws InputParseError for invalid input", async () => {
    await expect(
      signInController({ username: "ab", password: "short" })
    ).rejects.toBeInstanceOf(InputParseError);
  });

  it("throws AuthenticationError for wrong credentials", async () => {
    await expect(
      signInController({ username: "alice", password: "wrongpassword" })
    ).rejects.toBeInstanceOf(AuthenticationError);
  });
});
  • Step 2: Implement sign-in controller
// packages/core/src/interface-adapters/controllers/auth/sign-in.controller.ts
import { z } from "zod";

import { InputParseError } from "@/entities/errors/common.js";
import type { Cookie } from "@/entities/models/cookie.js";
import { signInUseCase } from "@/application/use-cases/auth/sign-in.use-case.js";

const inputSchema = z.object({
  username: z.string().min(3).max(31),
  password: z.string().min(6).max(255),
});

export async function signInController(
  input: Partial<z.infer<typeof inputSchema>>
): Promise<Cookie> {
  const { data, error: inputParseError } = inputSchema.safeParse(input);

  if (inputParseError) {
    throw new InputParseError("Invalid data", { cause: inputParseError });
  }

  const { cookie } = await signInUseCase(data);
  return cookie;
}
  • Step 3: Run sign-in controller test

Run: cd packages/core && pnpm vitest run tests/unit/controllers/auth/sign-in.controller.test.ts Expected: PASS (3 tests)

  • Step 4: Write sign-up controller test
// packages/core/tests/unit/controllers/auth/sign-up.controller.test.ts
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import {
  destroyContainer,
  initializeContainer,
} from "@/di/container.js";
import { signUpController } from "@/interface-adapters/controllers/auth/sign-up.controller.js";
import { InputParseError } from "@/entities/errors/common.js";

beforeEach(() => {
  initializeContainer();
});

afterEach(() => {
  destroyContainer();
});

describe("signUpController", () => {
  it("returns session, cookie, and user for valid input", async () => {
    const result = await signUpController({
      username: "newuser",
      password: "securepassword",
      confirmPassword: "securepassword",
    });
    expect(result).toHaveProperty("session");
    expect(result).toHaveProperty("cookie");
    expect(result).toHaveProperty("user");
  });

  it("throws InputParseError when passwords don't match", async () => {
    await expect(
      signUpController({
        username: "newuser",
        password: "password1",
        confirmPassword: "password2",
      })
    ).rejects.toBeInstanceOf(InputParseError);
  });

  it("throws InputParseError for missing fields", async () => {
    await expect(signUpController({})).rejects.toBeInstanceOf(InputParseError);
  });
});
  • Step 5: Implement sign-up controller
// packages/core/src/interface-adapters/controllers/auth/sign-up.controller.ts
import { z } from "zod";

import { InputParseError } from "@/entities/errors/common.js";
import { signUpUseCase } from "@/application/use-cases/auth/sign-up.use-case.js";

const inputSchema = z
  .object({
    username: z.string().min(3).max(31),
    password: z.string().min(6).max(255),
    confirmPassword: z.string().min(6).max(255),
  })
  .superRefine(({ password, confirmPassword }, ctx) => {
    if (confirmPassword !== password) {
      ctx.addIssue({
        code: "custom",
        message: "The passwords did not match",
        path: ["password"],
      });
      ctx.addIssue({
        code: "custom",
        message: "The passwords did not match",
        path: ["confirmPassword"],
      });
    }
  });

export async function signUpController(
  input: Partial<z.infer<typeof inputSchema>>
): Promise<ReturnType<typeof signUpUseCase>> {
  const { data, error: inputParseError } = inputSchema.safeParse(input);

  if (inputParseError) {
    throw new InputParseError("Invalid data", { cause: inputParseError });
  }

  return await signUpUseCase(data);
}
  • Step 6: Run sign-up controller test

Run: cd packages/core && pnpm vitest run tests/unit/controllers/auth/sign-up.controller.test.ts Expected: PASS (3 tests)

  • Step 7: Write sign-out controller test
// packages/core/tests/unit/controllers/auth/sign-out.controller.test.ts
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import {
  destroyContainer,
  initializeContainer,
} from "@/di/container.js";
import { signOutController } from "@/interface-adapters/controllers/auth/sign-out.controller.js";
import { InputParseError } from "@/entities/errors/common.js";

beforeEach(() => {
  initializeContainer();
});

afterEach(() => {
  destroyContainer();
});

describe("signOutController", () => {
  it("returns blank cookie for valid session", async () => {
    const cookie = await signOutController("some-session-id");
    expect(cookie.value).toBe("");
  });

  it("throws InputParseError when no session ID provided", async () => {
    await expect(signOutController(undefined)).rejects.toBeInstanceOf(
      InputParseError
    );
  });
});
  • Step 8: Implement sign-out controller
// packages/core/src/interface-adapters/controllers/auth/sign-out.controller.ts
import { InputParseError } from "@/entities/errors/common.js";
import type { Cookie } from "@/entities/models/cookie.js";
import { signOutUseCase } from "@/application/use-cases/auth/sign-out.use-case.js";

export async function signOutController(
  sessionId: string | undefined
): Promise<Cookie> {
  if (!sessionId) {
    throw new InputParseError("Must provide a session ID");
  }

  const { blankCookie } = await signOutUseCase(sessionId);
  return blankCookie;
}
  • Step 9: Run all auth controller tests

Run: cd packages/core && pnpm vitest run tests/unit/controllers/auth/ Expected: PASS (8 tests total)

  • Step 10: Commit
git add packages/core/src/interface-adapters/controllers/auth/ packages/core/tests/unit/controllers/auth/
git commit -m "feat(core): add auth controllers with tests (sign-in, sign-up, sign-out)"

Task 11: Content controller + tests (TDD)

Files:

  • Create: packages/core/src/interface-adapters/controllers/content/articles.controller.ts

  • Create: packages/core/tests/unit/controllers/content/articles.controller.test.ts

  • Step 1: Write articles controller test

// packages/core/tests/unit/controllers/content/articles.controller.test.ts
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import {
  destroyContainer,
  initializeContainer,
} from "@/di/container.js";
import {
  createArticleController,
  getArticlesController,
} from "@/interface-adapters/controllers/content/articles.controller.js";
import { InputParseError } from "@/entities/errors/common.js";

beforeEach(() => {
  initializeContainer();
});

afterEach(() => {
  destroyContainer();
});

describe("createArticleController", () => {
  it("creates an article with valid input", async () => {
    const result = await createArticleController({
      title: "Test Article",
      content: "Some content",
      authorId: "1",
    });
    expect(result.title).toBe("Test Article");
    expect(result.slug).toBe("test-article");
  });

  it("throws InputParseError for missing title", async () => {
    await expect(
      createArticleController({ content: "content", authorId: "1" } as any)
    ).rejects.toBeInstanceOf(InputParseError);
  });
});

describe("getArticlesController", () => {
  it("returns articles", async () => {
    await createArticleController({
      title: "Article",
      content: "Content",
      authorId: "1",
    });
    const result = await getArticlesController({});
    expect(result).toHaveLength(1);
  });
});
  • Step 2: Implement articles controller
// packages/core/src/interface-adapters/controllers/content/articles.controller.ts
import { z } from "zod";

import { InputParseError } from "@/entities/errors/common.js";
import type { Article } from "@/entities/models/article.js";
import { createArticleUseCase } from "@/application/use-cases/content/create-article.use-case.js";
import { getArticlesUseCase } from "@/application/use-cases/content/get-articles.use-case.js";

const createInputSchema = z.object({
  title: z.string().min(1).max(255),
  content: z.string(),
  authorId: z.string(),
  slug: z.string().optional(),
});

const getInputSchema = z.object({
  status: z.string().optional(),
  authorId: z.string().optional(),
  limit: z.number().optional(),
  offset: z.number().optional(),
});

export async function createArticleController(
  input: Partial<z.infer<typeof createInputSchema>>
): Promise<Article> {
  const { data, error: inputParseError } = createInputSchema.safeParse(input);

  if (inputParseError) {
    throw new InputParseError("Invalid data", { cause: inputParseError });
  }

  return await createArticleUseCase(data);
}

export async function getArticlesController(
  input: Partial<z.infer<typeof getInputSchema>>
): Promise<Article[]> {
  const { data, error: inputParseError } = getInputSchema.safeParse(input);

  if (inputParseError) {
    throw new InputParseError("Invalid data", { cause: inputParseError });
  }

  return await getArticlesUseCase(data);
}
  • Step 3: Run content controller tests

Run: cd packages/core && pnpm vitest run tests/unit/controllers/content/ Expected: PASS (3 tests)

  • Step 4: Commit
git add packages/core/src/interface-adapters/controllers/content/ packages/core/tests/unit/controllers/content/
git commit -m "feat(core): add content controller with tests (articles CRUD)"

Task 12: Update index.ts + run all tests

Files:

  • Modify: packages/core/src/index.ts

  • Step 1: Update src/index.ts with public API

// @repo/core — Clean Architecture core package
export * from "./entities/index.js";
export * from "./application/repositories/index.js";
export * from "./application/services/index.js";
export { signInUseCase } from "./application/use-cases/auth/sign-in.use-case.js";
export { signUpUseCase } from "./application/use-cases/auth/sign-up.use-case.js";
export { signOutUseCase } from "./application/use-cases/auth/sign-out.use-case.js";
export { createArticleUseCase } from "./application/use-cases/content/create-article.use-case.js";
export { getArticlesUseCase } from "./application/use-cases/content/get-articles.use-case.js";
export { signInController } from "./interface-adapters/controllers/auth/sign-in.controller.js";
export { signUpController } from "./interface-adapters/controllers/auth/sign-up.controller.js";
export { signOutController } from "./interface-adapters/controllers/auth/sign-out.controller.js";
export {
  createArticleController,
  getArticlesController,
} from "./interface-adapters/controllers/content/articles.controller.js";
export {
  getInjection,
  initializeContainer,
  destroyContainer,
} from "./di/container.js";
export { DI_SYMBOLS } from "./di/types.js";
  • Step 2: Run ALL tests

Run: cd packages/core && pnpm vitest run Expected: PASS — all tests (approximately 19 tests across 9 test files)

  • Step 3: Run turbo build from root

Run: pnpm build Expected: All workspaces build successfully

  • Step 4: Commit
git add packages/core/src/index.ts
git commit -m "feat(core): update public API exports"