One of the most difficult questions to answer as a frontend developer is how to approach architecture within the scope of frontend applications. There’s often a lot of ambiguity around what "architecture" really means in this context.
Whether we realize it or not, every developer applies some architectural pattern in their projects. Common examples include Flux architecture, Component-Driven architecture, or Micro-frontend architecture. These are powerful and widely used, but they don’t always solve the deeper challenges we face in enterprise-scale applications, where maintaining clear boundaries between business logic and presentation becomes critical—especially in environments with constantly changing requirements.
Even when our codebase looks clean and modular, with hundreds of components, hooks, and pure functions, we often reach a point where it becomes hard to maintain. We realize there’s no real separation between what the application does (the business logic) and how it does it (the implementation and UI).
The Problem: Business Logic Hidden in Components
Let’s take a simple example — an e-commerce app with a small React component called ProductPrice that displays the price of a product.
interface Product {
name: string;
basePrice: number;
}
export const ProductPrice = ({ product }: { product: Product }) => {
// 💥 Business logic embedded inside the UI
const finalPrice = product.basePrice * 1.1; // Adding 10% tax
return (
<div>
<h3>{product.name}</h3>
<p>Final price: ${finalPrice.toFixed(2)}</p>
</div>
);
};
At first glance, this looks fine. But this component is already doing more than it should — it’s calculating prices (business logic) and rendering UI (presentation logic) at the same time.
Now imagine the business team introduces new requirements:
Each product can have its own tax rate and discount percentage based on category or promotions. This forces us to modify the component to handle new rules:
export const ProductPrice = ({ product }: { product: Product }) => {
// 💥 Logic grows and mixes with presentation
const finalPrice =
product.basePrice *
(1 - (product.discountPercentage ?? 0)) *
(1 + (product.taxRate ?? 0));
return (
<div>
<h3>{product.name}</h3>
<p>Final price: ${finalPrice.toFixed(2)}</p>
</div>
);
};
Again, not catastrophic—but imagine scaling this pattern across dozens of components. Soon you’ll have duplicated logic, tight coupling, and a fragile codebase where every change in the business rules requires modifying multiple UI components.
This violates Separation of Concerns and makes it hard to:
- Test business logic independently from the UI.
- Reuse logic across different parts of the app.
- Adapt to new requirements without breaking existing behavior.
The Solution: Clean Architecture
To solve this, we can apply Clean Architecture, a concept popularized by Uncle Bob (Robert C. Martin). Its goal is to build software that is modular, maintainable, and independent of frameworks or external details.
In Clean Architecture, the codebase is divided into layers, each with a single responsibility:
Entities → Use Cases → Interface Adapters → Frameworks/UI
Each layer depends inward, toward the domain, never outward.
This keeps your business logic isolated from technical details like React, APIs, or databases.
1. Entities (Domain Models)
Entities represent the core business concepts and rules of your system. They’re pure, framework-agnostic objects — they don’t depend on React, APIs, or external libraries.
For our example, let’s define the Product entity:
// domain/entities/Product.ts
export interface Product {
id: string;
name: string;
basePrice: number;
discountPercentage?: number;
taxRate?: number;
}
This entity describes what a product is, not how it’s used or displayed.
2. Use Cases (Application Logic)
Use cases represent what the application does — they orchestrate business logic by coordinating entities.
They’re the only layer allowed to manipulate entities directly.
For example, let’s define a use case that calculates the final price of a product:
// domain/usecases/CalculateProductPrice.ts
import { Product } from "../entities/Product";
export class CalculateProductPrice {
execute(product: Product): number {
const discount = product.discountPercentage ?? 0;
const tax = product.taxRate ?? 0;
return product.basePrice * (1 - discount) * (1 + tax);
}
}
And another use case to retrieve a product by ID (which will depend on an abstraction of a repository):
// domain/usecases/GetProductById.ts
import { Product } from "../entities/Product";
import { ProductRepository } from "../../infrastructure/repositories/ProductRepository";
export class GetProductById {
constructor(private readonly productRepo: ProductRepository) {}
async execute(id: string): Promise<Product | null> {
return await this.productRepo.getProductById(id);
}
}
Note that use cases depend only on abstractions (interfaces), never on concrete implementations. This aligns with the Dependency Inversion Principle.
3. Infrastructure (Adapters & Repositories)
The Infrastructure layer implements the low-level details: APIs, databases, HTTP clients, etc.
It provides concrete implementations of the abstractions defined in the domain.
Here’s an example using Supabase as our data source:
// infrastructure/repositories/ProductRepository.ts
import { Product } from "../../domain/entities/Product";
export interface ProductRepository {
getProductById(id: string): Promise<Product | null>;
}
// infrastructure/repositories/SupabaseProductAdapter.ts
import { SupabaseClient } from "@supabase/supabase-js";
import { Product } from "../../domain/entities/Product";
import { ProductRepository } from "./ProductRepository";
export class SupabaseProductAdapter implements ProductRepository {
constructor(private readonly client: SupabaseClient) {}
async getProductById(id: string): Promise<Product | null> {
const { data, error } = await this.client
.from("products")
.select("*")
.eq("id", id)
.single();
if (error) throw error;
return data as Product;
}
}
And the initialization of the Supabase client itself:
// infrastructure/supabase/initClient.ts
import { createClient } from "@supabase/supabase-js";
export const initSupabaseClient = (url: string, anonKey: string) =>
createClient(url, anonKey);
4. Frameworks / UI Layer (React)
Finally, at the outermost layer, we have the Frameworks/UI — in this case, React.
This layer should be responsible only for presenting data and handling user interactions.
// ui/components/ProductCard.tsx
import React, { useEffect, useState } from "react";
import { initSupabaseClient } from "@/infrastructure/supabase/initClient";
import { SupabaseProductAdapter } from "@/infrastructure/repositories/SupabaseProductAdapter";
import { GetProductById } from "@/domain/usecases/GetProductById";
import { CalculateProductPrice } from "@/domain/usecases/CalculateProductPrice";
import { Product } from "@/domain/entities/Product";
const supabaseClient = initSupabaseClient(
import.meta.env.VITE_SUPABASE_URL!,
import.meta.env.VITE_SUPABASE_KEY!
);
const productRepository = new SupabaseProductAdapter(supabaseClient);
const getProductByIdUseCase = new GetProductById(productRepository);
const calculateProductPriceUseCase = new CalculateProductPrice();
interface ProductCardProps {
productId: string;
}
export const ProductCard = ({ productId }: ProductCardProps) => {
const [product, setProduct] = useState<Product | null>(null);
useEffect(() => {
getProductByIdUseCase.execute(productId).then(setProduct).catch(console.error);
}, [productId]);
if (!product) return <div>Loading...</div>;
const finalPrice = calculateProductPriceUseCase.execute(product);
return (
<div className="product-card">
<h2>{product.name}</h2>
<p>Final price: ${finalPrice.toFixed(2)}</p>
</div>
);
};
Now, the UI only renders and calls use cases.
If the business rules for pricing change tomorrow, we’ll update only CalculateProductPrice.ts, without touching any React component.
Wrapping Up
Let’s summarize how each layer works:
- Entities: Core business models and rules => Product = Use Cases: Define application behavior (what the app does) => CalculateProductPrice, GetProductById
- Adapters / Infrastructure: Concrete implementations (how data is retrieved or stored) => SupabaseProductAdapter
- Frameworks / UI => Presentation layer using frameworks like React ProductCard component
While it may seem over-engineered for small projects, Clean Architecture shines in enterprise-grade applications or long-lived projects, especially when business rules evolve frequently.
By enforcing these boundaries, you build software that is:
- Modular — each part has a clear responsibility.
- Testable — business logic can be tested independently from UI.
- Maintainable — changes in requirements don’t ripple through unrelated code.
- Framework-agnostic — your core logic survives even if you switch from React to another framework.
Ultimately, Clean Architecture allows frontend developers to think beyond components — and start designing software that truly scales with complexity.
Top comments (0)