10 KiB
@repo/core -- Clean Architecture Core Package
This is the central business logic package. All domain entities, use cases, repository/service interfaces, controllers, and the InversifyJS DI container live here. Nothing in this package depends on any web framework (Next.js, TanStack, Payload). It is portable and testable in isolation.
Package location: packages/core
Entry point: src/index.ts
Test runner: Vitest (pnpm vitest run from this directory)
Layer Diagram
Dependencies point inward (right to left). Outer layers depend on inner layers, never the reverse.
+------------------------------------------------------------------+
| di/ |
| (wires everything together -- imports ALL internal layers) |
+------------------------------------------------------------------+
| | | |
v v v v
+----------------+ +----------------+ +----------------+ +-----------+
| interface- | | infrastructure/| | application/ | | entities/ |
| adapters/ | | (implements | | (use cases, | | (models, |
| controllers/ | | interfaces | | repo/service | | errors) |
| (validates | | with concrete | | interfaces) | | |
| input, calls | | code) | | | | INNERMOST |
| use cases) | | | | imports: | | zero deps |
| | | imports: | | entities/ | | |
| imports: | | application/ | | ONLY | | |
| application/ | | entities/ | | | | |
| entities/ | | @repo/ | | | | |
| | | cms-client | | | | |
+----------------+ +----------------+ +----------------+ +-----------+
Import Rules
| Layer | Can Import From | NEVER Import From |
|---|---|---|
entities/ |
Nothing (Zod is the only external dependency) | application/, infrastructure/, interface-adapters/, di/, any @repo/*, any framework |
application/ |
entities/ only |
infrastructure/, interface-adapters/, di/ (except getInjection from di/container in use cases) |
interface-adapters/ |
application/ (use cases), entities/ (types, errors) |
infrastructure/, any @repo/* except via DI |
infrastructure/ |
application/ (interfaces to implement), entities/ (types), @repo/cms-client |
interface-adapters/, apps/*, Next.js, TanStack |
di/ |
All internal layers (it wires them together) | apps/*, any framework package |
Note: Use cases in application/ import getInjection from di/container to resolve dependencies at runtime. This is the one controlled exception to the "application never imports di" rule -- getInjection is a lookup function, not a concrete implementation.
DI Resolution Table
| Symbol Key | Interface | Production Implementation | Mock Implementation |
|---|---|---|---|
IUsersRepository |
IUsersRepository (getUser, getUserByUsername, createUser) |
PayloadUsersRepository (future -- will use @repo/cms-client) |
MockUsersRepository (infrastructure/repositories/mock-users.repository.ts) |
IArticlesRepository |
IArticlesRepository (getArticle, getArticles, createArticle, updateArticle) |
PayloadArticlesRepository (future -- will use @repo/cms-client) |
MockArticlesRepository (infrastructure/repositories/mock-articles.repository.ts) |
IAuthenticationService |
IAuthenticationService (generateUserId, hashPassword, verifyPassword, validateSession, createSession, invalidateSession) |
BetterAuthService (future) | MockAuthenticationService (infrastructure/services/mock-auth.service.ts) |
ITelemetryService |
ITelemetryService (startSpan) |
OTelSentryService (future) | MockTelemetryService (infrastructure/services/mock-telemetry.service.ts) |
Currently all modules bind mock implementations. When production implementations are added, the modules will conditionally bind based on environment or configuration.
Naming Conventions
| Type | Pattern | Example |
|---|---|---|
| Entity model | {name}.ts |
article.ts, user.ts |
| Entity error | {domain}.ts |
auth.ts, common.ts |
| Repository interface | {name}.repository.interface.ts |
users.repository.interface.ts |
| Service interface | {name}.service.interface.ts |
auth.service.interface.ts |
| Use case | {verb}-{noun}.use-case.ts |
sign-in.use-case.ts, create-article.use-case.ts |
| Controller | {noun}.controller.ts (may export multiple functions) |
articles.controller.ts |
| Production impl | {provider}-{name}.repository.ts |
payload-users.repository.ts |
| Mock impl | mock-{name}.repository.ts or mock-{name}.service.ts |
mock-users.repository.ts |
| DI module | {domain}.module.ts |
auth.module.ts, content.module.ts |
| Test file | {name}.test.ts matching the source file name |
sign-in.use-case.test.ts |
How to Add a New Dependency (5-Step Recipe)
This recipe adds a new repository or service interface to the DI container. For a complete feature, also follow the root AGENTS.md end-to-end recipe.
Step 1: Define the interface in application/
Create src/application/repositories/{name}.repository.interface.ts or src/application/services/{name}.service.interface.ts:
import type { MyEntity } from "@/entities/models/my-entity";
export interface IMyRepository {
findById(id: string): Promise<MyEntity | undefined>;
findAll(): Promise<MyEntity[]>;
create(input: MyEntity): Promise<MyEntity>;
}
Export from the appropriate barrel: src/application/repositories/index.ts or src/application/services/index.ts.
Step 2: Add the DI symbol to di/types.ts
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
export const DI_SYMBOLS = {
// ...existing symbols...
IMyRepository: Symbol.for("IMyRepository"),
};
export interface DI_RETURN_TYPES {
// ...existing types...
IMyRepository: IMyRepository;
}
Step 3: Create mock implementation in infrastructure/
import { injectable } from "inversify";
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
import type { MyEntity } from "@/entities/models/my-entity";
@injectable()
export class MockMyRepository implements IMyRepository {
private _items: MyEntity[] = [];
async findById(id: string): Promise<MyEntity | undefined> {
return this._items.find((item) => item.id === id);
}
async findAll(): Promise<MyEntity[]> {
return [...this._items];
}
async create(input: MyEntity): Promise<MyEntity> {
this._items.push(input);
return input;
}
}
Step 4: Create or update DI module in di/modules/
import { ContainerModule, interfaces } from "inversify";
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
import { MockMyRepository } from "@/infrastructure/repositories/mock-my.repository";
import { DI_SYMBOLS } from "../types";
const initializeModule = (bind: interfaces.Bind) => {
bind<IMyRepository>(DI_SYMBOLS.IMyRepository).to(MockMyRepository);
};
export const MyModule = new ContainerModule(initializeModule);
Step 5: Load module in di/container.ts
import { MyModule } from "./modules/my.module";
export const initializeContainer = () => {
// ...existing modules...
ApplicationContainer.load(MyModule);
};
export const destroyContainer = () => {
// ...existing modules...
ApplicationContainer.unload(MyModule);
};
Testing Pattern
All tests follow this lifecycle pattern:
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { destroyContainer, initializeContainer } from "@/di/container";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("myUseCase", () => {
it("does something", async () => {
const result = await myUseCase(/* ... */);
expect(result).toBeDefined();
});
});
Critical requirements:
import "reflect-metadata"MUST be the first import in every test file. InversifyJS decorators rely on runtime metadata reflection.initializeContainer()loads all DI modules (binding mock implementations).destroyContainer()unloads all modules, ensuring clean state between tests. Without this, Singleton-scoped mocks retain state across tests.
Test file locations mirror source structure:
tests/unit/use-cases/{domain}/{name}.use-case.test.tstests/unit/controllers/{domain}/{name}.controller.test.ts
tsconfig Requirements
These settings in tsconfig.json are MANDATORY. Do not remove them:
| Setting | Why |
|---|---|
experimentalDecorators: true |
InversifyJS uses TypeScript decorators (@injectable(), @inject()) |
emitDecoratorMetadata: true |
InversifyJS reads parameter type metadata at runtime for constructor injection |
types: ["reflect-metadata", "node"] |
reflect-metadata polyfill must be globally available for decorator metadata |
Path alias @/* -> ./src/* |
All internal imports use @/ prefix (e.g., @/entities/models/user) |
The base config comes from @repo/typescript-config/base.json which already includes experimentalDecorators and emitDecoratorMetadata. The core tsconfig.json extends it and adds the path alias and types.
Running Tests
cd packages/core
pnpm vitest run # Run all tests once
pnpm vitest # Run in watch mode
pnpm vitest run --reporter=verbose # Verbose output
Cross-References
src/entities/AGENTS.md-- Entity models and error classessrc/application/AGENTS.md-- Use cases, repository/service interfacessrc/infrastructure/AGENTS.md-- Concrete implementations with@injectable()src/interface-adapters/controllers/AGENTS.md-- Controller patternssrc/di/AGENTS.md-- DI container configuration and lifecyclesrc/application/use-cases/auth/AGENTS.md-- Auth domain business rulessrc/application/use-cases/content/AGENTS.md-- Content domain business rules- Root
AGENTS.md-- Full end-to-end feature recipe and monorepo map