Files
agentic-dev/packages/core/AGENTS.md

250 lines
10 KiB
Markdown

# @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`:
```typescript
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`
```typescript
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/`
```typescript
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/`
```typescript
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`
```typescript
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:
```typescript
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.ts`
- `tests/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
```bash
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 classes
- `src/application/AGENTS.md` -- Use cases, repository/service interfaces
- `src/infrastructure/AGENTS.md` -- Concrete implementations with `@injectable()`
- `src/interface-adapters/controllers/AGENTS.md` -- Controller patterns
- `src/di/AGENTS.md` -- DI container configuration and lifecycle
- `src/application/use-cases/auth/AGENTS.md` -- Auth domain business rules
- `src/application/use-cases/content/AGENTS.md` -- Content domain business rules
- Root `AGENTS.md` -- Full end-to-end feature recipe and monorepo map