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

Retail reference

A complete walkthrough of the retail application - the reference implementation every other vertical should mirror.

@africaos/retail is the reference implementation. It is the only application module with a fully built domain feature, and it establishes the patterns every vertical should follow. If you are building an application or a feature, read this package and mirror it.

This page walks the products feature end to end, following one slice of data from the database to the screen and back.

The feature at a glance

The domain model

features/products/schemas/product.ts defines the domain types with Zod:

export type ProductStatus = "draft" | "active" | "archived";

export interface Product {
  id: string;
  organizationId: string;
  name: string;
  description: string | null;
  priceCents: number;   // integer cents - never floats for money
  currency: string;
  status: ProductStatus;
  createdAt: Date;
  updatedAt: Date;
}

The service returns this domain shape with real Dates. The wire shape (PublicProduct) serializes dates to ISO strings before crossing the network. Prices are stored as integer cents, the standard money-handling rule.

The service - where business logic lives

features/products/service.ts defines ProductService:

Operation Permission Behavior
list(organizationId) retail.products.read Active first, then drafts, newest first. Archived excluded.
create(organizationId, input) retail.products.create Validates with createProductSchema, inserts.
update(organizationId, id, patch) retail.products.update COALESCE patch onto existing row.
archive(organizationId, id) retail.products.archive Sets status archived.

Every operation follows the same three steps:

  1. Guard - requirePermission("retail.products.<action>") before acting.
  2. Scope - orgWhere(organizationId) on the WHERE clause, so a product from another organization is unreachable (and fails with ProductNotFound).
  3. Type - rows are parsed into Product; status goes through productStatusSchema.

Reads share one column list spliced in with sql.literal, so every query selects the same shape.

The server functions

features/products/services/products.functions.ts exposes:

  • listProductsFn (GET) - returns PublicProduct[].
  • createProductFn (POST) - returns { product } | { error }.
  • updateProductFn (POST) - returns { product } | { error }.
  • archiveProductFn (POST) - returns { product } | { error }.

The retail application slug is a constant (RETAIL_APPLICATION_SLUG) passed into the request scope, so the runtime verifies application availability on every call. Error folding maps typed failures to stable codes:

export type ProductError =
  | "not_found" | "invalid" | "forbidden" | "no_organization" | "unauthenticated" | "unknown";

listProductsFn degrades any failure - including missing organization and missing capability - to an empty catalog, so the page shows an empty state rather than crashing.

The hooks

features/products/hooks/useProducts.ts is the client-facing surface:

const { products, isLoading, createProduct, updateProduct, archiveProduct } = useProducts({
  organizationSlug,
});
  • The query key includes the slug: ["retail", "products", organizationSlug ?? "active"], so switching organizations refetches the right catalog.
  • Mutations invalidate the list on success, keeping the table in sync.
  • products defaults to [] while loading.

The pages

  • ProductsPage - the catalog table/list with a create form.
  • ProductDetailPage - a single product, matched from relativePath.match(/^\/products\/([^/]+)$/).

Both receive basePath and organizationSlug and render through the retail shell.

The shell

src/components/RetailShell.tsx and RetailSidebar.tsx provide the application’s internal chrome. In embedded mode (embedded={true}) the shell renders content only, because the web app already supplies the sidebar. The dashboardPath prop points the back link at /:organization.

The test

features/products/service.test.ts exercises the service against Database.Test (in-memory PGlite) with a fresh memo map per test, so each test gets an isolated database. The service is exercised without Postgres. See Testing for the pattern.

What to copy

When building a new vertical or feature, copy the products feature folder and rename it. The pattern - schema, errors, Effect service with guards and scoping, thin server functions, query hooks, pages - is the platform’s standard, and deviating from it costs you the platform’s guarantees (tenancy, permissions, and consistent error handling).

Next steps

Last updated on August 18, 2026