Adding a feature
The end-to-end recipe for adding a domain feature to an application module - schema, service, server functions, hooks, and UI.
This guide adds a complete domain feature to an existing application module, mirroring the retail products feature. Use it as a template for any new domain slice.
What you are building
A feature is a folder under src/features/<feature>/ containing, in dependency order:
src/features/<feature>/
├── schemas/ Zod schemas and derived types
├── errors.ts typed errors
├── service.ts Effect business program
├── services/*.functions.ts server functions
├── hooks/*.ts TanStack Query hooks
├── components/ UI
└── *.test.ts service tests
The steps below build them in that order, from the database outward.
Step 1 - decide the data model
A feature operates on data. Decide whether it needs new tables (a new migration) or maps to existing tables. For a new table, add a migration following the Migrations guide. The pattern for tenant data:
CREATE SCHEMA IF NOT EXISTS <app>;
CREATE TABLE IF NOT EXISTS <app>.<entities> (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL REFERENCES platform.organizations (id) ON DELETE CASCADE,
...
);
CREATE INDEX IF NOT EXISTS <app>_<entities>_organization_idx
ON <app>.<entities> (organization_id);
Apply with pnpm db:migrate.
Step 2 - write the schemas
schema.ts defines the Zod schemas and derived types. Money as integer cents, statuses as string unions:
import { z } from "zod";
export const entityStatusSchema = z.enum(["draft", "active", "archived"]);
export const createEntitySchema = z.object({
name: z.string().min(1),
priceCents: z.number().int().nonnegative(),
status: entityStatusSchema.default("draft"),
});
export const updateEntitySchema = createEntitySchema.partial();
export type CreateEntityInput = z.input<typeof createEntitySchema>;
export type UpdateEntityInput = z.input<typeof updateEntitySchema>;
export type EntityStatus = z.infer<typeof entityStatusSchema>;
Step 3 - define the typed errors
errors.ts defines Effect Data.TaggedErrors, so failures carry structure and match in tests:
import { Data } from "effect";
export class EntityNotFound extends Data.TaggedError("EntityNotFound")<{ id: string }> {}
export class InvalidEntity extends Data.TaggedError("InvalidEntity")<{
errors: ReadonlyArray<{ path: string; message: string }>;
}> {}
Step 4 - write the Effect service
service.ts is where business logic lives. It depends on Database, Logger, and CurrentPermissions and never constructs them:
export class EntityService extends Context.Service<
EntityService,
{
list(organizationId: string): Effect.Effect<ReadonlyArray<Entity>, Forbidden | SqlError, Database | CurrentPermissions>;
create(organizationId: string, input: CreateEntityInput): Effect.Effect<Entity, InvalidEntity | Forbidden | SqlError, Database | CurrentPermissions>;
// ...
}
>()("AfricaOS/<App>/<Feature>") {
static readonly Live: Layer.Layer<EntityService, never, Database | Logger> = Layer.effect(
EntityService,
Effect.all([Database, Logger]).pipe(
Effect.map(([sql, logger]) => makeEntityService(sql, logger))
)
);
}
Every operation must:
- Guard -
yield* requirePermission("<app>.<feature>.<action>")first. Register the permission string in the seed/registry if it is new. - Scope - embed
yield* orgWhere(organizationId)in theWHEREclause, so rows from other organizations are unreachable. - Validate -
schema.safeParseinput, failing with the typed error on mismatch. - Type - parse rows into the domain shape with
Dates.
Step 5 - write the server functions
services/<feature>.functions.ts exposes the RPC layer. Every handler:
- Validates input with a Zod
.validator. - Reads
getRequestHeaders(). - Provides the service layer (
Effect.provide(program, EntityService.Live)). - Runs through
runRequestFoldedwith the request scope ({ organizationSlug, applicationSlug }). - Folds typed failures into stable client codes.
const requireActiveOrganization = Effect.gen(function* () {
const active = yield* CurrentOrganization;
const organization = Option.getOrNull(active);
if (!organization) return yield* Effect.fail(new NoActiveOrganization());
return organization.id;
});
const appScope = (organizationSlug: string | undefined) => ({
organizationSlug,
applicationSlug: APP_APPLICATION_SLUG,
});
export const listEntitiesFn = createServerFn({ method: "GET" })
.validator((data: unknown) => listSchema.parse(data))
.handler(async ({ data }) => {
const headers = getRequestHeaders();
const program = Effect.gen(function* () {
const organizationId = yield* requireActiveOrganization;
const service = yield* EntityService;
return (yield* service.list(organizationId)).map(toPublic);
});
return runRequestFolded(
headers,
Effect.matchEffect(Effect.provide(program, EntityService.Live), {
onFailure: () => Effect.succeed([]),
onSuccess: (entities) => Effect.succeed(entities),
}),
{ scope: appScope(data.organizationSlug) },
() => []
);
});
Step 6 - write the hooks
hooks/useEntities.ts wraps the server functions in TanStack Query. Put the organization slug in the query key and invalidate on success:
const entitiesKey = ["<app>", "<feature>", organizationSlug ?? "active"];
const { data: entities, isLoading } = useQuery({
queryKey: entitiesKey,
queryFn: () => listEntitiesFn({ data: { organizationSlug } }),
retry: false,
});
const createEntity = useMutation({
mutationFn: (input) => createEntityFn({ data: { ...input, organizationSlug } }),
onSuccess: (result) => {
if ("entity" in result) void queryClient.invalidateQueries({ queryKey: entitiesKey });
},
});
Step 7 - build the UI
Components consume the hook and render through the application shell. Register the feature’s route in the application’s page resolution and (if it should appear in navigation) add it to app.config.ts with a path.
Step 8 - expose the public surface
Export the service, server functions, hooks, schemas, and errors from the package’s src/index.ts, so hosts and consumers can import them.
Step 9 - write the tests
Add a service.test.ts mirroring the retail pattern (see Testing): Database.Test + Logger.Test, real migrations applied, and assertions on the strongest invariants - tenant isolation, permission gates, and validation.
Step 10 - verify
pnpm nx run @africaos/<app>:typecheck
pnpm nx run @africaos/<app>:test
pnpm nx run @africaos/<app>:lint
pnpm format
Checklist
- Every tenant query is scoped with
orgWhere/orgScope. - Every domain action is guarded with
requirePermission. - Input is validated with Zod before use.
- Server functions are thin - logic lives in the Effect service.
- Failures fold into stable client codes; reads degrade to empty states.
- The query key includes the organization scope.
- Tests assert the strongest invariants with
@effect/vitest.