Files

11 KiB

Kött Gård — Clean Architecture Guide

This document explains what we changed, why, and how to work in the new structure.


What is Clean Architecture?

Clean Architecture splits code into layers. Inner layers hold business rules. Outer layers hold frameworks (React, Next.js, Zustand, localStorage).

Golden rule: Dependencies point inward only.

Presentation  →  Application  →  Domain
Infrastructure  →  Application  →  Domain

Domain imports NOTHING from outside itself.

Before vs After

Before (mixed responsibilities)

page.tsx          → filter products, calculate delivery fee, create orders
store/cart.ts     → persistence + merge logic + totals
lib/products.ts   → data + queries in one file

Problems:

  • Delivery fee total > 500 ? 0 : 49 was copy-pasted in cart and checkout
  • Shop filtering logic lived inside the React page
  • Hard to swap localStorage for a real API later

After (separated layers)

Layer Folder Job
Domain src/domain/ Business rules — cart math, delivery fee, order IDs
Application src/application/ Use cases + ports (interfaces)
Infrastructure src/infrastructure/ Zustand, product data file, i18n adapter
Presentation src/app/, src/components/, src/presentation/hooks/ UI only — calls use cases

Layer diagram

flowchart TB
    subgraph Presentation
        P1[app/pages]
        P2[components]
        P3[presentation/hooks]
    end

    subgraph Application
        A1[use-cases]
        A2[ports interfaces]
        A3[container.ts]
    end

    subgraph Domain
        D1[entities]
        D2[domain services]
    end

    subgraph Infrastructure
        I1[Zustand stores]
        I2[InMemoryProductRepository]
        I3[products.data.ts]
    end

    P1 --> P3
    P3 --> A3
    A3 --> A1
    A1 --> A2
    A1 --> D2
    A2 -.implemented by.- I1
    A2 -.implemented by.- I2
    I2 --> I3
    D2 --> D1

Step-by-step: what we did

Step 1 — Domain entities (src/domain/entities/)

What: Moved Product, CartItem, Order, User, etc. from types/ into the domain.

Why: Entities are the core vocabulary of the business. They must not depend on React or Next.js.

Example: Product describes a meat item (price, category, slug) — not how it is rendered.

src/domain/entities/index.ts

src/types/index.ts now re-exports domain entities so old imports still work.


Step 2 — Domain services (src/domain/services/)

What: Pure functions / classes for business rules.

Service Rule extracted from
CartDomainService store/cart.ts — add item, merge duplicates, totals
DeliveryFeeService cart/page.tsx + checkout/page.tsx — 49 kr fee, free over 500 kr
CustomizationDomainService lib/customization.ts — cut styles, cart line keys
OrderDomainService checkout/page.tsx — order ID format, order object shape
AuthDomainService store/auth.ts — demo password check, user creation
CatalogDomainService shop/page.tsx — filter by category, search, sort

Why: One place per rule. Change delivery threshold once → cart and checkout both update.

Example — delivery fee (single source of truth):

// src/domain/constants/commerce.ts
export const FREE_DELIVERY_THRESHOLD_SEK = 500;
export const STANDARD_DELIVERY_FEE_SEK = 49;

// src/domain/services/DeliveryFeeService.ts
static calculateDeliveryFee(subtotal: number): number {
  return subtotal > FREE_DELIVERY_THRESHOLD_SEK ? 0 : STANDARD_DELIVERY_FEE_SEK;
}

Step 3 — Application ports (src/application/ports/)

What: TypeScript interfaces describing what the app needs — not how it is stored.

Port Contract
IProductRepository findAll(), findBySlug(), findFeatured()
ICartRepository getItems(), addItem(), clearCart()
IAuthRepository login(), register(), addOrder()
IWishlistRepository toggleItem(), isInWishlist()
ITranslationService translate(key)

Why: Today products live in a static array. Tomorrow they might come from Shopify or a database. Only the infrastructure adapter changes — use cases stay the same.


Step 4 — Use cases (src/application/use-cases/)

What: One class per user action. Orchestrates domain + ports.

Use case Replaces logic in
FilterProductsUseCase Shop page filtering/sorting
GetProductBySlugUseCase getProductBySlug() calls
LocalizeProductUseCase lib/product-i18n.ts
AddToCartUseCase Cart add button
GetCartSummaryUseCase Cart totals + delivery
PlaceOrderUseCase Checkout submit handler
LoginUseCase / RegisterUseCase Login page

Example — checkout flow:

User clicks Pay
    → PlaceOrderUseCase.execute()
        → OrderDomainService.createOrder()     [domain]
        → authRepository.addOrder()            [port]
        → cartRepository.clearCart()           [port]

File: src/application/use-cases/checkout/PlaceOrder.ts


Step 5 — Infrastructure (src/infrastructure/)

What: Concrete implementations of ports + framework code.

