DEV Community

İbrahim Turan
İbrahim Turan

Posted on

Clean Architecture in Modern Frontend Development: A Comprehensive Guide

In today's frontend world, we're constantly building more complex applications where business logic, UI components, and framework-specific code get tangled together, making our systems rigid and hard to maintain. I love how Robert C. Martin puts it: "Software was invented to be 'soft.' It was intended to be a way to easily change the behavior of machines. If we'd wanted the behavior of machines to be hard to change, we would have called it hardware." Let's explore how Clean Architecture principles can actually work in modern frontend development, with real patterns and practices that lead to maintainable, testable, and framework-independent applications.The Reality of Technical Debt and Market Pressure We've all heard this one before: "We can clean it up later; we just have to get to market first!" But let's be real - things never get cleaned up later because market pressures don't just disappear. "The fact is that making messes is always slower than staying clean, no matter which time scale you're using."

The Reality of Technical Debt and Market Pressure

"These developers buy into a familiar lie: 'We can clean it up later; we just have to get to market first!' Of course, things never do get cleaned up later because market pressures never abate."

"The fact is that making messes is always slower than staying clean, no matter which time scale you are using."

Clean Architecture Boundaries At every architectural boundary, you'll likely find the Humble Object pattern lurking nearby. Here's how that looks in practice:

// ❌ Quick and Dirty Implementation
const ProductPage: React.FC = () => {
  const [product, setProduct] = useState<Product | null>(null);
  const [relatedProducts, setRelatedProducts] = useState<Product[]>([]);
  const [cart, setCart] = useState<Cart | null>(null);
  const [error, setError] = useState<string | null>(null);

  // Messy side effects
  useEffect(() => {
    fetch(`/api/products/${id}`)
      .then(res => res.json())
      .then(setProduct)
      .catch(err => setError(err.message));
  }, [id]);

  // Business logic mixed with UI logic
  const addToCart = async () => {
    try {
      const response = await fetch('/api/cart', {
        method: 'POST',
        body: JSON.stringify({ productId: product?.id }),
      });
      const updatedCart = await response.json();
      setCart(updatedCart);
      // Direct DOM manipulation
      window.alert('Added to cart!');
    } catch (err) {
      setError((err as Error).message);
    }
  };
};

// ✅ Clean, Modular Implementation
// Domain Entity
interface Product {
  id: string;
  name: string;
  price: Money;
  inventory: Inventory;
}

// Use Case / Application Service
class ProductService {
  constructor(private readonly productRepo: ProductRepository) {}

  async getProduct(id: string): Promise<Product> {
    return this.productRepo.findById(id);
  }

  async getRelatedProducts(product: Product): Promise<Product[]> {
    return this.productRepo.findRelated(product);
  }
}

// Repository Interface
interface ProductRepository {
  findById(id: string): Promise<Product>;
  findRelated(product: Product): Promise<Product[]>;
}

// Custom Hook (Framework-specific adapter)
const useProduct = (id: string) => {
  return useQuery(['product', id], () => productService.getProduct(id));
};

// Clean Component
const ProductPage: React.FC = () => {
  const { data: product, isLoading, error } = useProduct(id);
  const { data: relatedProducts } = useRelatedProducts(product);
  const { addToCart, isAdding } = useCartMutation();

  if (isLoading) return <ProductSkeleton />;
  if (error) return <ErrorBoundary error={error} />;

  return (
    <ProductLayout>
      <ProductDetails product={product} />
      <AddToCartButton 
        onAdd={() => addToCart(product.id)} 
        isLoading={isAdding} 
      />
      <RelatedProducts products={relatedProducts} />
    </ProductLayout>
  );
};
Enter fullscreen mode Exit fullscreen mode

Architecture and Framework Independence

"The Web is a delivery mechanism... and your architecture should treat it as such."

"If the framework wants you to derive your business objects from its base classes, say no! Derive proxies instead, and keep those proxies in components that are plugins to your business rules."

// Domain Entities (Framework Independent)
interface Order {
  id: string;
  items: OrderItem[];
  total: Money;
  status: OrderStatus;
}

// Business Rules (Pure TypeScript)
class OrderCalculator {
  calculateTotal(items: OrderItem[]): Money {
    return items.reduce(
      (total, item) => total.add(item.price.multiply(item.quantity)),
      Money.zero()
    );
  }

