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:
- Guard -
requirePermission("retail.products.<action>")before acting. - Scope -
orgWhere(organizationId)on theWHEREclause, so a product from another organization is unreachable (and fails withProductNotFound). - Type - rows are parsed into
Product;statusgoes throughproductStatusSchema.
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) - returnsPublicProduct[].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.
productsdefaults to[]while loading.
The pages
ProductsPage- the catalog table/list with a create form.ProductDetailPage- a single product, matched fromrelativePath.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
- Anatomy of an application - the package structure this implements.
- Building an application - the recipe for a new vertical.