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.
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"