Skip to main content
Foldkit
On this pageStarting Simple

Project Organization

Foldkit apps can start in a single main.ts and split into modules as they grow. Here's how to organize your code as complexity increases.

Starting Simple

The simplest Foldkit apps keep everything in main.ts: Model, Messages, init, update, and view. The Counter example is a good reference.

This is fine for small apps. You don't need to split into multiple files until the single file becomes hard to navigate.

File Layout

As your app grows and you scale with submodels, a consistent file layout helps you navigate the codebase. Each page or feature becomes a folder:

src/
├── main.ts              App entry point
├── model.ts             App-level state
├── message.ts           App-level messages
├── route.ts             Route definitions
├── update.ts            App-level update
│
├── page/
│   ├── index.ts         Re-exports all pages
│   ├── home/
│   │   ├── index.ts     Re-exports Home module
│   │   ├── model.ts     Home state
│   │   ├── message.ts   Home events
│   │   ├── update.ts    Home update
│   │   └── view.ts      Home view
│   └── products/
│       ├── index.ts
│       ├── model.ts
│       ├── message.ts
│       ├── update.ts
│       └── view.ts
│
└── domain/
    ├── index.ts         Re-exports domain modules
    ├── cart.ts          Cart type + operations
    └── item.ts          Item type + operations

Each page folder mirrors the Elm Architecture: Model defines state, Message defines events, update handles transitions, view renders HTML, and init sets up initial state.

As pages grow, you can further split into subfolders. For example, the Typing Terminal room source has view/ and update/ subfolders for its Room page.

Domain Modules

For business logic that spans multiple modules, create a domain/ folder. Each file represents a domain concept with its schema and pure functions:

// domain/cart.ts
import { Array, Option, Schema } from 'effect'
import { evo } from 'foldkit/struct'

import { CartItem, Item } from './item'

export const Cart = Schema.Array(CartItem)
export type Cart = typeof Cart.Type

export const addItem =
  (item: Item) =>
  (cart: Cart): Cart => {
    const existing = Array.findFirst(
      cart,
      cartItem => cartItem.item.id === item.id,
    )

    return Option.match(existing, {
      onNone: () => [...cart, { item, quantity: 1 }],
      onSome: () =>
        Array.map(cart, cartItem =>
          cartItem.item.id === item.id
            ? evo(cartItem, { quantity: quantity => quantity + 1 })
            : cartItem,
        ),
    })
  }

export const removeItem =
  (itemId: string) =>
  (cart: Cart): Cart =>
    Array.filter(cart, cartItem => cartItem.item.id !== itemId)

export const totalItems = (cart: Cart): number =>
  Array.reduce(cart, 0, (total, { quantity }) => total + quantity)

This keeps related types and operations together. You can import the module and use Cart.addItem, Cart.removeItem, etc.

Index Re-exports

Use index.ts files to create clean namespace imports:

// page/home/index.ts
export * as Model from './model'
export * as Message from './message'
export * from './init'
export * from './update'
export * from './view'

// page/index.ts
export * as Home from './home'
export * as Products from './products'

// domain/index.ts
export * as Cart from './cart'
export * as Item from './item'

Then import and use the namespace:

import { Cart, Item } from './domain'
import { Home, Products } from './page'

// Access page modules
Home.Model
Home.view(model.home, message => HomeMessage({ message }))

// Access domain modules
Cart.addItem(item)(cart)
Cart.totalItems(cart)

This pattern gives you discoverability (Home. shows everything available) while keeping imports clean.