Kana Monorepo Fullstack Best Practices
A prescriptive guide for the Kana stack: FastAPI Clean Architecture on the backend, TanStack Router + Query + Form on the frontend(s), wired together through a generated OpenAPI client, and orchestrated by moon across pnpm and uv.
Before You Scaffold — ASK
Before generating any code from this skill, stop and ask the user one question:
Will this app be multi-tenant (organization-scoped), or single-tenant?
The answer changes the scaffold materially. Do not guess. Default to asking even if the prompt looks obvious — a wrong assumption here costs a full refactor later.
If multi-tenant (default for this skill)
Keep every rule in this document as written:
organization_idCASCADE FK on every tenant-scoped table.organization_idas the first positional arg on every tenant-scoped repo method.OrgContext/ActiveOrgdep inpresentation/;require_role/require_permissionenforce org role.- Two-layer role system: platform
super-admin+ orgowner | admin | member. - Frontend
$orgSlugroute layer.
If single-tenant
Strip before scaffolding — do not leave dead code:
- Drop
organization_idcolumns, FKs, and repo-method args. Repo methods take their natural key only. - Delete
domain/organization/,domain/member/,domain/role/(org-role) and their infra/presentation counterparts. - Drop
OrgContext/ActiveOrg/require_role(org axis); keep a singleCurrentUser-basedrequire_role(*roles: AppRole)if you still need admin vs user. - Skip the
$orgSlugfrontend layer — features live directly under_authenticated/. - Skip the platform-vs-org split. One
AppRole = Literal["admin", "user"]is enough.
If unsure or "mostly single with one org feature later"
Scaffold single-tenant now. Adding tenancy later is a well-defined migration (create organization, add FK on one table, add scope on one repo, add dep). Pre-building tenancy you never use is not.
Core Principles
- Max 200 LOC per file across Python, TSX, and TS. Split instead of growing.
- No code comments except a one-line
# why:/// why:when a non-obvious invariant would otherwise be lost. - Clean Architecture dependency rule:
domain→application→infrastructure/presentation. Inner layers must not import outer ones, and must not import frameworks (FastAPI, SQLAlchemy, pydantic-settings). This is enforced byruff'sflake8-tidy-imports.banned-api— do not silence it outside outer layers. - Single responsibility per file — one use case, one repository, one router, one component.
- Reusability-first: when two call sites need the same logic, extract to
_shared/(Python) orlibs/(TS) before the third appears. - Strict typing:
pyrightstrict (0 errors), TypeScriptstrict, noany. PrefixTfor types,Ifor interfaces,Efor enums. - Kebab-case for all TS/TSX file and folder names.
Repo Layout
kana/
├── apps/
│ ├── api/ # FastAPI backend (Python, uv)
│ ├── web/ # Public TanStack SPA (pnpm)
│ └── admin/ # Admin TanStack SPA (pnpm)
├── .moon/workspace.yml # moon orchestrator — projects: apps/*
├── biome.json # shared JS/TS/CSS/JSON/MD lint+format
├── pnpm-workspace.yaml
├── docker-compose.yml
└── CLAUDE.md # session-level conventions
Indentation & Formatting
- JS / TS / TSX / CSS / JSON / MD / YAML: real
\tcharacter, displayed 2-wide. Matchesbiome.json(indentStyle: "tab",indentWidth: 2) and.editorconfig. - Python: 4 spaces (PEP 8,
ruff formatdefault). Never tabs. - When editing an existing file, match its existing indentation exactly. The
Edittool matches literally — mismatched indentation is the most common cause of "string not found" errors. biome check --write .andruff check+ruff formatmust both pass before commit.
Backend — FastAPI Clean Architecture
Every feature slice lives in four layers under apps/api/src/{package}_api/:
domain/{feature}/ # pure: dataclasses, repository Protocols, exceptions
models.py # @dataclass(frozen=True, slots=True)
repository.py # Protocol only — no impl
application/{feature}/ # use cases: one file per action
{use_case}.py # @dataclass(frozen=True, slots=True), .execute(...)
infrastructure/{feature}/ # adapters: SQLAlchemy ORM, repos, external clients
orm.py # Mapped[...] classes
repository.py # SqlAlchemy{X}Repository implementing the Protocol
presentation/{feature}/ # FastAPI routers + pydantic schemas (DTOs)
router.py # APIRouter, pydantic BaseModel request/response
Domain models
@dataclass(frozen=True, slots=True) for entities — immutable, hashable, small:
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True, slots=True)
class User:
id: str
name: str
email: str
role: str = "user"
email_verified: bool = False
created_at: datetime | None = None
Allowed imports inside domain/: stdlib + pydantic value objects only. No enum.Enum — use Literal[...] + a tuple[str, ...] constant for runtime iteration:
AppRole = Literal["owner", "admin", "member"]
APP_ROLE_VALUES: tuple[AppRole, ...] = ("owner", "admin", "member")
ASSESSMENT_STATUS_VALUES: tuple[str, ...] = ("not_started", "partial", "mostly_ready", "compliant")
Cheap (de)serialization, no enum-string conversion churn at DB/JSON boundary.
Domain errors subclass a single DomainError. Each subclass maps to exactly one HTTP status in the presentation layer (NotFoundError → 404, ForbiddenError → 403, ConflictError → 409). Never spread status choices across routers.
Repository protocol in domain, implementation in infrastructure
# domain/user/repository.py
from typing import Protocol
class UserRepository(Protocol):
async def find_by_id(self, user_id: str) -> User | None: ...
async def find_by_email(self, email: str) -> User | None: ...
async def list_by_ids(self, ids: list[str]) -> list[User]: ...
# infrastructure/user/repository.py
from dataclasses import dataclass
from sqlalchemy.ext.asyncio import AsyncSession
@dataclass(frozen=True, slots=True)
class SqlAlchemyUserRepository:
session: AsyncSession
async def find_by_id(self, user_id: str) -> User | None:
row = await self.session.get(UserOrm, user_id)
return _to_domain(row) if row else None
A module-level _to_domain(row) -> Entity function bridges ORM ↔ domain. Never leak UserOrm out of infrastructure/.
Tenant isolation
- Every tenant-scoped table declares
organization_id: Mapped[str] = mapped_column(ForeignKey("organization.id", index=True). - Every repo method that touches a tenant-scoped table takes
organization_idas its first positional arg. No ambient tenant via contextvars, no middleware magic. - Defense-in-depth: authz dependency in
presentation/+WHERE organization_id = :org_idininfrastructure/. Both must be present.
async def list_by_org(self, organization_id: str) -> list[GapItem]: ...
async def find_thread(self, organization_id: str, user_id: str, thread_id: str) -> ChatThread | None: ...
Typed ORM
SQLAlchemy 2 Mapped[...] + mapped_column(...) only — no legacy Column(...). Timestamp columns use a shared _utcnow helper so default and onupdate stay consistent:
def _utcnow() -> datetime:
return datetime.now(tz=UTC)
class ChatThreadOrm(Base):
__tablename__ = "chat_thread"
id: Mapped[str] = mapped_column(String, primary_key=True)
organization_id: Mapped[str] = mapped_column(
ForeignKey("organization.id", index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_utcnow
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_utcnow,
)
Use case per file in application/
# application/user/list_members.py
@dataclass(frozen=True, slots=True)
class ListMembers:
member_repo: MemberRepository
user_repo: UserRepository
async def execute(self, organization_id: str) -> list[MemberWithUser]:
members = await self.member_repo.list_by_org(organization_id)
users = await self.user_repo.list_by_ids([m.user_id for m in members])
by_id = {u.id: u for u in users}
return [MemberWithUser(member=m, user=u) for m in members if (u := by_id.get(m.user_id))]
One use case = one file = one .execute() method. Constructor takes domain-layer Protocols — never an AsyncSession, never a repo implementation, never a FastAPI object.
FastAPI presentation layer
DI lives in presentation/_shared/deps/ split by concern:
base.py—SessionDep,SettingsDeprepos.py— every{X}RepoDep = Annotated[Repo, Depends(get_{x}_repo)]auth.py—CurrentUserOptional,CurrentUserrbac.py—require_role(...),require_permission(resource, [actions]),SuperAdmin
Routers consume Annotated-deps, build a use case, .execute(), commit the session, and return a pydantic response DTO:
router = APIRouter(tags=["me"])
class UpdateProfileRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
image: str | None = Field(default=None, max_length=500)
@router.patch("/me", response_model=UserResponse)
async def update_me(
body: UpdateProfileRequest,
user: CurrentUser,
session: SessionDep,
user_repo: UserRepoDep,
) -> UserResponse:
if body.name is None and body.image is None:
raise HTTPException(status_code=400, detail="nothing to update")
updated = await user_repo.update_profile(user.id, name=body.name, image=body.image)
await session.commit()
return UserResponse.from_domain(updated)
- Each domain exception type (
NotFoundError,ForbiddenError) maps to a singleHTTPExceptionstatus — do not scatter status choices. - Routers are thin: validate → call use case / repo → commit → serialize.
- Mount everything under
/v1viacreate_app(); nested domains like/orgs/{slug}/...are composed with sub-APIRouters. - Response DTOs expose a
@classmethod from_domain(cls, entity) -> Selffactory. Domain entities never embedpydantic.BaseModel; translation is one-way at the boundary.
Router sub-package for big modules
When a single module's router would breach the 200-LOC cap, split into a package whose __init__.py re-exports one APIRouter:
presentation/auth/router/
├── __init__.py # builds + re-exports the single APIRouter
├── core.py # login / register / logout
├── verification.py # OTP
├── sessions.py
└── password.py
Each sub-file owns an APIRouter() and __init__.py calls router.include_router(...) on each — so mount-point URLs stay stable.
RBAC factory
require_role and require_permission are closures that return a FastAPI dependency. Platform super-admin always bypasses org-role checks:
def require_role(*allowed: AppRole) -> Callable[..., Awaitable[OrgContext]]:
async def _dep(user: CurrentUser, ctx: ActiveOrg) -> OrgContext:
if user.role == PLATFORM_SUPER_ADMIN:
return ctx
if ctx.role not in allowed:
raise HTTPException(403, "required role: " + " or ".join(allowed))
return ctx
return _dep
Usage: ctx: Annotated[OrgContext, Depends(require_role("owner", "admin"))].
Settings singleton
pydantic-settings instance is cached for the process lifetime, and enforces prod invariants at boot (fail fast, not at first request):
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
class Settings(BaseSettings):
jwt_secret: str
@field_validator("jwt_secret")
@classmethod
def _min_len(cls, v: str) -> str:
if len(v) < 32:
raise ValueError("JWT_SECRET must be >= 32 chars")
return v
Server-Sent Events
Long-lived streaming endpoints share one helper and one header set. A 2 KiB comment preamble defeats nginx / Cloudflare buffer pre-flush so the first real event reaches the client immediately:
def _sse(event: str, payload: object) -> str:
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False, default=str)}\n\n"
_SSE_PREAMBLE = (":" + " " * 2048 + "\n\n").encode("utf-8")
_SSE_HEADERS = {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
}
Ruff layering enforcement
pyproject.toml bans fastapi, sqlalchemy, pydantic_settings workspace-wide and re-allows them per-file only in infrastructure/**, presentation/**, main.py, scripts/**, and alembic/**. When a lint error from TID251 fires in domain/ or application/, the correct fix is to move the code out, never to add a per-file ignore.
Testing
pytest+pytest-asynciowithasyncio_mode = "auto".- Unit tests live in
tests/unit/{layer}/...and mirror thesrc/tree. - Test each use case against fake repos that implement the domain Protocol — no SQLAlchemy in unit tests.
tests/test_layering.pyis the layering smoke test: it instantiates one real use case wired entirely with in-memory fakes of everyProtocolport and runs.execute(). If the inner layers accidentally grow an import intoinfrastructureorpresentation, this test fails at import time. Keep it cheap; its job is structural, not functional.
OpenAPI export
moon run api:openapi-export writes apps/api/openapi.json from the running FastAPI schema. Regenerate after any router/DTO change. apps/web and apps/admin consume it via openapi-typescript into src/libs/api/schema.d.ts.
Frontend — TanStack Apps (web, admin)
TanStack conventions
Naming & prefixes
- Prefix
Tfor types (e.g.TUserItems,TCreateUserPayload),Ifor interfaces (e.g.IUserData),Efor enums (e.g.EUserGender). - Always use kebab-case for file and folder names (e.g.
user-profile.tsx,auth-guard.tsx).
Component organization
src/components/
├── ui/ # Reusable UI primitives (Button, Input, Card, Table)
├── features/ # Domain-specific composites (UserForm, AccountTable)
└── layout/ # Layouts, guards (DashboardLayout, ProtectedRoute)
State management
- Server state: TanStack Query
- Client state: TanStack Store with selectors
- Forms: TanStack Form + Zod validation
API layer pattern
Every feature API in _apis/ must follow this structure with typed wrappers, query key factories, and hooks:
Query key factories — always define, never inline keys:
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (params?: TListParams) => [...userKeys.lists(), params] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
}
Response types — standardize wrappers:
type TSingleResponse<T> = { data: T; message: string }
type TListResponse<T> = { data: T[]; pagination: TPaginationMeta }
Design principles
- Reusability-first: extract shared logic before the third call site.
- Single responsibility per file: one component, one hook, one service.
- High cohesion: group related logic together within modules.
- Low coupling: minimize dependencies between modules, use clean interfaces.
TanStack rules
- Always use query key factories — never inline query keys.
- Invalidate queries on mutations.
- Separate types from implementation.
- Use barrel exports (
index.ts). - Keep components pure when possible.
- Extract reusable logic to hooks.
- Use Zod for all form validation.
App shell
// main.tsx
createRoot(rootEl).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
)
// router.tsx
export const router = createRouter({
routeTree,
context: { queryClient: getQueryClient(), session: null },
defaultPreload: "intent",
scrollRestoration: true,
})
Root route resolves the session into router context once, so all child routes read context.session without refetching:
export const sessionQuery = queryOptions({
queryKey: ["session"],
queryFn: fetchSession,
staleTime: 30_000,
})
export const Route = createRootRouteWithContext<TRouterContext>()({
beforeLoad: async ({ context }) => {
const session = await context.queryClient.ensureQueryData(sessionQuery)
return { session }
},
component: RootLayout,
})
Route grouping: _authenticated vs _public
Gate access at the layout level, not inside every page:
// routes/_authenticated.tsx
export const Route = createFileRoute("/_authenticated")({
beforeLoad: ({ context }) => {
if (!context.session) throw redirect({ to: "/auth/login" })
return { session: context.session }
},
component: () => <Outlet />,
})
_public.tsx mirrors this and redirects authenticated users away from /auth/*.
Feature slice inside a route
Each route folder (e.g. routes/_authenticated/$orgSlug/users/) owns four colocated concerns:
users/
├── index.tsx # route component — composes pieces only
├── _apis/index.ts # typed openapi-fetch wrappers, throws Error
├── _hooks/ # one hook per query / mutation / form
│ ├── use-list-members.ts
│ ├── use-post-invitation.ts
│ └── use-invite-form.ts
└── _components/ # schema.ts (zod + types), columns, forms, tables
Folders starting with _ are excluded from TanStack Router's file-based routing — safe to store siblings there.
API client
One openapi-fetch client per app, cookie-credentialed:
// libs/api/client.ts
import createClient from "openapi-fetch"
import type { paths } from "./schema"
export const api = createClient<paths>({
baseUrl: "/api/v1",
credentials: "include",
})
Feature _apis/index.ts wraps endpoints, throws on error with a shared extractor:
export const postInvitation = async (orgSlug: string, payload: TPostInvitationRequest) => {
const { data, error } = await api.POST("/orgs/{slug}/invitations", {
params: { path: { slug: orgSlug } },
body: payload,
})
if (error || !data) throw new Error(extractErrorMessage(error))
return data
}
Queries & mutations
- One hook per endpoint, file named
use-{verb}-{noun}.ts. - Query keys are tuples:
["members", orgSlug],["invitations", orgSlug]— keep them flat and stable. - Mutations toast on both outcomes and invalidate affected lists:
export const usePostInvitation = (orgSlug: string) => {
const qc = useQueryClient()
return useMutation({
mutationKey: ["post-invitation", orgSlug],
mutationFn: async (payload) => await postInvitation(orgSlug, payload),
onSuccess: () => {
toast.success("Undangan terkirim")
qc.invalidateQueries({ queryKey: ["invitations", orgSlug] })
},
onError: (err) => toast.error(extractErrorMessage(err)),
})
}
- Single
QueryClientinstance fromlibs/tanstack-query/index.ts(staleTime: 30_000,refetchOnWindowFocus: false).
Forms
TanStack Form + Zod schema colocated in _components/*-schema.ts. Validators run onChange + onSubmit:
export const orgInviteSchema = z.object({
email: z.string().min(1, "Email wajib diisi").email("Format email tidak valid").max(254),
role: z.enum(["owner", "admin", "member"]),
})
Server error → field error via formApi.setFieldMeta(target, ...) in the hook's onSubmit. Never rely on raw toast for recoverable field-level errors.
libs/tanstack-form/field-error.tsx is the one authoritative <FieldError /> — it gates by isTouched || isDirty, reserves vertical space to prevent layout shift, and exposes role="alert" only when visible.
RBAC on the frontend
libs/auth/permissions.ts mirrors the backend role model (owner | admin | member + super-admin) and exposes hasPermission(role, resource, actions) and outranks(caller, target). UI affordances (show/hide buttons, filter role dropdowns) consult it — but never rely on it for security. Every gated action must also be enforced in presentation/_shared/deps/rbac.py with require_permission(...).
Components
src/components/
├── ui/ # primitives built on Radix (Button, Input, Card, Select, …)
└── {feature}/ # feature-scoped pieces live next to the route, not here
components/ui/*is exempt from several biome rules (seebiome.jsonoverrides) — do not copy those relaxations to feature code.- Use
cn()(clsx + tailwind-merge) for conditional classes. Tailwind 4 +tw-animate-css; no legacyclassnames.
Tooling & Gates
moon tasks (per app)
apps/api/moon.yml:
| Task | Command |
|---|---|
dev |
uv run uvicorn {pkg}_api.main:app --reload --port 3001 |
test |
uv run pytest |
lint |
uv run ruff check src tests |
typecheck |
uv run pyright |
format |
uv run ruff format src tests |
check-max-lines |
uv run python -m {pkg}_api.scripts.check_max_lines |
db-migrate |
uv run alembic upgrade head |
db-revision |
uv run alembic revision --autogenerate -m "migration" |
openapi-export |
uv run python -m {pkg}_api.scripts.export_openapi |
apps/{web,admin}/moon.yml wraps vite, vitest run, openapi-typescript, and vite build.
Pre-commit / pre-push
- Lefthook pre-commit:
lint-staged→ biome on staged JS/TS/CSS/JSON/MD. - Lefthook pre-push: full gate —
biome check+tsc+ruff check+pyright+check-max-lines+pytest.
Always use Lefthook for git hooks. Never use husky. See
push-flow-conventionskill for setup.
Never use --no-verify to push through a failing gate. Fix the root cause.
Releases
Semver bumps touch every version declaration in one commit:
package.jsonapps/web/package.jsonapps/admin/package.jsonapps/api/pyproject.tomlapps/api/src/{pkg}_api/presentation/app.py(hardcodedFastAPI(version=...))
After bumping, regenerate apps/api/openapi.json and apps/{web,admin}/src/libs/api/schema.d.ts. Tag vX.Y.Z, push, cut a GitHub release, then redeploy.
Commit Conventions
- Conventional prefix:
feat(scope):,fix(scope):,chore(release):,docs(scope):. - Close issues with
Closes #Nin the body. - No AI/Claude branding in commit messages, PR titles, or PR bodies.
Checklist Before Merging
moon run :lint— biome + ruff clean.moon run :typecheck— tsc + pyright strict, 0 errors.moon run :test— pytest + vitest green.moon run api:check-max-lines— nothing over 200 LOC.- OpenAPI regenerated if any router/DTO changed;
schema.d.tsregenerated in both SPAs. - New backend permission path? Mirrored in
libs/auth/permissions.tsand gated server-side. - New route? Guarded by the right
_authenticated/_publiclayout.
Rules Summary
- Inner Python layers (
domain,application) never import FastAPI / SQLAlchemy / pydantic-settings. - Repositories are Protocols in
domain,SqlAlchemy{X}Repositoryimplementations ininfrastructure. - One use case per file, one
.execute()method, frozen-slots dataclass for DI. - Routers are thin; commit the
AsyncSessionafter a successful write. - Frontend feature slices colocate
_apis/,_hooks/,_components/under the route. - One openapi-fetch client per app; all API calls typed against generated
paths. - One hook per query/mutation, flat tuple query keys, invalidate on mutation success.
- Forms = TanStack Form + Zod; server errors →
setFieldMeta, not free-floating toasts. - Frontend RBAC is cosmetic; security lives in
presentation/_shared/deps/rbac.py. - Tabs for JS/TS/CSS/JSON/MD/YAML, 4-space for Python. Match existing files when editing.
- 200 LOC ceiling is a hard cap — split before you hit it.
- No
--no-verify, no amending published commits, no skipping hooks. - Tenant-scoped repo methods take
organization_idas the first positional arg; tables FK toorganizationwithondelete="CASCADE". - Response DTOs expose
from_domain(...); domain entities never importpydantic.BaseModel. - No
enum.Enum— useLiteral[...]plus atuple[...]of values. - Settings are a
@lru_cache(maxsize=1)singleton with boot-time validators; never read env vars ad-hoc outsideSettings. - Big routers split into a sub-package re-exporting one
APIRouter; never silenceTID251insidedomain/orapplication/.