  applyDiscount(order: Order, discount: Discount): Order {
    return {
      ...order,
      total: order.total.subtract(this.calculateDiscount(order, discount))
    };
  }
}

// Framework Adapter (React-specific)
const useOrder = (orderId: string) => {
  const calculator = new OrderCalculator();
  const { data: order } = useQuery(['order', orderId], fetchOrder);

  return {
    order,
    applyDiscount: (discount: Discount) => 
      calculator.applyDiscount(order!, discount),
    // other order operations...
  };
};

// UI Component (Framework-specific)
const OrderSummary: React.FC<{ orderId: string }> = ({ orderId }) => {
  const { order, applyDiscount } = useOrder(orderId);
  // React-specific rendering logic
};
Enter fullscreen mode Exit fullscreen mode

Clean Architecture Boundaries

"At each architectural boundary, we are likely to find the Humble Object pattern lurking somewhere nearby."

// Domain Layer (Business Rules)
interface PaymentProcessor {
  process(payment: Payment): Promise<PaymentResult>;
}

// Application Layer (Use Cases)
class PaymentService {
  constructor(private processor: PaymentProcessor) {}

  async processPayment(order: Order): Promise<PaymentResult> {
    // Business logic for payment processing
    return this.processor.process({
      amount: order.total,
      currency: order.currency,
      orderId: order.id
    });
  }
}

// Infrastructure Layer (External Services)
class StripePaymentProcessor implements PaymentProcessor {
  constructor(private stripe: Stripe) {}

  async process(payment: Payment): Promise<PaymentResult> {
    const result = await this.stripe.charges.create({
      amount: payment.amount.value,
      currency: payment.currency,
      metadata: { orderId: payment.orderId }
    });
    return this.mapToPaymentResult(result);
  }
}

// Presentation Layer (React Components)
const CheckoutButton: React.FC<{ order: Order }> = ({ order }) => {
  const paymentService = usePaymentService();
  const mutation = useMutation(
    () => paymentService.processPayment(order),
    {
      onSuccess: (result) => {
        // Handle success
      },
      onError: (error) => {
        // Handle error
      }
    }
  );

  return (
    <Button 
      onClick={() => mutation.mutate()} 
      loading={mutation.isLoading}
    >
      Pay Now
    </Button>
  );
};
Enter fullscreen mode Exit fullscreen mode

Testing and Dependency Management

"Don't depend on things you don't need."

Testing and Dependency Management Let's talk about dependencies - specifically, why you shouldn't depend on things you don't need.

// ❌ Hard to Test Component with Direct Dependencies
const UserProfile: React.FC = () => {
  const [user, setUser] = useState<User>();

  useEffect(() => {
    axios.get('/api/user').then(res => setUser(res.data));
  }, []);

  return <div>{user?.name}</div>;
};

// ✅ Testable Component with Injected Dependencies
interface UserProfileProps {
  userService: UserService;
}

const UserProfile: React.FC<UserProfileProps> = ({ userService }) => {
  const { data: user } = useQuery(
    'user',
    () => userService.getCurrentUser()
  );

  return <div>{user?.name}</div>;
};

// Easy to test with mock service
describe('UserProfile', () => {
  it('displays user name', async () => {
    const mockService = {
      getCurrentUser: jest.fn().mockResolvedValue({ name: 'John' })
    };

    render(<UserProfile userService={mockService} />);

    expect(await screen.findByText('John')).toBeInTheDocument();
  });
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

"Good architects are careful to separate details from policy, and then decouple the policy from the details so thoroughly that the policy has no knowledge of the details."

Conclusion Great architects know how to separate details from policy, and then decouple them so thoroughly that the policy has no clue about the details. This guide shows you how to:

  • Keep your code framework-independent
  • Make testing a breeze
  • Handle dependencies like a pro
  • Separate concerns properly
  • Build applications that can actually scale

Remember, these aren't rigid rules - they're tools. Use them when they make sense for your project.Want to dive deeper into any of these topics? Just let me know!

This article created with the help of AI using my notes from the Clean Architecture book

Top comments (1)

Collapse
 
mer_gkalp_5a5f86265a2f6 profile image
Ömer Gökalp

Thnx for the article. ❤️