Skip to content

Dev Workflow

This page is the practical, day-to-day version of Contributing Conventions. It covers setup, the validation loop you run before pushing, and two recipes that come up often: adding a new module, and adding a new LLM provider.

Setup

bash
git clone https://github.com/vineethkrishnan/clearpr.git
cd clearpr
npm install
cp .env.example .env       # fill in GitHub App credentials, LLM key
docker compose -f docker-compose.dev.yml up -d   # PostgreSQL + Redis
npm run migration:run
npm run start:dev

docker-compose.dev.yml only brings up the stateful dependencies (pgvector/pgvector:pg16 and redis:7-alpine). The Nest app runs on the host so you get watch reloads without a container rebuild on every save.

The validation loop

Before you push, run the same four steps CI runs. They're fast and they catch nearly everything review would have caught.

bash
npm run lint:check     # ESLint (no fix)
npm run test           # Jest
npm run build          # nest build (catches type errors the editor missed)

You can also run:

  • npm run lint:dead-codetsc --noEmit --noUnusedLocals --noUnusedParameters
  • npm run lint:strict — strict-mode TypeScript pass
  • npm run lint:duplicatesjscpd looking for copy-paste
  • npm run test:cov — Jest with coverage report
  • npm run format — Prettier write (use format:check to dry-run)

If you only want to trigger a single layer, run them in this order: types -> lint -> tests -> build. A failing tsc makes everything else noisy.

Adding a new module

Suppose we want a notifications/ module that sends a Slack message when a review finishes. Hexagonally, the work is small and predictable.

1. Sketch the boundary

What's the use case? "Notify a channel when a review completes." That's one application use case: NotifyOnReviewCompleteUseCase. It needs an outbound capability: send a notification. That's a port.

2. Lay out the folders

