Dependency Injection in Deno: A Practical Guide

After building a production Deno application over the past few months, I’ve learned that dependency injection (DI) isn’t just an academic concept; it’s a practical tool that makes code more testable and maintainable. In this post, I’ll share what I’ve learned about implementing dependency injection in Deno, with real examples from my codebase.

What is Dependency Injection?

Dependency injection is a design pattern where objects receive their dependencies from the outside rather than creating them internally. Instead of a class instantiating its own dependencies, you pass them in through the constructor (or other methods).

Here’s a simple example of what we’re trying to avoid:

// Bad: Class creates its own dependencies
class UserService {
  private db = new Database(); // Tightly coupled!
  
  async getUser(id: number) {
    return await this.db.query('SELECT * FROM users WHERE id = ?', [id]);
  }
}Code language: TypeScript (typescript)

The problem with this approach is that UserService is tightly coupled to a specific Database implementation. Testing becomes difficult because you can’t easily swap in a mock database, and you’re stuck with whatever database implementation you chose initially.

With dependency injection, you’d write it like this:

// Good: Dependencies are injected
class UserService {
  constructor(private db: IDatabase) {}
  
  async getUser(id: number) {
    return await this.db.query('SELECT * FROM users WHERE id = ?', [id]);
  }
}Code language: TypeScript (typescript)

Now UserService doesn’t know or care how the database is created. You can pass in a real database in production, or a mock database in tests.

Why Use Dependency Injection in Deno?

The benefits of dependency injection are the same in Deno as they are in Node.js or any other language:

  1. Testability: You can easily swap in mock implementations for testing
  2. Flexibility: You can change implementations without modifying the class that uses them
  3. Separation of concerns: Classes focus on their core responsibility, not on creating dependencies

What makes Deno interesting here is that TypeScript is built-in, so you get compile-time type checking for your dependencies. This means you can catch dependency mismatches before your code even runs.

Deno’s single-module graph and URL-based imports also make dependencies explicit by design. Because every module import is a concrete file or URL, you’re already encouraged to wire dependencies deliberately rather than hiding them in a global container. This plays very nicely with constructor injection and factory functions, where you define your object graph in one clear place.

Constructor Injection Pattern

The most common form of dependency injection is constructor injection, where dependencies are passed through the constructor. In my codebase, I use this pattern extensively.

Here’s a simplified example from my BroadsignDigitalRequest class, which handles API requests:

class BroadsignDigitalRequest {
  private readonly sessionManager: ISessionManager;
  private readonly requestExecutor: IRequestExecutor;
  
  constructor(
    config = new BroadsignDigitalConfig(),
    repository = new SessionRepository(),
    sessionManager?: ISessionManager,
    requestExecutor?: IRequestExecutor
  ) {
    const sessionService = new SessionService(config);
    this.sessionManager = sessionManager || 
      new SessionManager(sessionService, repository);
    this.requestExecutor = requestExecutor || 
      new RequestExecutor(config, this.sessionManager);
  }
  
  async get<T>(endpoint: string): Promise<T> {
    return await this.requestExecutor.execute<T>('GET', endpoint);
  }
}Code language: TypeScript (typescript)

Notice a few things here:

  • Optional parameters with defaults: The constructor accepts optional dependencies (sessionManager?, requestExecutor?) but provides sensible defaults if they’re not provided. This makes the class easy to use in normal code, but still allows you to inject mocks in tests.
  • Interface types: The dependencies are typed as interfaces (ISessionManager, IRequestExecutor), not concrete classes. This is the Dependency Inversion Principle in action—we depend on abstractions, not implementations.
  • Fallback creation: If a dependency isn’t provided, we create a default instance. This gives us flexibility without requiring a dependency injection container.

Note – Purists might argue you shouldn’t call  new  inside this class at all. In practice, this “optional injection with sensible defaults” pattern is a good compromise between full DI purity and developer ergonomics.

Interface-Based Design

Using interfaces is crucial for dependency injection. They define contracts that implementations must follow, and they make it easy to swap implementations or create mocks.

Here’s the ISessionManager interface from my codebase:

interface ISessionManager {
  getSessionId(forceRefresh?: boolean): Promise<string>;
}Code language: TypeScript (typescript)

It’s small and focused—exactly what you want in an interface. The SessionManager class implements this interface:

class SessionManager implements ISessionManager {
  constructor(
    private readonly sessionService: SessionService,
    private readonly repository: SessionRepository
  ) {}
  
