How to Configure Dependency Injection Contracts in Common Types for Freebuff

Use TypeScript interfaces in common/src/types/contracts/ to define DI contracts, then wire production implementations in common/src/env.ts and test mocks via common/src/env-process.ts.

The Freebuff repository (CodebuffAI/freebuff) implements a lightweight, explicit dependency injection system using TypeScript contracts. These contracts live in common/src/types/contracts/ and strictly type all runtime-configurable values—from environment variables to service abstractions. This guide walks through how to define, implement, and inject these contracts correctly.

Understanding the DI Contract Architecture

Freebuff's DI system separates interface definition from implementation. This enables compile-time safety and seamless test mocking without external DI frameworks.

Contract Purpose Location
EnvContract Environment variable schema common/src/types/contracts/env.ts
AnalyticsContract Analytics service interface common/src/types/contracts/analytics.ts
SchedulerContract Timer/scheduler hook for CLI common/src/types/contracts/scheduler.ts

The pattern follows three layers:

  1. Contracts – Pure TypeScript interfaces with zero runtime dependencies
  2. Production implementations – Concrete objects reading process.env or external services
  3. Test factories – Helper functions generating mock implementations

Defining a New DI Contract

Create or extend an interface in common/src/types/contracts/ following the existing EnvContract pattern.

// common/src/types/contracts/env.ts
export interface EnvContract {
  /** OpenAI API key – required for any LLM call */
  OPENAI_API_KEY: string;
  /** Bun runtime version – used for feature gating */
  BUN_VERSION: string;
  /** Optional flag that disables telemetry */
  DISABLE_TELEMETRY?: boolean;
  /** New experimental feature flag */
  ENABLE_EXPERIMENT?: boolean;
}

Key conventions observed in Freebuff contracts:

  • JSDoc comments describe purpose and constraints
  • Required fields have no ? modifier
  • Optional flags use ? with sensible defaults in implementations

Implementing the Contract in Production

Production code lives adjacent to the contracts, not inside them.

// common/src/env.ts
import type { EnvContract } from './types/contracts/env';

export const env: EnvContract = {
  OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? '',
  BUN_VERSION: process.env.BUN_VERSION ?? '',
  DISABLE_TELEMETRY: process.env.DISABLE_TELEMETRY === 'true',
  ENABLE_EXPERIMENT: process.env.ENABLE_EXPERIMENT === 'true',
};

This module provides the default implementation imported throughout the codebase. It coerces environment strings to proper types and supplies empty-string fallbacks for required fields (validation happens at consumption time).

Creating Injectable Test Mocks

The env-process.ts module exports a factory for building partial or complete contract implementations.

// common/src/env-process.ts
import type { EnvContract } from './types/contracts/env';

export const makeEnv = (overrides: Partial<EnvContract> = {}): EnvContract => ({
  OPENAI_API_KEY: '',
  BUN_VERSION: '',
  DISABLE_TELEMETRY: false,
  ENABLE_EXPERIMENT: false,
  ...overrides,
});

The makeEnv function spreads defaults first, then overrides, ensuring all required fields are always present. Tests pass only the values they need to vary.

Injecting Contracts into Application Code

Modules receive their dependencies through constructor injection or function parameters, never importing process.env directly.

// src/agents/base2/base2.ts
import type { EnvContract } from '../../common/src/types/contracts/env';
import { makeEnv } from '../../common/src/env-process';

export class Base2 {
  constructor(private readonly env: EnvContract = makeEnv()) {}

  canRunExperiment(): boolean {
    return !!this.env.ENABLE_EXPERIMENT;
  }

  async callOpenAI(prompt: string) {
    const apiKey = this.env.OPENAI_API_KEY;
    if (!apiKey) throw new Error('OPENAI_API_KEY not configured');
    // ... use apiKey ...
  }
}

Providing makeEnv() as a default argument ensures the class works in production (when called with no arguments) while remaining fully testable.

Configuring CI-Specific Implementations

For continuous integration environments, Freebuff provides env-ci.ts with specialized overrides.

