Skip to content

Angular DI vs TDI2

Angular revolutionized frontend development with enterprise-grade dependency injection, but came with significant complexity costs. TDI2 brings Angular’s architectural benefits to React with modern simplicity - achieving the same enterprise patterns without Angular’s learning curve.

Key Finding: TDI2 provides 90% of Angular’s architectural benefits with 50% less complexity

Section titled “Key Finding: TDI2 provides 90% of Angular’s architectural benefits with 50% less complexity”
// Angular's opinionated, comprehensive approach
@Component({
selector: 'product-catalog',
providers: [ProductService, CatalogService],
templateUrl: './product-catalog.component.html',
styleUrls: ['./product-catalog.component.scss']
})
export class ProductCatalogComponent implements OnInit, OnDestroy {
products$ = new BehaviorSubject<Product[]>([]);
loading = false;
constructor(
private productService: ProductService,
private catalogService: CatalogService,
private cdr: ChangeDetectorRef,
private router: Router,
private activatedRoute: ActivatedRoute
) {}
ngOnInit(): void {
// Complex lifecycle management
this.loadProducts();
this.setupSubscriptions();
}
ngOnDestroy(): void {
// Manual cleanup required
this.products$.complete();
// Subscription cleanup
}
private loadProducts(): void {
this.loading = true;
this.productService.getProducts().subscribe({
next: products => {
this.products$.next(products);
this.loading = false;
this.cdr.markForCheck();
},
error: error => {
console.error('Failed to load products:', error);
this.loading = false;
}
});
}
}
// TDI2's lightweight, React-native approach
function ProductCatalog({
productService, catalogService
}: {
productService: Inject<ProductServiceInterface>,
catalogService: Inject<CatalogServiceInterface>
}) {
const products = productService.state.products;
const loading = productService.state.loading;
useEffect(() => {
productService.loadProducts();
}, []);
return (
<div className="product-catalog">
{loading ? (
<ProductSkeleton />
) : (
<ProductGrid products={products} />
)}
</div>
);
}
// Complex but powerful hierarchical injection
@Injectable({ providedIn: 'root' })
export class ProductService {
constructor(
private http: HttpClient,
@Inject(API_CONFIG) private config: ApiConfig,
@Optional() private logger?: LoggerService,
@Self() private localCache?: CacheService
) {}
getProducts(): Observable<Product[]> {
return this.http.get<Product[]>(`${this.config.baseUrl}/products`)
.pipe(
tap(products => this.logger?.log(`Loaded ${products.length} products`)),
catchError(this.handleError.bind(this))
);
}
private handleError(error: HttpErrorResponse): Observable<never> {
this.logger?.error('Product loading failed:', error);
return throwError(() => new Error('Failed to load products'));
}
}
// Module configuration required
@NgModule({
providers: [
ProductService,
{ provide: API_CONFIG, useValue: environment.apiConfig },
{ provide: LoggerService, useClass: ConsoleLoggerService },
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor,
multi: true
}
]
})
export class ProductModule {}
// Component injection
@Component({
selector: 'product-details',
templateUrl: './product-details.component.html'
})
export class ProductDetailsComponent {
constructor(
private productService: ProductService,
@Inject(DOCUMENT) private document: Document,
private location: Location
) {}
}
// Simpler interface-based injection
interface ProductServiceInterface {
state: {
products: Product[];
currentProduct: Product | null;
loading: boolean;
error: string | null;
};
loadProducts(): Promise<void>;
loadProduct(id: string): Promise<void>;
}
@Service()
export class ProductService implements ProductServiceInterface {
state = {
products: [] as Product[],
currentProduct: null as Product | null,
loading: false,
error: null as string | null
};
constructor(
private httpClient: Inject<HttpClientInterface>,
private apiConfig: Inject<ApiConfigInterface>,
private logger: Inject<LoggerServiceInterface>
) {}
async loadProducts(): Promise<void> {
this.state.loading = true;
this.state.error = null;
try {
const products = await this.httpClient.get(`${this.apiConfig.baseUrl}/products`);
this.state.products = products;
this.logger.log(`Loaded ${products.length} products`);
} catch (error) {
this.state.error = 'Failed to load products';
this.logger.error('Product loading failed:', error);
} finally {
this.state.loading = false;
}
}
}
// Zero configuration - automatic resolution
function ProductDetails({
productService
}: {
productService: Inject<ProductServiceInterface>
}) {
const product = productService.state.currentProduct;
const loading = productService.state.loading;
if (loading) return <ProductDetailsSkeleton />;
if (!product) return <ProductNotFound />;
return (
<div className="product-details">
<h1>{product.name}</h1>
<p>{product.description}</p>
<span className="price">${product.price}</span>
</div>
);
}
@Injectable({ providedIn: 'root' })
export class ShoppingCartService {
private cartItems$ = new BehaviorSubject<CartItem[]>([]);
private total$ = new BehaviorSubject<number>(0);
constructor(
private productService: ProductService,
private userService: UserService
) {}
get items$(): Observable<CartItem[]> {
return this.cartItems$.asObservable();
}
get total$(): Observable<number> {
return this.total$.asObservable();
}
addItem(productId: string, quantity: number): Observable<void> {
return this.productService.getProduct(productId).pipe(
switchMap(product => {
const currentItems = this.cartItems$.value;
const existingItem = currentItems.find(item => item.productId === productId);
if (existingItem) {
existingItem.quantity += quantity;
} else {
currentItems.push({
productId,
productName: product.name,
price: product.price,
quantity
});
}
this.cartItems$.next([...currentItems]);
this.calculateTotal();
return this.saveCart();
})
);
}
private calculateTotal(): void {
const items = this.cartItems$.value;
const total = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
this.total$.next(total);
}
private saveCart(): Observable<void> {
return this.userService.isLoggedIn$.pipe(
switchMap(isLoggedIn =>
isLoggedIn
? this.http.post('/api/cart', this.cartItems$.value)
: of(null)
),
map(() => void 0)
);
}
}
@Service()
export class ShoppingCartService implements ShoppingCartServiceInterface {
state = {
items: [] as CartItem[],
total: 0,
itemCount: 0,
loading: false
};
constructor(
private productService: Inject<ProductServiceInterface>,
private userService: Inject<UserServiceInterface>,
private apiClient: Inject<ApiClientInterface>
) {}
async addItem(productId: string, quantity: number): Promise<void> {
this.state.loading = true;
try {
const product = await this.productService.getProduct(productId);
const existingItem = this.state.items.find(item => item.productId === productId);
if (existingItem) {
existingItem.quantity += quantity;
} else {
this.state.items.push({
productId,
productName: product.name,
price: product.price,
quantity
});
}
this.recalculateTotal();
if (this.userService.state.isLoggedIn) {
await this.saveCart();
}
} finally {
this.state.loading = false;
}
}
private recalculateTotal(): void {
this.state.total = this.state.items.reduce(
(sum, item) => sum + (item.price * item.quantity), 0
);
this.state.itemCount = this.state.items.reduce(
(count, item) => count + item.quantity, 0
);
}
private async saveCart(): Promise<void> {
await this.apiClient.post('/api/cart', this.state.items);
}
}
@Component({
selector: 'shopping-cart',
template: `
<div class="shopping-cart">
<h2>Shopping Cart</h2>
<div *ngIf="loading$ | async" class="loading">Loading...</div>
<div *ngFor="let item of items$ | async" class="cart-item">
<span>{{ item.productName }}</span>
<span>\${{ item.price }}</span>
<input
type="number"
[value]="item.quantity"
(change)="updateQuantity(item.productId, $event.target.value)"
/>
<button (click)="removeItem(item.productId)">Remove</button>
</div>
<div class="total">
Total: \${{ total$ | async | number:'1.2-2' }}
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ShoppingCartComponent implements OnInit, OnDestroy {
items$ = this.cartService.items$;
total$ = this.cartService.total$;
loading$ = this.cartService.loading$;
constructor(private cartService: ShoppingCartService) {}
ngOnInit(): void {
this.cartService.loadCart();
}
ngOnDestroy(): void {
// Cleanup handled by service
}
updateQuantity(productId: string, quantity: string): void {
const qty = parseInt(quantity, 10);
if (qty > 0) {
this.cartService.updateQuantity(productId, qty).subscribe();
}
}
removeItem(productId: string): void {
this.cartService.removeItem(productId).subscribe();
}
}
function ShoppingCart({
cartService
}: {
cartService: Inject<ShoppingCartServiceInterface>
}) {
const { items, total, loading } = cartService.state;
useEffect(() => {
cartService.loadCart();
}, []);
const handleQuantityChange = (productId: string, quantity: string) => {
const qty = parseInt(quantity, 10);
if (qty > 0) {
cartService.updateQuantity(productId, qty);
}
};
return (
<div className="shopping-cart">
<h2>Shopping Cart</h2>
{loading && <div className="loading">Loading...</div>}
{items.map(item => (
<div key={item.productId} className="cart-item">
<span>{item.productName}</span>
<span>${item.price}</span>
<input
type="number"
value={item.quantity}
onChange={(e) => handleQuantityChange(item.productId, e.target.value)}
/>
<button onClick={() => cartService.removeItem(item.productId)}>
Remove
</button>
</div>
))}
<div className="total">
Total: ${total.toFixed(2)}
</div>
</div>
);
}

Key Similarities: What TDI2 Learned from Angular ✅

Section titled “Key Similarities: What TDI2 Learned from Angular ✅”

Both frameworks recognize that services should contain business logic, not components.

Both use constructor injection to provide dependencies automatically.

Both promote programming against interfaces rather than implementations.

Both enforce clear boundaries between presentation and business logic.

Both make unit testing easier through dependency injection and service isolation.

Angular: Steep learning curve

  • RxJS observables
  • Complex lifecycle hooks
  • Hierarchical DI system
  • Module system
  • Change detection strategies

TDI2: Gentle learning curve

  • Familiar React patterns
  • Simple service interfaces
  • Automatic reactivity
  • No module configuration needed

Angular: High boilerplate

// Angular requires extensive ceremony
@Component({
selector: 'user-profile',
templateUrl: './user-profile.component.html',
styleUrls: ['./user-profile.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserProfileComponent implements OnInit, OnDestroy {
user$ = new BehaviorSubject<User | null>(null);
loading = false;
private destroy$ = new Subject<void>();
constructor(
private userService: UserService,
private cdr: ChangeDetectorRef
) {}
ngOnInit(): void {
this.userService.getCurrentUser()
.pipe(takeUntil(this.destroy$))
.subscribe(user => {
this.user$.next(user);
this.cdr.markForCheck();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

TDI2: Minimal boilerplate

// TDI2 is much more concise
function UserProfile({
userService
}: {
userService: Inject<UserServiceInterface>
}) {
const user = userService.state.currentUser;
useEffect(() => {
userService.loadCurrentUser();
}, []);
return <div>{user?.name}</div>;
}

Angular: Observable streams

  • Manual subscription management
  • Complex RxJS operators
  • Memory leak potential
  • Change detection complexity

TDI2: Reactive proxies

  • Automatic reactivity
  • No subscription management needed
  • Memory-safe by default
  • React’s natural rendering

Angular: Full framework

  • ~130KB+ minified + gzipped
  • Complete framework runtime
  • RxJS dependency
  • Complex change detection

TDI2: Lightweight library

  • ~15KB minified + gzipped
  • Minimal runtime overhead
  • Leverages React’s rendering
  • Build-time optimizations
describe('ProductService', () => {
let service: ProductService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
ProductService,
{ provide: API_CONFIG, useValue: mockApiConfig },
{ provide: LoggerService, useClass: MockLoggerService }
]
});
service = TestBed.inject(ProductService);
httpMock = TestBed.inject(HttpTestingController);
});
it('should load products', () => {
const mockProducts: Product[] = [
{ id: '1', name: 'Test Product', price: 10 }
];
service.getProducts().subscribe(products => {
expect(products).toEqual(mockProducts);
});
const req = httpMock.expectOne('/api/products');
expect(req.request.method).toBe('GET');
req.flush(mockProducts);
});
afterEach(() => {
httpMock.verify();
});
});
describe('ProductService', () => {
let productService: ProductService;
let mockHttpClient: jest.Mocked<HttpClientInterface>;
beforeEach(() => {
mockHttpClient = {
get: jest.fn()
};
productService = new ProductService(
mockHttpClient,
mockApiConfig,
mockLogger
);
});
test('should load products', async () => {
const mockProducts: Product[] = [
{ id: '1', name: 'Test Product', price: 10 }
];
mockHttpClient.get.mockResolvedValue(mockProducts);
await productService.loadProducts();
expect(productService.state.products).toEqual(mockProducts);
expect(productService.state.loading).toBe(false);
});
});
Angular ConceptTDI2 EquivalentMigration Strategy
@Injectable()@Service()Direct replacement
@Component()Function componentConvert template, inject services
OnInit lifecycleuseEffectMove initialization to useEffect
ObservablesReactive stateReplace with direct state access
ModulesDI ConfigurationCentralize service configuration
  1. Reduced Complexity: Eliminate RxJS learning curve
  2. Better Performance: React’s efficient rendering
  3. Smaller Bundle: Significant size reduction
  4. Easier Testing: Simpler mocking and setup
  5. Team Flexibility: React developers can contribute immediately
  • Building large enterprise applications from scratch
  • Team already experienced with Angular
  • Need comprehensive framework features (routing, forms, HTTP client)
  • Prefer opinionated, batteries-included approach
  • Existing React codebase that needs better architecture
  • Want Angular’s DI benefits without framework lock-in
  • Prefer lightweight, focused solutions
  • Team familiar with React ecosystem
  • Need gradual migration path

TDI2 successfully brings Angular’s most valuable architectural pattern - dependency injection - to React while eliminating much of Angular’s complexity. It provides a migration path for teams who want enterprise-grade architecture without adopting an entirely new framework.

Key Takeaway: You can have Angular’s architectural benefits in React without Angular’s complexity costs. TDI2 proves that dependency injection doesn’t require framework-level commitment.