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 : 49was 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
- Edit
src/domain/constants/commerce.ts - Done —
GetCartSummaryUseCaseandPlaceOrderUseCasepick it up automatically
Add a new product
- Add entry in
src/infrastructure/data/products.data.ts - Add translations in
src/i18n/locales/*.ts
Add a new page action (e.g. "apply coupon")
- Add rule in
src/domain/services/if it is business logic - Add port method if it needs storage
- Create
src/application/use-cases/cart/ApplyCoupon.ts - Register in
container.ts - Expose via
useCart()or new hook - Call from page/component
Replace static products with an API
- Create
src/infrastructure/repositories/ApiProductRepository.tsimplementingIProductRepository - In
container.ts, swapInMemoryProductRepository→ApiProductRepository - 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/