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

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

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