Across Parts 1, 2, and 3, we laid down the foundational theory, modular directory layouts, and advanced engineering patterns—such as Dependency Injection with InversifyJS, Composition Roots, and tag-based cache invalidation—that power a scalable frontend application.
Now, we bring everything together into an actionable, real-world development workflow. How does an engineer build a new business feature from scratch without violating architectural boundaries? How do we integrate validation, handle async side effects, and connect UI components? And crucial for scaling teams, how do we automatically enforce these architectural boundaries in CI/CD pipelines so technical debt never creeps back in?
In this final installment of our series, we walk step-by-step through implementing an end-to-end Discount Coupon feature in our reference repository, React-Clean-Architecture, write comprehensive unit and integration test suites, and establish automated static analysis guardrails to protect your domain boundaries.
🏗️ The Feature Workflow: Inward-Outward Development
When adding a new feature to a Clean Architecture application, we execute an Inward-Outward development workflow. We start at the core Domain layer (business logic and invariants) and work our way outward through Application orchestration, Infrastructure implementation, and finally Presentation rendering.
flowchart LR
Step1\["1. Domain Layer\<br/>(Entities & Value Objects)"] --> Step2\["2. Application Layer\<br/>(Ports & Command Handlers)"]
Step2 --> Step3\["3. Infrastructure Layer\<br/>(Adapters & IoC Bindings)"]
Step3 --> Step4\["4. Presentation Layer\<br/>(ViewModel Hook & React Component)"]
By completing inner layers first, we ensure our business rules are fully validated and tested before writing a single line of React or API integration code.
🛠️ Step-by-Step Feature Implementation: Discount Coupons
We will build a feature that allows shoppers to apply a promotional discount coupon code to their active cart.
Step 1: Core Domain Modeling (Value Objects & Entity Invariants)
We start in the pure Domain Layer by creating an immutable CouponCode Value Object and extending our Cart Entity to handle discount rules.
1. The Value Object
The CouponCode encapsulates formatting and validation constraints.
export class CouponCode {
private constructor(
readonly value: string,
readonly discountPercentage: number
) {
if (!value || value.trim().length === 0) {
throw new Error("Coupon code cannot be empty.");
}
if (discountPercentage <= 0 || discountPercentage > 100) {
throw new Error("Discount percentage must be between 1 and 100.");
}
}
static create(value: string, discountPercentage: number): CouponCode {
return new CouponCode(value.toUpperCase().trim(), discountPercentage);
}
equals(other: CouponCode): boolean {
return this.value === other.value && this.discountPercentage === other.discountPercentage;
}
}
2. Domain Entity Mutation & Invariants
Next, we update the Cart entity. Domain entities guarantee that invalid state combinations never exist in memory.
export class Cart {
private constructor(
readonly id: string,
private _items: CartItem[],
private _appliedCoupon?: CouponCode
) {}
static create(id: string, items: CartItem[] = []): Cart {
return new Cart(id, items);
}
get items(): readonly CartItem[] {
return [...this._items];
}
get appliedCoupon(): CouponCode | undefined {
return this._appliedCoupon;
}
applyCoupon(coupon: CouponCode): void {
if (this._appliedCoupon && this._appliedCoupon.equals(coupon)) {
throw new Error("This coupon is already applied to the cart.");
}
this._appliedCoupon = coupon;
}
removeCoupon(): void {
this._appliedCoupon = undefined;
}
calculateTotal(): Money {
const subtotal = this._items.reduce(
(sum, item) => sum.add(item.totalPrice),
Money.create(0)
);
if (!this._appliedCoupon) {
return subtotal;
}
const discountMultiplier = (100 - this._appliedCoupon.discountPercentage) / 100;
const discountedAmount = subtotal.amount * discountMultiplier;
return Money.create(discountedAmount, subtotal.currency);
}
}
Step 2: Application Layer (Ports & Use Case Command Handler)
With domain logic secured, we define the application ports and build the ApplyCouponCommandHandler.
1. Defining the Verification Port
The application layer requires a service to verify coupon codes against external rules without knowing how verification is performed.
export interface ICouponVerificationService {
verify(code: string): Promise<CouponCode>;
}
2. Building the Command Handler
The ApplyCouponCommandHandler orchestrates fetching the cart, verifying the coupon, mutating the entity, persisting changes, and invalidating relevant cache tags.
export interface ApplyCouponInput {
cartId: string;
code: string;
}
@injectable()
export class ApplyCouponCommandHandler {
constructor(
@inject(TYPES.CARTS_REPOSITORY) private readonly cartRepository: ICartRepository,
@inject(TYPES.COUPON_SERVICE) private readonly couponService: ICouponVerificationService,
@inject(TYPES.CACHE) private readonly cacheManager: CacheManager
) {}
async execute(input: ApplyCouponInput): Promise<void> {
const cart = await this.cartRepository.getById(input.cartId);
if (!cart) {
throw new Error(`Cart with ID "${input.cartId}" was not found.`);
}
// 1. Verify coupon code via external service port
const coupon = await this.couponService.verify(input.code);
// 2. Enforce entity domain rules
cart.applyCoupon(coupon);
// 3. Persist updated domain state
await this.cartRepository.save(cart);
// 4. Trigger tag-based cache invalidation for reactive UI updates
this.cacheManager.invalidateTags(["cart", "pricing"]);
}
}
Step 3: Infrastructure Layer (Adapter & IoC Container Setup)
Now we fulfill the abstract contract by implementing the concrete HTTP adapter and registering bindings in the Composition Root.
1. Concrete REST Adapter
export @injectable()
class ApiCouponVerificationService implements ICouponVerificationService {
async verify(code: string): Promise<CouponCode> {
const response = await fetch(`/api/v1/coupons/verify?code=${encodeURIComponent(code)}`);
if (!response.ok) {
throw new Error("Invalid or expired discount coupon code.");
}
const data = await response.json();
return CouponCode.create(data.code, data.discount_percentage);
}
}
2. Registering InversifyJS Symbols & Container Bindings
export const TYPES = {
// Existing identifiers...
COUPON_SERVICE: Symbol.for("COUPON_SERVICE"),
APPLY_COUPON_COMMAND_HANDLER: Symbol.for("APPLY_COUPON_COMMAND_HANDLER"),
};
container.bind<ICouponVerificationService>(TYPES.COUPON_SERVICE).to(ApiCouponVerificationService);
container.bind<ApplyCouponCommandHandler>(TYPES.APPLY_COUPON_COMMAND_HANDLER).to(ApplyCouponCommandHandler);
Step 4: Presentation Layer (Custom Hook & Thin View Component)
Finally, we connect our application layer to React components through a custom ViewModel hook.
1. The Custom ViewModel Hook
export function useApplyCouponViewModel() {
const handler = useDependency<ApplyCouponCommandHandler>(TYPES.APPLY_COUPON_COMMAND_HANDLER);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const applyCoupon = async (cartId: string, code: string) => {
setLoading(true);
setError(null);
setSuccess(false);
try {
await handler.execute({ cartId, code });
setSuccess(true);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
return { applyCoupon, loading, error, success };
}
2. The React View Component
The UI component contains zero business logic; it merely collects user input and renders state.
export const CouponInputForm: React.FC<{ cartId: string }> = ({ cartId }) => {
const [code, setCode] = useState("");
const { applyCoupon, loading, error, success } = useApplyCouponViewModel();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (code.trim()) {
applyCoupon(cartId, code);
}
};
return (
<div className="coupon-container">
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="PROMO2026"
disabled={loading}
className="border p-2 rounded"
/>
<button type="submit" disabled={loading || !code.trim()} className="btn-primary">
{loading ? "Applying..." : "Apply Coupon"}
</button>
</form>
{error && <p className="text-red-500 text-sm mt-1">{error}</p>}
{success && <p className="text-green-500 text-sm mt-1">Coupon successfully applied!</p>}
</div>
);
};
🧪 Comprehensive Feature Verification & Testing
Because we decoupled domain logic and use cases from UI frameworks, we can thoroughly verify this feature using fast unit and integration tests.
1. Domain Unit Test (Zero Mocks)
describe("Cart Entity - Coupon Discount Invariants", () => {
it("should apply percentage discount correctly to cart total", () => {
const cart = Cart.create("cart-1");
const item = CartItem.create("prod-1", Money.create(100, "USD"), 1);
const coupon = CouponCode.create("SAVE20", 20);
cart.addItem(item);
cart.applyCoupon(coupon);
expect(cart.calculateTotal().amount).toBe(80);
});
it("should prevent applying the same coupon code twice", () => {
const cart = Cart.create("cart-1");
const coupon = CouponCode.create("SAVE20", 20);
cart.applyCoupon(coupon);
expect(() => cart.applyCoupon(coupon)).toThrow("This coupon is already applied to the cart.");
});
});
2. Application Integration Test (In-Memory Fake)
describe("ApplyCouponCommandHandler", () => {
it("should process coupon application and invalidate cache tags", async () => {
const container = createApplicationContainer("test");
const repo = container.get<ICartRepository>(TYPES.CARTS_REPOSITORY);
// Seed initial cart
await repo.save(Cart.create("cart-100"));
const handler = container.get<ApplyCouponCommandHandler>(TYPES.APPLY_COUPON_COMMAND_HANDLER);
await handler.execute({ cartId: "cart-100", code: "SAVE20" });
const updatedCart = await repo.getById("cart-100");
expect(updatedCart?.appliedCoupon?.value).toBe("SAVE20");
});
});
🔒 Enforcing Architectural Rules & Automated Guardrails
Documenting architectural guidelines in a README.md is rarely enough. As engineering teams scale, developers working under tight deadlines will accidentally import infrastructure adapters into UI components or reference React hooks inside domain entities.
To prevent boundary decay, we implement automated static analysis guardrails using ESLint rules and Dependency Cruiser.
1. Automated Boundary Checks via ESLint
We configure eslint-plugin-import with restricted paths to enforce the Dependency Rule at compile time:
// .eslintrc.json
{
"plugins": ["import"],
"rules": {
"import/no-restricted-paths": [
"error",
{
"zones": [
{
"target": "./src/modules/*/domain",
"from": [
"./src/modules/*/application",
"./src/modules/*/infrastructure",
"./src/modules/*/presentation"
],
"message": "ARCHITECTURAL VIOLATION: Domain Layer cannot import from outer layers (Application, Infrastructure, or Presentation)."
},
{
"target": "./src/modules/*/application",
"from": [
"./src/modules/*/infrastructure",
"./src/modules/*/presentation"
],
"message": "ARCHITECTURAL VIOLATION: Application Layer cannot import from Infrastructure or Presentation layers."
},
{
"target": "./src/modules/*/infrastructure",
"from": ["./src/modules/*/presentation"],
"message": "ARCHITECTURAL VIOLATION: Infrastructure Layer cannot import from Presentation layer."
}
]
}
]
}
}
If a developer accidentally attempts to import an HTTP client into a Domain Entity, the build fails immediately in local development and CI/CD pipelines.
2. Module Boundary Enforcement Matrix
To keep team members aligned, we enforce the following dependency access rules across all modules:
| Source Layer | Allowed Imports | Forbidden Imports |
|---|---|---|
| Domain | Pure TypeScript utilities, internal Domain Entities / Value Objects. | Application, Infrastructure, Presentation, React, HTTP clients. |
| Application | Domain Layer, Application Ports, InversifyJS decorators. | Infrastructure Adapters, Presentation, UI Hooks, Browser APIs. |
| Infrastructure | Domain, Application Ports, External Libraries (Axios, IndexedDB). | Presentation Layer, React Components, Custom UI Hooks. |
| Presentation | Application Handlers, DTOs, React, UI components. | Direct Concrete Infrastructure Adapters (ApiRepository). |
🏁 Series Conclusion & Architectural Summary
Over this 4-part series, we transitioned from fragile, framework-coupled React applications to a resilient, enterprise-ready frontend system:
- Part 1: Diagnosed accidental technical coupling and established the fundamental Dependency Rule.
- Part 2: Structured project directories into pure Domain, Application (CQRS), Infrastructure, and Presentation layers.
- Part 3: Mastered production patterns including Composition Roots, Dependency Injection with InversifyJS, tag-based caching, and in-memory fakes.
- Part 4: Demonstrated a practical feature workflow and established automated ESLint guardrails to maintain architectural integrity at scale.
By decoupling your business domain from volatile outer drivers, UI frameworks, and state containers, your codebase stays maintainable, simple to test, and adaptable to future technology shifts.

Top comments (0)