Dependency Injection Patterns: DI in TypeScript, Go & Java
Dependency injection is the most impactful design pattern you can adopt for building testable, maintainable software. The core idea is deceptively simple: a component should receive its dependencies from the outside rather than creating them internally. This single principle transforms tightly coupled code into composable, swappable modules.
The Problem DI Solves#
Consider a service that creates its own dependencies:
class OrderService {
private db = new PostgresDatabase();
private mailer = new SmtpMailer();
async placeOrder(order: Order) {
await this.db.save(order);
await this.mailer.send(order.userEmail, 'Order confirmed');
}
}
This code is untestable without a real database and mail server. It is impossible to swap PostgresDatabase for an in-memory store or replace SmtpMailer with a mock. The class controls its dependencies instead of depending on abstractions.
DI Principles#
Dependency injection follows from the Dependency Inversion Principle (the D in SOLID):
- High-level modules should not depend on low-level modules. Both should depend on abstractions.
- Abstractions should not depend on details. Details should depend on abstractions.
In practice, this means:
- Define interfaces (or abstract types) for dependencies.
- Pass concrete implementations in from the outside.
- Let the caller — not the callee — decide which implementation to use.
Constructor Injection#
The most common and recommended form. Dependencies are passed through the constructor:
interface Database {
save(entity: unknown): Promise<void>;
}
interface Mailer {
send(to: string, body: string): Promise<void>;
}
class OrderService {
constructor(
private db: Database,
private mailer: Mailer
) {}
async placeOrder(order: Order) {
await this.db.save(order);
await this.mailer.send(order.userEmail, 'Order confirmed');
}
}
Advantages: Dependencies are explicit, immutable after construction, and the object is always in a valid state. This is the default choice for DI.
Property Injection#
Dependencies are set via public properties or setters after construction:
class OrderService {
db!: Database;
mailer!: Mailer;
async placeOrder(order: Order) {
await this.db.save(order);
await this.mailer.send(order.userEmail, 'Order confirmed');
}
}
const service = new OrderService();
service.db = new PostgresDatabase();
service.mailer = new SmtpMailer();
Disadvantages: The object can exist in an invalid state (missing dependencies). It is harder to reason about when dependencies are set. Use this only when constructor injection is not possible — for example, in frameworks that require a no-arg constructor.
Method Injection#
Dependencies are passed as method parameters:
class OrderService {
async placeOrder(order: Order, db: Database, mailer: Mailer) {
await db.save(order);
await mailer.send(order.userEmail, 'Order confirmed');
}
}
Use case: When the dependency varies per call rather than per instance. Common for strategy-pattern scenarios where different callers pass different implementations.
IoC Containers#
An Inversion of Control (IoC) container automates dependency resolution. You register types and their implementations; the container wires them together:
// Using tsyringe (TypeScript IoC container)
import { container, injectable, inject } from 'tsyringe';
@injectable()
class OrderService {
constructor(
@inject('Database') private db: Database,
@inject('Mailer') private mailer: Mailer
) {}
}
// Registration
container.register('Database', { useClass: PostgresDatabase });
container.register('Mailer', { useClass: SmtpMailer });
// Resolution — container builds the full dependency tree
const service = container.resolve(OrderService);
IoC containers shine when the dependency graph is deep. Manually wiring 50 services with nested dependencies is tedious and error-prone. The container handles it automatically.
Popular IoC containers:
- TypeScript: tsyringe, InversifyJS, typed-inject
- Java: Spring, Guice, Dagger
- C#: Microsoft.Extensions.DependencyInjection, Autofac
- Python: dependency-injector, injector
DI in Go#
Go does not have decorators or reflection-based DI. Instead, DI is done explicitly through constructor functions and interfaces:
type Database interface {
Save(ctx context.Context, entity any) error
}
type Mailer interface {
Send(to, body string) error
}
type OrderService struct {
db Database
mailer Mailer
}
func NewOrderService(db Database, mailer Mailer) *OrderService {
return &OrderService{db: db, mailer: mailer}
}
func (s *OrderService) PlaceOrder(ctx context.Context, order Order) error {
if err := s.db.Save(ctx, order); err != nil {
return err
}
return s.mailer.Send(order.UserEmail, "Order confirmed")
}
In Go, interfaces are implicitly satisfied — any type that has the right methods implements the interface. This makes DI natural without any framework. Wire dependencies in main():
func main() {
db := postgres.New(connStr)
mailer := smtp.New(smtpConfig)
orderSvc := NewOrderService(db, mailer)
// ...
}
Google's wire tool can generate this wiring code for large dependency graphs.
DI in Java with Spring#
Spring is the most widely used DI framework. It uses annotations to mark injectable components:
@Service
public class OrderService {
private final Database db;
private final Mailer mailer;
@Autowired
public OrderService(Database db, Mailer mailer) {
this.db = db;
this.mailer = mailer;
}
public void placeOrder(Order order) {
db.save(order);
mailer.send(order.getUserEmail(), "Order confirmed");
}
}
Spring scans for @Component, @Service, @Repository annotations and automatically resolves the dependency graph. For testing, you can override beans with @MockBean or @TestConfiguration.
Testing with DI#
DI makes testing straightforward — inject mocks or stubs instead of real implementations:
describe('OrderService', () => {
it('saves order and sends email', async () => {
const mockDb: Database = { save: vi.fn() };
const mockMailer: Mailer = { send: vi.fn() };
const service = new OrderService(mockDb, mockMailer);
await service.placeOrder(testOrder);
expect(mockDb.save).toHaveBeenCalledWith(testOrder);
expect(mockMailer.send).toHaveBeenCalledWith(
testOrder.userEmail,
'Order confirmed'
);
});
});
Without DI, you would need to monkey-patch modules, use complex mocking libraries, or spin up real infrastructure. DI eliminates all of that.
The Service Locator Anti-Pattern#
A service locator is a global registry that components query for their dependencies:
class OrderService {
async placeOrder(order: Order) {
const db = ServiceLocator.get<Database>('Database');
const mailer = ServiceLocator.get<Mailer>('Mailer');
await db.save(order);
await mailer.send(order.userEmail, 'Order confirmed');
}
}
Why it is an anti-pattern:
- Hidden dependencies — You cannot see what
OrderServiceneeds by looking at its constructor. Dependencies are buried inside method bodies. - Global state — The service locator is a singleton that couples all code to a shared mutable registry.
- Testing friction — You must configure the global locator before each test and clean it up afterward.
- Runtime failures — If a dependency is not registered, you get a runtime error instead of a compile-time error.
Service locators invert control (the class does not create dependencies) but they hide the dependency graph. True DI makes the graph explicit.
Composition Root#
The composition root is the single place in your application where the entire dependency graph is wired together. It is typically in main(), the application startup file, or the IoC container configuration:
// composition root — app entry point
const db = new PostgresDatabase(config.dbUrl);
const mailer = new SmtpMailer(config.smtpHost);
const orderService = new OrderService(db, mailer);
const orderController = new OrderController(orderService);
app.use('/orders', orderController.router);
Keep the composition root as close to the entry point as possible. Do not scatter new calls or container resolutions throughout your codebase.
When Not to Use DI#
DI adds indirection. For small scripts, CLIs, or simple CRUD apps with few dependencies, manual instantiation is fine. The overhead of interfaces, containers, and composition roots is not justified when you have three files and no tests.
Use DI when:
- You have non-trivial business logic that needs unit testing.
- Dependencies are swappable (database, cache, external APIs).
- The dependency graph has more than two levels of nesting.
- Multiple teams work on the same codebase.
Wrapping Up#
Dependency injection is not about frameworks or decorators — it is about passing dependencies in from the outside. Constructor injection is the default. IoC containers help when the graph is large. Go proves you do not need a framework to do DI well. Avoid service locators. Wire everything at the composition root. The payoff is code that is testable, modular, and easy to change.
305 articles and guides at codelit.io/blog.
Try it on Codelit
GitHub Integration
Paste any repo URL to generate an interactive architecture diagram from real code
Related articles
Try these templates
Real-Time Collaborative Editor
Notion-like document editor with real-time collaboration, conflict resolution, and rich media.
9 componentsDiscord Voice & Communication Platform
Handles millions of concurrent voice calls with WebRTC, media servers, and guild-based routing.
10 componentsDistributed Rate Limiter
API rate limiting with sliding window, token bucket, and per-user quotas.
7 componentsBuild this architecture
Generate an interactive architecture for Dependency Injection Patterns in seconds.
Try it in Codelit →
Comments