src/notifications/
├── SUMMARY.md                                        ← module purpose + boundary (see CONTRIBUTING.md)
├── domain/
│   ├── entities/
│   │   └── notification.entity.ts
│   ├── value-objects/
│   ├── ports/
│   │   └── notifier.port.ts
│   └── errors/
├── application/
│   ├── use-cases/
│   │   └── notify-on-review-complete.use-case.ts
│   ├── ports/                                        ← cross-module ports (if any)
│   └── dtos/                                         ← class-validator DTOs (if there's an HTTP boundary)
├── infrastructure/
│   ├── repositories/
│   │   ├── notification.record.ts                    ← @Entity-decorated record
│   │   ├── notification.mapper.ts                    ← static toDomain / toRecord
│   │   └── typeorm-notification.repository.ts        ← implements the port
│   └── adapters/
│       └── slack-notifier.adapter.ts
├── presenters/
│   └── http/                                         ← only if the module has an HTTP surface
│       └── *.controller.ts
└── notifications.module.ts

3. Define the port (domain)

ts
// src/notifications/domain/ports/notifier.port.ts
export abstract class NotifierPort {
  abstract send(channel: string, message: string): Promise<void>;
}

Abstract class, not interface. No vendor types in sight.

4. Write the use case (application)

ts
// src/notifications/application/use-cases/notify-on-review-complete.use-case.ts
@Injectable()
export class NotifyOnReviewCompleteUseCase {
  private readonly logger = new Logger(NotifyOnReviewCompleteUseCase.name);

  constructor(private readonly notifier: NotifierPort) {}

  async execute(repository: string, prNumber: number): Promise<void> {
    const message = `Review complete for ${repository}#${prNumber}`;
    await this.notifier.send('#code-review', message);
    this.logger.debug({ repository, prNumber }, 'Notification sent');
  }
}

The use case depends on the port. It does not know Slack exists. Class names follow <Verb><Noun>UseCase; the public method is conventionally execute().

5. Implement the adapter (infrastructure)

ts
// src/notifications/infrastructure/adapters/slack-notifier.adapter.ts
@Injectable()
export class SlackNotifierAdapter extends NotifierPort {
  constructor(private readonly config: AppConfig) {
    super();
  }
  async send(channel: string, message: string): Promise<void> {
    // call the Slack webhook
  }
}

Vendor SDK errors get caught here and translated to a domain error if a higher layer needs to react to them.

6. Wire up the module

ts
// src/notifications/notifications.module.ts
@Module({
  providers: [
    NotifyOnReviewCompleteUseCase,
    { provide: NotifierPort, useClass: SlackNotifierAdapter },
  ],
  exports: [NotifyOnReviewCompleteUseCase],
})
export class NotificationsModule {}

The binding { provide: NotifierPort, useClass: SlackNotifierAdapter } is where domain meets infrastructure. Swap to DiscordNotifierAdapter later by changing this one line. If another module needs to call NotifyOnReviewCompleteUseCase, expose it via an abstract port in application/ports/ and bind it with useExisting so the consumer module imports the port, not the concrete class.

7. Register it

Add NotificationsModule to AppModule.imports. If the review module needs to call it, import it there.

That's the whole recipe. Same shape as memory/, review/, and every other existing module.

Adding a new LLM provider

The LLM provider port is the cleanest example of the pattern in the codebase. Adding a sixth provider (say, Cohere) is mechanical.

1. Look at the port

ts
// src/review/domain/ports/llm-provider.port.ts
export abstract class LlmProviderPort {
  abstract generateReview(prompt: string, maxTokens: number): Promise<LlmResponse>;
}

One method. That's the entire contract you have to satisfy.

2. Add the adapter

Create src/review/infrastructure/llm/cohere-llm.adapter.ts:

ts
export class CohereLlmAdapter extends LlmProviderPort {
  private readonly client: CohereClient;
  private readonly model: string;

  constructor(config: AppConfig) {
    super();
    this.client = new CohereClient({ token: config.LLM_API_KEY });
    this.model = config.llmModelWithDefault;
  }

  async generateReview(prompt: string, maxTokens: number): Promise<LlmResponse> {
    try {
      const response = await this.client.chat({ /* ... */ });
      return {
        content: response.text,
        promptTokens: response.meta.tokens.input,
        completionTokens: response.meta.tokens.output,
        model: this.model,
      };
    } catch (error) {
      // translate vendor errors to LlmRateLimitError / LlmTimeoutError
      throw error;
    }
  }
}

Use AnthropicLlmAdapter as your reference — same structure, same error translation, same return shape.

3. Add the enum value

In src/config/app.config.ts, add COHERE = 'cohere' to the LlmProvider enum.

4. Register it in the factory

ts
// src/review/infrastructure/llm/llm-provider.registry.ts
export function createLlmProvider(): Provider<LlmProviderPort> {
  return {
    provide: LlmProviderPort,
    inject: [AppConfig],
    useFactory: (config: AppConfig): LlmProviderPort => {
      switch (config.LLM_PROVIDER) {
        case LlmProvider.ANTHROPIC: return new AnthropicLlmAdapter(config);
        case LlmProvider.OPENAI:    return new OpenAiLlmAdapter(config);
        case LlmProvider.OLLAMA:    return new OllamaLlmAdapter(config);
        case LlmProvider.MISTRAL:   return new MistralLlmAdapter(config);
        case LlmProvider.GEMINI:    return new GeminiLlmAdapter(config);
        case LlmProvider.COHERE:    return new CohereLlmAdapter(config);
        default:
          throw new Error(`Unknown LLM_PROVIDER: ${config.LLM_PROVIDER as string}`);
      }
    },
  };
}

The registry is a factory provider, not a class provider. It picks the adapter at startup based on LLM_PROVIDER. Nothing in the review pipeline cares which one it is.

5. Tests, docs, env

  • Add a unit test for the adapter that fakes the SDK and checks error translation.
  • Add the new value to the LLM_PROVIDER row in Configuration and LLM Providers.
  • Add the SDK to package.json dependencies.

Migrations

Migrations live under src/shared/infrastructure/database/migrations/ and are run via the npm scripts:

bash
npm run migration:generate   # generate from current entities (after build)
npm run migration:run        # apply pending migrations
npm run migration:revert     # revert last migration

Today, migrations are written by hand or generated against the built dist/. The plan (tracked under P6) is to move to fully auto-generated migrations as part of the build pipeline so the round-trip is one command. Until then, generate, inspect, and commit the SQL alongside the code change.

When in doubt

  • Where does this code go? If it knows about an external system, it's infrastructure. If it orchestrates a business operation, it's application. If it would still make sense in a vacuum, it's domain.
  • Should I make a port for this? If something outside the module needs to talk to something inside the module, yes. If it's purely internal, no.
  • My use case has 200 lines and four branches. Split it. Cognitive complexity 15 is the lint cap, not a target.

Released under the MIT License.