// common/src/env-ci.ts
import type { EnvContract } from './types/contracts/env';

export const env: EnvContract = {
  OPENAI_API_KEY: process.env.CI_OPENAI_API_KEY ?? 'ci-mock-key',
  BUN_VERSION: process.env.BUN_VERSION ?? '1.0.0-ci',
  DISABLE_TELEMETRY: true, // Always disable in CI
  ENABLE_EXPERIMENT: true, // Enable experiments in CI builds
};

CI pipelines import from env-ci.ts instead of env.ts to inject CI-appropriate behavior without code changes.

Complete Configuration Example: Adding a Feature Flag

Step-by-step workflow for extending the DI system:

Step 1: Extend the contract

// common/src/types/contracts/env.ts
export interface EnvContract {
  // existing fields...
  MAX_BATCH_SIZE?: number; // New configurable limit
}

Step 2: Update production implementation

// common/src/env.ts
export const env: EnvContract = {
  // existing fields...
  MAX_BATCH_SIZE: parseInt(process.env.MAX_BATCH_SIZE ?? '10', 10),
};

Step 3: Add test default

// common/src/env-process.ts
export const makeEnv = (overrides: Partial<EnvContract> = {}): EnvContract => ({
  // existing defaults...
  MAX_BATCH_SIZE: 10,
  ...overrides,
});

Step 4: Consume in application code

// src/batch-processor.ts
import type { EnvContract } from '../common/src/types/contracts/env';

export function createBatchProcessor(env: EnvContract) {
  const limit = env.MAX_BATCH_SIZE ?? 10;
  return {
    process: (items: unknown[]) => items.slice(0, limit),
  };
}

Step 5: Test with mock

import { createBatchProcessor } from './batch-processor';
import { makeEnv } from '../common/src/env-process';

test('respects batch size limit', () => {
  const env = makeEnv({ MAX_BATCH_SIZE: 2 });
  const processor = createBatchProcessor(env);
  
  const result = processor.process([1, 2, 3, 4, 5]);
  expect(result).toHaveLength(2);
});

Key Files Reference

File Role Implementation Pattern
common/src/types/contracts/env.ts Environment contract definition Pure interface
common/src/types/contracts/analytics.ts Analytics service contract Interface with method signatures
common/src/types/contracts/scheduler.ts Scheduler abstraction Interface with callback types
common/src/env.ts Production environment implementation Reads process.env
common/src/env-process.ts Test factory for EnvContract makeEnv() with spread defaults
common/src/env-ci.ts CI-specific environment override Hardcoded CI values

Summary

  • Define contracts as TypeScript interfaces in common/src/types/contracts/ with clear JSDoc documentation
  • Implement production values in common/src/env.ts, coercing environment strings to proper types
  • Generate test mocks using makeEnv from common/src/env-process.ts to inject partial overrides
  • Inject dependencies via constructor parameters or function arguments, never accessing process.env directly in business logic
  • Support multiple environments by creating alternative implementations (e.g., env-ci.ts) that adhere to the same contract interface

Frequently Asked Questions

How do I add a required environment variable to Freebuff?

Extend EnvContract in common/src/types/contracts/env.ts with a non-optional property, then update common/src/env.ts to read it from process.env. Add a default value in common/src/env-process.ts's makeEnv function to prevent test breakage.

Can I use Freebuff's DI contracts for non-environment dependencies?

Yes. Create a new interface in common/src/types/contracts/ (following analytics.ts or scheduler.ts as models), implement it in a service module, and inject via constructor parameters. The pattern works for any replaceable dependency.

Why does Freebuff use explicit contracts instead of a DI framework like InversifyJS?

According to the Freebuff source code, the explicit contract pattern provides compile-time type safety without runtime overhead and zero external dependencies for the common types package. The small surface area of injected values makes a full DI framework unnecessary.

How do I override a single value in a unit test without repeating all defaults?

Import makeEnv from common/src/env-process.ts and pass only the fields you want to override: makeEnv({ OPENAI_API_KEY: 'test-key' }). The spread operator merges your overrides with sensible defaults for all other fields.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →