close

DEV Community

Jorge Castillo
Jorge Castillo

Posted on

Part 4 — Practical Guide: Adding Features & Architectural Rules

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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"]);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

🧪 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.");
  });
});
Enter fullscreen mode Exit fullscreen mode

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");
  });
});
Enter fullscreen mode Exit fullscreen mode

🔒 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."
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

📦 Explore the Reference Implementation

👉 github.com/schorts99/React-Clean-Architecture

Top comments (0)