250 lines
10 KiB
Markdown
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
|