File Role
data/products.data.ts Static 16-product catalog
repositories/InMemoryProductRepository.ts Implements IProductRepository
persistence/zustand/cartStore.ts Zustand + ZustandCartRepository
persistence/zustand/authStore.ts Auth persistence + demo user
persistence/zustand/wishlistStore.ts Wishlist persistence
i18n/I18nTranslationService.ts Wraps existing i18n/ dictionaries

Why Zustand stays: It is an infrastructure detail (browser storage). The application layer only sees ICartRepository.


Step 6 — Composition root (src/application/container.ts)

What: One file that wires everything together.

export const container = new ApplicationContainer();
// container.addToCart.execute(...)
// container.getCartSummary.execute()
// container.placeOrder.execute(...)

Why: Pages and hooks do not new InMemoryProductRepository() themselves. That would couple UI to infrastructure. The container is the only place that knows concrete classes.


Step 7 — Presentation hooks (src/presentation/hooks/)

What: React-friendly API on top of the container.

Hook Use in
useCart() Cart page, Header, product page
useAuth() Login, account, checkout
useWishlist() Product card, wishlist page
useCheckout() Checkout submit
useCatalogFilter() Shop page

Example:

// Old (page knew about Zustand internals + business math)
const { getTotal } = useCartStore();
const deliveryFee = total > 500 ? 0 : 49;

// New (page uses use case result)
const { summary } = useCart();
const { subtotal, deliveryFee, grandTotal } = summary;

Step 8 — Backward-compatible facades

Old paths still work so nothing breaks silently:

Old import Now points to
@/types @/domain/entities
@/lib/customization CustomizationDomainService
@/lib/products container.productRepository
@/store/cart infrastructure + useCart hook

New folder map

src/
├── domain/
│   ├── entities/           # Product, Order, CartItem…
│   ├── constants/          # FREE_DELIVERY_THRESHOLD_SEK
│   └── services/           # CartDomainService, DeliveryFeeService…
│
├── application/
│   ├── ports/              # IProductRepository, ICartRepository…
│   ├── use-cases/          # AddToCart, PlaceOrder, FilterProducts…
│   ├── dtos/               # LocalizedProduct, CartSummary
│   └── container.ts        # ★ wires everything
│
├── infrastructure/
│   ├── data/               # products.data.ts, demo-orders.data.ts
│   ├── repositories/       # InMemoryProductRepository
│   ├── persistence/zustand/  # Stores + repository adapters
│   └── i18n/               # I18nTranslationService
│
├── presentation/
│   └── hooks/              # useCart, useAuth, useCatalog…
│
├── app/                    # Thin pages (UI + hooks)
├── components/             # Visual components
├── hooks/useTranslation.ts # Still used for t('key')
├── lib/                    # Facades (backward compat)
├── store/                  # Re-exports (backward compat)
└── types/                  # Re-exports (backward compat)

How to add a feature (cheat sheet)

Change delivery fee rule

  1. Edit src/domain/constants/commerce.ts
  2. Done — GetCartSummaryUseCase and PlaceOrderUseCase pick it up automatically

Add a new product

  1. Add entry in src/infrastructure/data/products.data.ts
  2. Add translations in src/i18n/locales/*.ts

Add a new page action (e.g. "apply coupon")

  1. Add rule in src/domain/services/ if it is business logic
  2. Add port method if it needs storage
  3. Create src/application/use-cases/cart/ApplyCoupon.ts
  4. Register in container.ts
  5. Expose via useCart() or new hook
  6. Call from page/component

Replace static products with an API

  1. Create src/infrastructure/repositories/ApiProductRepository.ts implementing IProductRepository
  2. In container.ts, swap InMemoryProductRepositoryApiProductRepository
  3. No changes to shop page, use cases, or domain

Dependency rules (memorize this)

Layer Can import
Domain Only domain
Application Domain + its own ports/DTOs
Infrastructure Application ports + Domain
Presentation Application container + hooks + React
Layer Cannot import
Domain React, Next, Zustand, app/, components/
Application Zustand, page.tsx, JSX
Use cases Concrete repositories (only interfaces)

Request flow example: Add to cart

sequenceDiagram
    participant Page as product/page.tsx
    participant Hook as useCart()
    participant UC as AddToCartUseCase
    participant Repo as ZustandCartRepository
    participant Domain as CartDomainService
    participant Store as Zustand persist

    Page->>Hook: addItem(product, customization, label)
    Hook->>UC: execute(...)
    UC->>Repo: addItem(...)
    Repo->>Domain: addItem(items, product, ...)
    Domain-->>Repo: newItems[]
    Repo->>Store: setItems(newItems)
    Store-->>Page: React re-render

Verify the project

cd /Users/apple/Desktop/code/Kottgard
npm run build    # must pass
npm run dev      # test at http://localhost:3000

Demo login: demo@kottgard.se / demo123


Further reading

  • Uncle Bob — Clean Architecture (concentric circles diagram)
  • This project's annotated code: docs/annotated/src/
  • Beginner folder map: ~/Desktop/Kottgard-Project-Learn/