In Part 1 and Part 2, we established why frontend architectures collapse over time and how to physically structure a domain-driven codebase into isolated layers: Domain, Application, Infrastructure, and Presentation.
However, separating code into clean classes and abstract interfaces creates a practical challenge: How do we instantiate and wire these dependencies together at runtime without creating hard-coded couplings? Furthermore, how do we handle state caching, asynchronous side effects, and lightning-fast test execution without compromising our architectural boundaries?
In this third installment, we dive deep into the advanced production patterns featured in our reference implementation, React-Clean-Architecture: Dependency Injection via InversifyJS, Composition Roots for client vs. server environments, tag-based cache invalidation, and isolated testing strategies.
🏗️ Architectural Dependency & Control Flow
To understand how these advanced patterns interact, it is helpful to visualize the distinction between compile-time dependencies and runtime control flow across the application boundaries:
flowchart TB
subgraph Presentation["PRESENTATION LAYER"]
React["React View / UI Component"]
ViewModel["Custom ViewModel Hook"]
React -->|"uses"| ViewModel
end
ViewModel -->|"resolves via IoC Container"| Handlers
subgraph Application["APPLICATION LAYER"]
Handlers["Command / Query Handlers"]
Ports["Primary Ports"]
Domain["Domain Entities / Value Objects<br/>(Pure TypeScript - Zero External Dependencies)"]
Handlers -->|"implements"| Ports
Handlers -->|"orchestrates"| Domain
Ports -->|"depends on"| Domain
end
Ports -.->|"Inversion of Control"| Infra
subgraph Infrastructure["INFRASTRUCTURE LAYER"]
ApiRepo["ApiCartRepository"]
Cache["CacheManager"]
Composition["Composition Root / InversifyJS Container Setup"]
ICart["ICartRepository"]
ApiRepo -->|"implements Port"| ICart
end
Notice how the Infrastructure Layer points inward by implementing the ports defined by the Application Layer, fulfilling the Dependency Inversion Principle.
🔌 Pattern 1: Dependency Injection & The Composition Root
When using abstract ports (e.g., ICartRepository), application code or custom hooks should never manually instantiate infrastructure adapters with statements like new ApiCartRepository(). Doing so reintroduces direct coupling to concrete infrastructure and breaks testability.
Instead, we use Inversion of Control (IoC) powered by InversifyJS to resolve dependencies dynamically at runtime.
1. Defining Symbols & Binding Contracts
We define unique Symbol identifiers for our application ports and services to guarantee type safety during dependency lookup:
export const TYPES = {
// Ports / Repositories
CARTS_REPOSITORY: Symbol.for("CARTS_REPOSITORY"),
PRODUCTS_CATALOG_SERVICE: Symbol.for("PRODUCTS_CATALOG_SERVICE"),
// Command & Query Handlers
ADD_ITEM_TO_CART_COMMAND_HANDLER: Symbol.for("ADD_ITEM_TO_CART_COMMAND_HANDLER"),
GET_CART_SUMMARY_QUERY_HANDLER: Symbol.for("GET_CART_SUMMARY_QUERY_HANDLER"),
// Infrastructure Services
CACHE: Symbol.for("CACHE"),
HTTP_CLIENT: Symbol.for("HTTP_CLIENT"),
};
2. The Composition Root
The Composition Root is the single location in your application where the object graph is constructed. It is the only module in the entire system allowed to import concrete infrastructure adapters.
export function createApplicationContainer(environment: "production" | "test" | "ssr") {
const container = new Container({ defaultScope: "Singleton" });
// 1. Core Infrastructure Services
container.bind<CacheManager>(TYPES.CACHE).to(CacheManager);
// 2. Bind Infrastructure Adapters based on execution environment
if (environment === "production") {
container.bind<ICartRepository>(TYPES.CARTS_REPOSITORY).to(ApiCartRepository);
} else if (environment === "ssr") {
// SSR environment might use a direct internal API adapter or specialized cache
container.bind<ICartRepository>(TYPES.CARTS_REPOSITORY).to(ApiCartRepository);
} else {
// In-memory fake repository for lightning-fast testing and offline development
container.bind<ICartRepository>(TYPES.CARTS_REPOSITORY).to(InMemoryCartRepository);
}
// 3. Bind Application Use Case Handlers
container.bind<AddItemToCartCommandHandler>(
TYPES.ADD_ITEM_TO_CART_COMMAND_HANDLER
).to(AddItemToCartCommandHandler);
container.bind<GetCartSummaryQueryHandler>(
TYPES.GET_CART_SUMMARY_QUERY_HANDLER
).to(GetCartSummaryQueryHandler);
return container;
}
3. Exposing IoC to React via Context
To consume these dependencies inside React without coupling presentation code to InversifyJS directly, we wrap our application in a lightweight Dependency Injection Provider:
import React, { createContext, useContext } from "react";
import { Container } from "inversify";
const DependencyInjectionContext = createContext<Container null |>(null);
export const DependencyInjectionProvider: React.FC<{
container: Container;
children: React.ReactNode;
}> = ({ container, children }) => (
<DependencyInjectionContext.Provider value="{container}">
{children}
</DependencyInjectionContext.Provider>
);
export function useDependency<T>(serviceIdentifier: symbol): T {
const container = useContext(DependencyInjectionContext);
if (!container) {
throw new Error("useDependency must be used within a DependencyInjectionProvider");
}
return container.get<T>(serviceIdentifier);
}
By changing the environment flag passed to createApplicationContainer, we can swap the entire underlying infrastructure (REST API vs. IndexedDB vs. In-Memory) across the application without modifying a single UI component or business rule.
⚡ Pattern 2: Tag-Based Cache Invalidation
One of the hardest problems in rich frontend applications is keeping local UI state synchronized with underlying domain mutations without over-fetching network resources or writing imperative state updates across dozens of components.
In React-Clean-Architecture, query handlers manage local caching using Tag-Based Invalidation. Queries tag the domain entities they retrieve, and Commands emit architectural invalidation tags upon successful mutations.
1. The Query Handler with Tag Metadata
export interface CartSummaryDTO {
id: string;
itemCount: number;
totalFormatted: string;
}
export class GetCartSummaryQueryHandler {
readonly cacheTags = ["cart", "pricing"];
constructor(private readonly cartRepository: ICartRepository) {}
async execute(cartId: string): Promise<CartSummaryDTO> {
const cart = await this.cartRepository.getById(cartId);
if (!cart) {
throw new Error("Cart not found");
}
return {
id: cart.id,
itemCount: cart.items.reduce((sum, item) => sum + item.quantity.value, 0),
totalFormatted: `$${cart.calculateTotal().amount.toFixed(2)}`,
};
}
}
2. Infrastructure Cache Manager
When a write operation like AddItemToCartCommandHandler succeeds, the infrastructure cache manager processes the invalidation tags:
type InvalidationCallback = () => void;
export class CacheManager {
private cache = new Map<string, { data: unknown; tags: string[] }>();
private subscribers = new Map<string, Set<InvalidationCallback>>();
set(key: string, data: unknown, tags: string[]): void {
this.cache.set(key, { data, tags });
}
get<T>(key: string): T | null {
return (this.cache.get(key)?.data as T) || null;
}
subscribe(tag: string, callback: InvalidationCallback): () => void {
if (!this.subscribers.has(tag)) {
this.subscribers.set(tag, new Set());
}
this.subscribers.get(tag)!.add(callback);
return () => {
this.subscribers.get(tag)?.delete(callback);
};
}
invalidateTags(tagsToInvalidate: string[]): void {
for (const [key, entry] of this.cache.entries()) {
const hasMatchingTag = entry.tags.some((tag) => tagsToInvalidate.includes(tag));
if (hasMatchingTag) {
this.cache.delete(key);
}
}
// Notify active UI subscribers
for (const tag of tagsToInvalidate) {
const callbacks = this.subscribers.get(tag);
if (callbacks) {
callbacks.forEach((cb) => cb());
}
}
}
}
3. Bridging Caching to Presentation via Custom ViewModel Hooks
React components consume these use-case handlers through clean custom hooks that encapsulate execution lifecycle and auto-subscribe to tag invalidations:
export function useCartSummaryViewModel(cartId: string) {
const queryHandler = useDependency<GetCartSummaryQueryHandler>(TYPES.GET_CART_SUMMARY_QUERY_HANDLER);
const cacheManager = useDependency<CacheManager>(TYPES.CACHE);
const [data, setData] = useState<CartSummaryDTO null |>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<Error null |>(null);
const fetchSummary = useCallback(async () => {
setLoading(true);
try {
const cacheKey = `cart_summary_${cartId}`;
const cached = cacheManager.get<CartSummaryDTO>(cacheKey);
if (cached) {
setData(cached);
setLoading(false);
return;
}
const result = await queryHandler.execute(cartId);
cacheManager.set(cacheKey, result, queryHandler.cacheTags);
setData(result);
setError(null);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
}, [cartId, queryHandler, cacheManager]);
useEffect(() => {
fetchSummary();
// Subscribe to cache invalidation tags
const unsubscribes = queryHandler.cacheTags.map((tag) =>
cacheManager.subscribe(tag, fetchSummary)
);
return () => unsubscribes.forEach((unsub) => unsub());
}, [fetchSummary, cacheManager, queryHandler.cacheTags]);
return { data, loading, error, refresh: fetchSummary };
}
When AddItemToCartCommandHandler completes, it requests an invalidation of the "cart" tag. Any active React hook subscribed to queries tagged with "cart" automatically triggers a background re-fetch, keeping the UI completely in sync without manual state syncing across React components.
🧪 Pattern 3: Lightning-Fast Isolated Testing Strategies
A major benefit of decoupling domain rules and use cases from React and browser APIs is the drastic impact on test suite execution speed and reliability.
We categorize testing into two distinct tiers:
1. Pure Domain Unit Tests (Zero Mocks)
Domain entities and value objects are pure TypeScript. They require no mocking libraries (jest.fn()), JSDOM, or React testing utilities.
describe("Cart Entity (Domain Unit Test)", () => {
it("should aggregate quantity when adding duplicate items", () => {
const cart = Cart.create("cart-1");
const itemA = CartItem.create("prod-100", Money.create(50), 1);
const itemB = CartItem.create("prod-100", Money.create(50), 2);
cart.addItem(itemA);
cart.addItem(itemB);
expect(cart.items).toHaveLength(1);
expect(cart.items[0].quantity.value).toBe(3);
expect(cart.calculateTotal().amount).toBe(150);
});
it("should throw an error when adding money with mismatched currencies", () => {
const usd = Money.create(100, "USD");
const eur = Money.create(100, "EUR");
expect(() => usd.add(eur)).toThrow("Currency mismatch.");
});
});
2. Use Case Handler Tests with In-Memory Fakes
Instead of mocking HTTP network responses with libraries like MSW or intercepting fetch calls, application use-case handlers are tested using fast In-Memory Fake Adapters plugged directly into the test IoC container.
describe("AddItemToCartCommandHandler (Application Integration Test)", () => {
it("should orchestrate cart item addition using fake infrastructure", async () => {
// 1. Arrange: Instantiate container configured for testing
const container = createApplicationContainer("test");
const fakeRepo = container.get<ICartRepository>(TYPES.CARTS_REPOSITORY);
// Seed initial state into the in-memory repository
await fakeRepo.save(Cart.create("cart-99"));
const handler = container.get<AddItemToCartCommandHandler>(TYPES.ADD_ITEM_TO_CART_COMMAND_HANDLER);
// 2. Act: Execute Command
await handler.execute({ cartId: "cart-99", productId: "prod-1", quantity: 2 });
// 3. Assert: Verify state in fake persistence
const updatedCart = await fakeRepo.getById("cart-99");
expect(updatedCart?.items).toHaveLength(1);
expect(updatedCart?.items[0].quantity.value).toBe(2);
});
});
Because these tests execute entirely in Node/Vite without JSDOM overhead or actual HTTP networks, hundreds of business logic scenarios run in fractions of a second—eliminating flaky CI builds entirely.
🛠️ Summary of Advanced Engineering Patterns
| Pattern | Problem Solved | Clean Architecture Mechanism |
|---|---|---|
| Composition Root & IoC | Prevents direct coupling to concrete infrastructure drivers. | InversifyJS container binding abstract ports to adapters at application boot. |
| Tag-Based Caching | Prevents state drift and over-fetching after state mutations. | Queries register tags; Commands trigger tag invalidations through event handlers. |
| In-Memory Fakes | Eliminates slow, brittle network mocks in unit/integration tests. | Swap real API adapters for in-memory repositories in test containers. |
| Pure Domain Testing | Eliminates DOM setup overhead and test flakiness. | Tests execute against pure TypeScript classes without JSDOM or framework dependencies. |
📢 Up Next...
We have explored the architectural principles, directory structures, and advanced production patterns. But how does this work in day-to-day developer workflows when building a new feature from scratch?
Coming up next in Part 4: Practical Guide — Adding Features & Architectural Rules. We will step through adding a brand-new feature to an existing Clean Architecture codebase—from domain modeling down to registering handlers and connecting UI components—and outline automated guardrails (ESLint rules, boundary checks) to enforce these boundaries as your engineering team scales.
Explore the full reference codebase on GitHub:
Top comments (0)