  async getSessionId(forceRefresh = false): Promise<string> {
    // Implementation details...
  }
}Code language: TypeScript (typescript)

And here’s the IRequestExecutor interface:

interface IRequestExecutor {
  execute<T>(
    method: 'GET' | 'POST' | 'PUT' | 'DELETE',
    endpoint: string,
    data?: unknown,
    params?: Record<string, string>
  ): Promise<T>;
}Code language: TypeScript (typescript)

By depending on these interfaces rather than concrete classes, BroadsignDigitalRequest doesn’t care about the implementation details. It just knows that sessionManager.getSessionId() will return a session ID, and requestExecutor.execute() will make an HTTP request.

Testing with Mocks

This is where dependency injection really shines. When you can inject dependencies, testing becomes straightforward.

Here’s a test example from my codebase that shows how easy it is to mock dependencies:

import { stub } from "@std/testing/mock";

Deno.test('should call requestExecutor.execute with GET method', async () => {
  const mockExecutor = {
    execute: () => Promise.resolve({})
  };

  const executeStub = stub(mockExecutor, "execute", () => Promise.resolve({}));
  
  const request = new BroadsignDigitalRequest(
    new BroadsignDigitalConfig(),
    new SessionRepository(),
    undefined,
    mockExecutor as IRequestExecutor
  );
  
  await request.get('screens');
  
  assertEquals(executeStub.calls.length, 1);
  assertEquals(executeStub.calls[0].args, ['GET', 'screens', undefined, undefined]);
  
  executeStub.restore();
});Code language: TypeScript (typescript)

In this test, I’m creating a mock IRequestExecutor and injecting it into BroadsignDigitalRequest. Then I can verify that the execute method was called with the correct arguments, without actually making any HTTP requests.

Deno’s standard library stub function from @std/testing/mock makes this easy. You can stub any method on an object and verify how it was called.

Service Layer Example

Dependency injection works well at the service layer too. Here’s a simplified example from my SyncJobService:

interface ISyncJobRepository {
  create(data: /* ... */): Promise<SyncJobSelect>;
  // other methods used by SyncJobService
}

interface ISyncJobItemRepository {
  batchCreate(items: /* ... */): Promise<void>;
}

interface IBillboardRepository {
  getAllBillboards(): Promise<Billboard[]>;
}

class SyncJobService {
  constructor(
    private readonly syncJobRepo: ISyncJobRepository,
    private readonly syncJobItemRepo: ISyncJobItemRepository,
    private readonly billboardRepo: IBillboardRepository,
  ) {}
  
  async createJob(type: SyncJobType): Promise<SyncJobSelect> {
    const billboards = await this.billboardRepo.getAllBillboards();
    
    const job = await this.syncJobRepo.create({
      type,
      status: 'pending',
      total_items: billboards.length,
      processed_items: 0,
      failed_items: 0,
    });
    
    const items = billboards.map(billboard => ({
      sync_job_id: job.id,
      billboard_id: billboard.id,
      status: 'pending',
    }));
    
    await this.syncJobItemRepo.batchCreate(items);
    return job;
  }
}Code language: TypeScript (typescript)

The service depends on repository interfaces, not concrete implementations. This makes it easy to test:

Deno.test('createJob should create job with items for all billboards', async () => {
  const mockBillboards = [
    { id: 1, name: "Billboard 1" },
    { id: 2, name: "Billboard 2" },
  ];

  const mockSyncJobRepo = {
    create: () => Promise.resolve({ id: 1, type: "broadsign", status: "pending" }),
    // ... other methods
  };

  const mockBillboardRepo = {
    getAllBillboards: () => Promise.resolve(mockBillboards),
    // ... other methods
  };

  const service = new SyncJobService(
    mockSyncJobRepo,
    mockSyncJobItemRepo,
    mockBillboardRepo
  );

  const result = await service.createJob("broadsign");
  
  assertEquals(result.id, 1);
  assertEquals(result.type, "broadsign");
});Code language: TypeScript (typescript)

I can test the business logic of createJob without touching a real database. The test is fast, isolated, and reliable.

Factory Pattern for Dependency Wiring

As your application grows, manually creating all these dependencies can get tedious. That’s where factory functions come in handy.

I have a factory function that wires up all the dependencies for my sync task runner:

// db, frameAttributeRepo, syncJobItemService, processors, getScreenClient,
// and getFaceDetailClient are assumed to be created elsewhere in your app.

