Skip to content
Africa OS Knowledgebase
Esc
navigateopen⌘Jpreview
On this page

Anatomy of an application

The structure every application module follows - package layout, app.config.ts, the mountable root, features, routes, and exports.

Every application module in packages/applications/* follows the same anatomy. Understand it once and you can read any vertical. This page uses @africaos/retail as the concrete example.

The package layout

packages/applications/retail/
├── app.config.ts          self-description for the registry
├── package.json           @africaos/retail, workspace:* deps
├── tsconfig.json          extends the shared typescript config
├── project.json           Nx project metadata
├── vitest.config.ts       vite-plus test config
└── src/
    ├── index.ts           public exports
    ├── application.tsx    the mountable root component
    ├── queryClient.ts     TanStack Query client
    ├── components/        application-level chrome (shell, sidebar)
    ├── features/          one folder per domain slice
    ├── pages/             larger non-feature pages
    └── routes/            the internal route tree

app.config.ts - the self-description

The config is the single source of truth for the application’s metadata:

import { defineAppConfig } from "@africaos/config";

export default defineAppConfig({
  id: "retail",
  name: "Retail OS",
  icon: "shopping-cart",
  color: "#059669",
  description: "Retail OS for products, inventory, sales, and point of sale",
  route: "/",
  capabilities: { offline: false, desktop: true, mobile: false },
  features: [
    { slug: "products", label: "Products", icon: "package", path: "/products" },
    { slug: "inventory", label: "Inventory", icon: "boxes", path: "/inventory" },
    { slug: "customers", label: "Customers", icon: "users" }, // declared, not yet implemented
    // ...
  ],
});

Two audiences read this file:

  • The registry (pnpm db:register-apps) upserts the metadata so the platform never hardcodes per-app knowledge.
  • The application shell renders the implemented features (those with a path) as the application’s internal navigation.

A feature without a path is declared surface - planned work that is not yet a route.

The mountable root

src/application.tsx exports the host-agnostic root component:

export function RetailApplication({ organizationSlug }: RetailApplicationProps) {
  const basePath = organizationSlug ? `/${organizationSlug}/retail` : "";
  const { pathname } = useLocation();
  const relativePath = pathname.startsWith(basePath)
    ? pathname.slice(basePath.length) || "/"
    : "/";

  return (
    <RetailShell basePath={basePath} embedded={Boolean(organizationSlug)} dashboardPath={...}>
      {resolvePage(relativePath, basePath, organizationSlug)}
    </RetailShell>
  );
}

It computes the path relative to the mount prefix and renders the matching page. This lets the same component serve two hosts:

  • The web app passes organizationSlug, so deep links like /:organization/retail/products/:productId resolve without the host knowing retail’s internal routes.
  • A standalone host omits the slug, and the request runtime falls back to the active-org cookie.

The component wraps pages in its application shell (RetailShell), which shows the app’s internal navigation and a back-to-dashboard link.

The feature folder

A feature is one folder under src/features/. The products feature shows the full stack:

features/products/
├── service.ts                 Effect business program
├── schemas/product.ts         Zod schemas + derived types
├── errors.ts                  typed errors
├── services/products.functions.ts  server functions
├── hooks/useProducts.ts       TanStack Query hooks
├── components/ProductsPage.tsx
├── components/ProductDetailPage.tsx
└── service.test.ts

The dependency order is strict: components -> hooks -> server functions -> service -> database. The server function never contains business logic; it validates input, provides the service layer, runs the program through the request runtime, and folds failures into serializable codes.

The service layer

The domain logic lives in an Effect service (features/products/service.ts). It depends on Database, Logger, and CurrentPermissions, and every operation:

  1. Guards with requirePermission("retail.products.<action>").
  2. Scopes every query structurally with orgWhere(organizationId).
  3. Parses rows into a typed domain Product.
  4. Fails with typed errors (ProductNotFound, InvalidProduct, Forbidden).

The service never constructs its dependencies - it receives them through the layer, so the request runtime decides the implementations.

Server functions

features/products/services/products.functions.ts defines the TanStack Start server functions. Every handler follows the same pattern:

export const createProductFn = createServerFn({ method: "POST" })
  .validator((data: unknown) => schema.parse(data))
  .handler(async ({ data }) => {
    const headers = getRequestHeaders();
    const program = Effect.gen(function* () {
      const organizationId = yield* requireActiveOrganization;
      const productService = yield* ProductService;
      return toPublicProduct(yield* productService.create(organizationId, input));
    });
    return runRequestFolded(headers, Effect.matchEffect(withProductService(program), {
      onFailure: (error) => Effect.succeed({ error: toProductError(error) } as const),
      onSuccess: (product) => Effect.succeed({ product } as const),
    }), { scope: retailScope(organizationSlug) }, () => ({ error: "unauthenticated" } as const));
  });

The pieces:

  • retailScope(organizationSlug) passes both the org slug and the application slug (retail) to the request scope, so the runtime re-verifies server-side on every call that the application is enabled and the user holds platform.applications.access.
  • withProductService(program) provides ProductService.Live, which needs Database and Logger - both provided by the request runtime.
  • Effect.matchEffect folds typed failures into stable client codes (not_found, invalid, forbidden, no_organization, unknown).
  • runRequestFolded maps a stale/missing session to the same neutral shape instead of a 500.

Hooks

features/products/hooks/useProducts.ts wraps the server functions in TanStack Query. The organization slug is part of the query key, so switching organizations refetches the correct catalog. Mutations invalidate the list on success.

The route tree

src/routes/tree.ts exports the application’s internal routes so hosts can compose them (see Data fetching). The mounted application resolves the same pages from the URL.

Public exports

src/index.ts is the package’s public surface: the root component, the config, the query client, the route tree, the hooks, the service, the server functions, the schemas, and the errors. Hosts import only what they need.

Consistency rules

  • The feature structure is identical across features. Copy the products feature to start a new one.
  • Server functions are thin. All logic lives in the Effect service.
  • Every tenant query is scoped. orgWhere/orgScope on every query touching tenant data.
  • Every domain action is guarded. requirePermission before acting.
  • The application is host-agnostic. No web-app imports; the organizationSlug prop drives behavior.

Next steps

Last updated on August 18, 2026