Bringing enterprise-grade architecture to frontend development
What is Dependency Injection?
Dependency Injection (DI) is a design pattern where dependencies are provided to a class from outside, rather than created internally.
// ❌ Without DI: tight coupling
class BlogService {
private http = new AxiosHttp(); // Hard-coded
async getPosts() {
return this.http.get("/api/posts");
}
}
// ✅ With DI: loose coupling
class BlogService {
constructor(private http: HttpClient) {}
async getPosts() {
return this.http.get("/api/posts");
}
}
While common in backend development (Spring, .NET Core), DI is rarely discussed in frontend — until now.
Why DI in React?
1. Service Reuse Across Models
Without DI, you'd pass dependencies manually to every model:
// Tedious
const blogService = new BlogService(new AxiosHttp());
const userService = new UserService(new AxiosHttp());
const productService = new ProductService(new AxiosHttp());
With DI, services are automatically injected:
class BlogModel {
@inject(HttpSchema)
private http?: HttpClient;
async fetchPosts() {
return this.http?.get("/api/posts");
}
}
2. Environment Switching
Switch between mock and production APIs without code changes:
// Development
config(
<Container>
<CInjection schema={HttpSchema} ctor={MockHttp} />
</Container>
);
// Production - just change one line
// <CInjection schema={HttpSchema} ctor={AxiosHttp} />
3. Testability
Mock dependencies for unit testing:
describe('BlogModel', () => {
beforeEach(() => {
const mockHttp: HttpClient = {
get: async (url: string) => {
if (url.includes('posts')) {
return [{ id: '1', title: 'Test Post' }];
}
return [];
},
post: async () => ({ id: 'new', title: 'New Post' }),
};
config(
<Container>
<CInjection schema={HttpSchema} ctor={mockHttp} />
</Container>,
);
});
test('fetches posts', async () => {
const blog = provide(BlogModel)();
await blog.fetchPosts();
// Test passes with mock data
});
});
easy-model IoC: Architecture Overview
┌─────────────────────────────────────────┐
│ App │
├─────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ BlogModel │ │ UserModel │ │
│ │ @inject │ │ @inject │ │
│ └──────┬──────┘ └────────┬────────┘ │
│ │ │ │
│ ┌──────▼──────────────────▼────────┐ │
│ │ Container │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ CInjection │ │ │
│ │ │ schema={HttpSchema} │ │ │
│ │ │ ctor={MockHttp} │ │ │
│ │ └─────────────────────────────┘ │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
Step-by-Step Implementation
Step 1: Define Schema with Zod
// types/http.ts
import { z } from "zod";
export const HttpSchema = z.object({
get: z.function().args(z.string()),
post: z.function().args(z.string(), z.unknown()),
});
export type HttpClient = z.infer<typeof HttpSchema>;
Step 2: Implement Services
// http/mock-http.ts
export class MockHttp implements HttpClient {
private database: Record<string, unknown[]> = {
"/api/posts": [
{ id: "1", title: "Getting Started", content: "..." },
{ id: "2", title: "Advanced Topics", content: "..." },
],
};
async get(url: string) {
return this.database[url] || [];
}
async post(url: string, data: unknown) {
console.log("Mock POST:", url, data);
return { id: crypto.randomUUID(), ...(data as object) };
}
}
// http/axios-http.ts
export class AxiosHttp implements HttpClient {
private client = axios.create({ baseURL: "" });
async get(url: string) {
const res = await this.client.get(url);
return res.data;
}
async post(url: string, data: unknown) {
const res = await this.client.post(url, data);
return res.data;
}
}
Step 3: Inject into Models
// models/blog.ts
import { inject, loader } from "@e7w/easy-model";
import { HttpSchema, type HttpClient } from "@/types/http";
export class BlogModel {
posts: Post[] = [];
@inject(HttpSchema)
private http?: HttpClient;
@loader.load(true)
@loader.once
async fetchPosts() {
this.posts = (await this.http?.get("/api/posts")) as Post[];
}
}
Step 4: Configure Container
// main.tsx
import { CInjection, config, Container } from "@e7w/easy-model";
import { MockHttp } from "./http/mock-http";
config(
<Container>
<CInjection schema={HttpSchema} ctor={MockHttp} />
</Container>
);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Project Structure
src/
├── types/
│ └── http.ts # Schema definitions
├── http/
│ ├── mock-http.ts # Mock implementation
│ └── axios-http.ts # Axios implementation
├── models/
│ ├── blog.ts
│ ├── user.ts
│ └── comment.ts
├── ioc/
│ └── container.ts # Container setup
└── main.tsx
Namespace Isolation
For multiple environments or tenants:
import { CInjection, config, Container } from "@e7w/easy-model";
import { AdminAuth } from "./auth/admin";
import { UserAuth } from "./auth/user";
config(
<>
<Container namespace="admin">
<CInjection schema={AuthSchema} ctor={AdminAuth} />
</Container>
<Container namespace="user">
<CInjection schema={AuthSchema} ctor={UserAuth} />
</Container>
</>
);
Then specify the namespace in your Model:
class DashboardModel {
@inject(AuthSchema, "admin")
adminAuth!: AuthService;
@inject(AuthSchema, "user")
userAuth!: AuthService;
}
Testing Strategy
Unit Tests
describe('BlogModel', () => {
let mockHttp: jest.Mocked<HttpClient>;
beforeEach(() => {
mockHttp = {
get: jest.fn(),
post: jest.fn(),
} as any;
config(
<Container>
<CInjection schema={HttpSchema} ctor={mockHttp} />
</Container>,
);
});
afterEach(() => {
clearNamespace();
});
test('fetches posts successfully', async () => {
const mockPosts = [{ id: '1', title: 'Test' }];
mockHttp.get.mockResolvedValue(mockPosts);
const { fetchPosts } = provide(BlogModel)();
await fetchPosts();
expect(mockHttp.get).toHaveBeenCalledWith('/api/posts');
});
});
Integration Tests
describe('BlogModel Integration', () => {
beforeEach(() => {
// Use real HTTP client for integration tests
config(
<Container>
<CInjection schema={HttpSchema} ctor={AxiosHttp} />
</Container>,
);
});
test('fetches real data', async () => {
const { fetchPosts } = provide(BlogModel)();
await fetchPosts();
// Tests hit real API
});
});
Comparison with Alternatives
| Feature | easy-model | InversifyJS | Manual DI |
|---|---|---|---|
| React Integration | ✅ Native | ❌ Adapter needed | ✅ |
| Zod Support | ✅ | ❌ | ❌ |
| Type Safety | ✅ Full | ✅ Full | ⚠️ Partial |
| Learning Curve | Low | Medium | Low |
| Boilerplate | Minimal | Medium | Variable |
When to Use DI
Use DI when:
- Building medium to large applications
- Multiple services need shared dependencies
- Testing is a priority
- Different environments require different implementations
Skip DI when:
- Building simple prototypes
- Services have minimal dependencies
- Team is unfamiliar with DI patterns
Conclusion
Dependency injection brings backend engineering best practices to React development. With easy-model's IoC container, you get:
- Service reuse without tight coupling
- Environment switching with one-line configuration
- Testability through easy mocking
- Type safety with Zod schema validation
For enterprise React applications, these benefits significantly improve maintainability and code quality.
GitHub: https://github.com/ZYF93/easy-model
Have you implemented DI in frontend projects? Share your approach and experiences!
Top comments (0)