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

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:

  1. Guard - yield* requirePermission("<app>.<feature>.<action>") first. Register the permission string in the seed/registry if it is new.
  2. Scope - embed yield* orgWhere(organizationId) in the WHERE clause, so rows from other organizations are unreachable.
  3. Validate - schema.safeParse input, failing with the typed error on mismatch.
  4. 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:

  1. Validates input with a Zod .validator.
  2. Reads getRequestHeaders().
  3. Provides the service layer (Effect.provide(program, EntityService.Live)).
  4. Runs through runRequestFolded with the request scope ({ organizationSlug, applicationSlug }).
  5. 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.

Last updated on August 18, 2026