export function initializeSyncTaskRunner(config = {}): SyncTaskRunner {
  // Initialize repositories
  const syncJobRepo = new DrizzleSyncJobRepository(db);
  const syncJobItemRepo = new DrizzleSyncJobItemRepository(db);
  const billboardRepo = new DrizzleBillboardRepository(db);

  // Initialize services
  const syncJobService = new SyncJobService(
    syncJobRepo,
    syncJobItemRepo,
    billboardRepo
  );

  // Initialize processors
  const broadsignProcessor = new BroadsignSyncProcessor(
    frameAttributeRepo,
    syncJobItemService,
    syncJobService,
    getScreenClient,
    getFaceDetailClient
  );

  // Wire everything together
  return new SyncTaskRunner(
    syncJobService,
    syncJobItemService,
    billboardRepo,
    processors,
    config
  );
}Code language: TypeScript (typescript)

This factory function is the single place where all dependencies are wired together. In production, I call initializeSyncTaskRunner() and get a fully configured SyncTaskRunner. In tests, I can create the dependencies manually or create a test-specific factory.

This function acts as a composition root: a single place where the object graph for this part of the application is assembled.

Best Practices (What Worked for Me)

After using dependency injection extensively in my Deno application, here’s what I’ve learned:

Use Interfaces, Not Concrete Classes

Always type your dependencies as interfaces. This makes it easy to swap implementations and create mocks:

// Good
constructor(private repo: IBillboardRepository) {}

// Less flexible
constructor(private repo: DrizzleBillboardRepository) {}Code language: TypeScript (typescript)

Provide Sensible Defaults

Make dependencies optional with defaults, so the class is easy to use in normal code:

constructor(
  config = new Config(),
  repository = new Repository(),
  service?: CustomService
) {
  this.service = service || new DefaultService(config);
}Code language: TypeScript (typescript)

Keep Interfaces Small and Focused

An interface should define only what the class actually needs. Don’t include methods that won’t be used:

// Good: Small, focused interface
interface ISessionManager {
  getSessionId(forceRefresh?: boolean): Promise<string>;
}

// Too broad: Includes methods we don't need
interface ISessionManager {
  getSessionId(): Promise<string>;
  refreshSession(): Promise<void>;
  invalidateSession(): Promise<void>;
  // ... many more methods
}Code language: TypeScript (typescript)

Inject at the Boundary

Inject dependencies where your code touches the outside world, i.e. any boundary between your application logic and external systems. That might include:

  • The database layer (e.g., pass in a DatabaseClient instance or repository).
  • The filesystem (e.g., inject a FileStorage abstraction instead of calling Deno.readFile directly).
  • The network layer (e.g., provide an HttpClient or FetchAdapter rather than using fetch() inline).
  • The environment or configuration (e.g., inject a ConfigProvider that reads from Deno.env so you can substitute fake values in tests).

These are the places where failures, side-effects, and external variability live; so injecting them isolates the boundaries and keeps your core application logic pure and testable. You don’t need DI for simple helpers or domain-level objects; focus it where you cross I/O boundaries.

Use Factory Functions for Complex Wiring

When you have many dependencies to wire together, use a factory function. It keeps the wiring logic in one place and makes it easy to create test instances.

When Not to Use Dependency Injection

Dependency injection is a useful tool, but it’s not always necessary. Here are some cases where I’ve found it’s overkill:

  • Simple utility classes: If a class just does calculations or transformations, you probably don’t need DI.
  • Value objects: Classes that just hold data don’t need dependency injection.
  • Very small applications: If your app is tiny and you’re not writing tests, DI might add unnecessary complexity.

The key is to use dependency injection where it provides value: primarily when you need testability or flexibility.

Conclusion

Dependency injection in Deno works just like it does in Node.js or any other TypeScript environment but Deno’s module system and built-in TypeScript make it especially pleasant to work with.

In my experience, dependency injection has made my code more testable and maintainable. It’s not magic, and it does add some complexity, but the benefits are worth it for production applications.

The patterns I’ve shown here: constructor injection, interface-based design, and factory functions, are all straightforward and don’t require any special Deno features. They’re just good software design practices that happen to work well in Deno.

If you’re building a Deno application and want to write tests, I’d recommend starting with constructor injection. It’s simple, it works, and it makes your code more flexible. You don’t need a fancy dependency injection container. Just pass dependencies through constructors and use interfaces for type safety.

That’s what I’ve done in my application, and it’s worked well. The code is testable, the tests are fast, and I can swap implementations when needed. Sometimes the simple approach is the best approach.