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:
- Contracts – Pure TypeScript interfaces with zero runtime dependencies
- Production implementations – Concrete objects reading
process.envor external services - 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
makeEnvfromcommon/src/env-process.tsto inject partial overrides - Inject dependencies via constructor parameters or function arguments, never accessing
process.envdirectly 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →