Mastering Clean Architecture for Frontend: A Practical Guide with React & TypeScript

This article explains clean architecture concepts—domain, use case, and application layers—and demonstrates how to apply them in a frontend React/TypeScript project, covering design principles, layer responsibilities, dependency rules, advantages, costs, and concrete code examples for a cookie‑shop example.

Goodme Frontend Team
Goodme Frontend Team
Goodme Frontend Team
Mastering Clean Architecture for Frontend: A Practical Guide with React & TypeScript

Article Overview

Recently I gave a public talk on clean architecture for the frontend; this article outlines that talk and expands on it, providing useful links to the video, slides, source code, and a working demo.

Architecture and Design

Design is about splitting things into composable parts so they can be recombined later. – Rich Hickey

Good architecture should also aim for system scalability, allowing easy updates as requirements evolve.

Clean Architecture

Clean architecture separates responsibilities based on their closeness to the domain. The domain models the real‑world concepts, while the outer layers handle use cases, adapters, and external services.

Domain Layer

The domain layer defines entities and data transformations for the subject area (e.g., products, users, carts, orders). Domain entities are independent of UI frameworks and external services.

Application Layer

The application layer contains use cases (user scenarios) and ports that describe how the application expects to communicate with the outside world.

Send a request to the server.

Perform domain transformations.

Render the UI with the response.

Adapters Layer

Adapters translate incompatible external APIs into interfaces the application can use, reducing coupling. They are divided into driving adapters (e.g., UI events) and driven adapters (e.g., infrastructure services).

Dependency Rule

Only outer layers may depend on inner layers: the domain is independent, the application layer may depend on the domain, and outer layers may depend on anything.

Advantages of Clean Architecture

Separate domain : Core business logic is isolated, making testing easier.

Independent use cases : Business rules are decoupled from external services, allowing easy replacement of third‑party APIs.

Replaceable third‑party services : Adapters enable swapping implementations without affecting the core.

Costs of Clean Architecture

Time cost : Designing and implementing layers takes more effort than a quick direct call.

Verbosity : Small projects may feel over‑engineered.

Higher entry barrier : New developers need to understand the layered structure.

Increased bundle size : More files can enlarge the final JavaScript payload.

Cost Optimization

Pragmatic shortcuts—such as extracting the domain, obeying the dependency rule, and simplifying code splitting—can reduce time and size while preserving most benefits.

Designing the Application

We build a cookie‑shop frontend using React (but the approach works with any UI library). The domain includes entities for users, products, carts, and orders, each defined in TypeScript:

src/
|_domain/
  |_user.ts
  |_product.ts
  |_order.ts
  |_cart.ts
|_application/
  |_addToCart.ts
  |_authenticate.ts
  |_orderProducts.ts
  |_ports.ts
|_services/
  |_authAdapter.ts
  |_notificationAdapter.ts
  |_paymentAdapter.ts
  |_storageAdapter.ts
  |_api.ts
  |_store.tsx
|_lib/
|_ui/

Domain Entities

// domain/user.ts
export type UserName = string;
export type User = {
  id: UniqueId;
  name: UserName;
  email: Email;
  preferences: Ingredient[];
  allergies: Ingredient[];
};

// domain/product.ts
export type ProductTitle = string;
export type Product = {
  id: UniqueId;
  title: ProductTitle;
  price: PriceCents;
  toppings: Ingredient[];
};

// domain/cart.ts
import { Product } from "./product";
export type Cart = { products: Product[] };

// domain/order.ts
export type OrderStatus = "new" | "delivery" | "completed";
export type Order = {
  user: UniqueId;
  cart: Cart;
  created: DateTimeString;
  status: OrderStatus;
  total: PriceCents;
};

Domain Functions

// domain/user.ts
export function hasAllergy(user: User, ingredient: Ingredient): boolean {
  return user.allergies.includes(ingredient);
}
export function hasPreference(user: User, ingredient: Ingredient): boolean {
  return user.preferences.includes(ingredient);
}

// domain/cart.ts
export function addProduct(cart: Cart, product: Product): Cart {
  return { ...cart, products: [...cart.products, product] };
}
export function contains(cart: Cart, product: Product): boolean {
  return cart.products.some(({ id }) => id === product.id);
}

// domain/product.ts
export function totalPrice(products: Product[]): PriceCents {
  return products.reduce((total, { price }) => total + price, 0);
}

// domain/order.ts
export function createOrder(user: User, cart: Cart): Order {
  return {
    user: user.id,
    cart,
    created: new Date().toISOString(),
    status: "new",
    total: totalPrice(cart.products),
  };
}

Shared Kernel Types

// shared-kernel.d.ts
type Email = string;
type UniqueId = string;
type DateTimeString = string;
type PriceCents = number;

Application Layer – Use Cases

The checkout use case validates data, creates an order, attempts payment, notifies on failure, and stores the result.

// application/ports.ts
export interface PaymentService {
  tryPay(amount: PriceCents): Promise<boolean>;
}
export interface NotificationService {
  notify(message: string): void;
}
export interface OrdersStorageService {
  orders: Order[];
  updateOrders(orders: Order[]): void;
}

Use‑Case Implementation

// application/orderProducts.ts
export async function orderProducts(user: User, cart: Cart) {
  const order = createOrder(user, cart);
  const paid = await payment.tryPay(order.total);
  if (!paid) return notifier.notify("Oops! 🤷");
  const { orders } = orderStorage;
  orderStorage.updateOrders([...orders, order]);
  cartStorage.emptyCart();
}

Adapters

Payment, notification, and storage adapters implement the interfaces, e.g., a fake API for payment:

// services/paymentAdapter.ts
export function usePayment(): PaymentService {
  return { tryPay: (amount) => fakeApi(true) };
}

// services/api.ts
export function fakeApi<TResponse>(response: TResponse): Promise<TResponse> {
  return new Promise((res) => setTimeout(() => res(response), 450));
}

// services/notificationAdapter.ts
export function useNotifier(): NotificationService {
  return { notify: (msg) => window.alert(msg) };
}

UI Binding

A React component calls the use‑case via a custom hook:

// ui/components/Buy.tsx
export function Buy() {
  const { orderProducts } = useOrderProducts();
  async function handleSubmit(e) {
    e.preventDefault();
    setLoading(true);
    await orderProducts(user!, cart);
    setLoading(false);
  }
  return (
    <section>
      <h2>Checkout</h2>
      <form onSubmit={handleSubmit}>/* … */</form>
    </section>
  );
}

Hooks such as useOrderProducts retrieve the concrete adapters and expose the use‑case function, acting as a “bent” dependency injection.

Further Considerations

Discussed improvements like using value objects for price, feature‑sliced code organization, branded types for stronger type safety, and keeping domain functions pure by passing required data (e.g., timestamps) from the outer layer.

Overall, the article provides a concrete, layered example of applying clean architecture principles to a frontend React/TypeScript project, highlighting benefits, trade‑offs, and practical implementation details.

frontendDomain-Driven DesignClean Architecturesoftware designHexagonal Architecture
Goodme Frontend Team
Written by

Goodme Frontend Team

Regularly sharing the team's insights and expertise in the frontend field

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.