| Context | Convention | Example |
|---|---|---|
| Backend (Deno) | camelCase.ts |
userRepo.ts, createMessage.ts |
| React components | PascalCase.tsx |
NavChannel.tsx, LoginPage.tsx |
| Storybook stories | PascalCase.stories.tsx |
Badge.stories.tsx |
| Context providers | camelCase.tsx |
appState.tsx, theme.tsx |
| Hooks | useXxx.ts |
useSidebar.ts, useSize.ts |
| MobX models | camelCase.ts |
channel.ts, messages.ts |
| Test files | Inside __tests__/ directory |
__tests__/create.test.ts |
- Backend (Deno): Use
mod.tsas barrel file (Deno convention). - Frontend (React): No barrel files. Use direct imports to enable tree-shaking.
// Backend — re-export from mod.ts
export { createCommand } from "./command.ts";
export { Core } from "./core.ts";
// Frontend — import directly, no index.ts barrels
import { Badge } from "../atoms/Badge";
import { NavChannel } from "../molecules/NavChannel";Every React component follows this structure:
// 1. Props interface
interface BadgeProps {
count: number;
variant?: "default" | "alert";
}
// 2. Styled components (if needed)
const Container = styled.div<{ $variant: string }>`
background: ${({ theme, $variant }) =>
$variant === "alert" ? theme.alertBg : theme.defaultBg};
`;
// 3. Component function wrapped with observer()
export const Badge = observer(({ count, variant = "default" }: BadgeProps) => {
return <Container $variant={variant}>{count}</Container>;
});Key rules:
- Props interface named
XxxProps, declared above the component. - Styled-components use transient props (
$prefix) to avoid DOM forwarding. - Export the component wrapped in
observer()frommobx-react-lite. - One component per file (co-located styled-components are fine).
Commands represent write operations. They are validated with Valibot schemas and run inside MongoDB transactions.
import * as v from "valibot";
import { createCommand } from "../command.ts";
export default createCommand({
type: "channel:create",
body: v.object({
userId: Id,
name: v.string(),
channelType: v.picklist(["public", "private"]),
}),
}, async (core, { userId, name, channelType }) => {
// Business logic here — runs in a transaction
const channel = await core.repo.channel.create({ name, channelType });
await core.bus.dispatch("channel:created", { channel, userId });
return channel;
});Queries represent read operations. No transactions, no side effects.
import * as v from "valibot";
import { createQuery } from "../query.ts";
export default createQuery({
type: "channel:list",
body: v.object({
userId: Id,
}),
}, async (core, { userId }) => {
return await core.repo.channel.getAll({ userId });
});Each route file exports a factory function that takes Core and returns a Route:
import { Route } from "@planigale/planigale";
import type { Core } from "../../core/core.ts";
export default (core: Core) =>
new Route({
method: "POST",
url: "/api/channels",
schema: {
body: { name: "string", channelType: "string" },
},
handler: async (req) => {
const result = await core.channel.create({
userId: req.state.userId,
...req.body,
});
return Response.json(result, { status: 201 });
},
});Repositories extend the generic Repo<Query, Model> base class:
import { Repo } from "../repo.ts";
export class ChannelRepo extends Repo<ChannelQuery, Channel> {
COLLECTION = "channels";
makeQuery(query: ChannelQuery) {
// Convert domain query to MongoDB filter
return { ...query };
}
}The base class provides: create(), get(), getR() (required — throws if not found), getAll(), update(), remove(), count().
Domain errors are defined in core and mapped to HTTP in the inter layer:
// Core — define domain error
export class ResourceNotFound extends AppError {
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, "RESOURCE_NOT_FOUND");
}
}
// Inter — map to HTTP (in errors.ts middleware)
if (error instanceof ResourceNotFound) {
return Response.json({ error: error.message }, { status: 404 });
}React Contexts follow a consistent create-provide-consume pattern:
// 1. Create context with null default
const SidebarContext = createContext<SidebarState | null>(null);
// 2. Hook with null check
export const useSidebar = () => {
const ctx = useContext(SidebarContext);
if (!ctx) throw new Error("useSidebar must be used within SidebarProvider");
return ctx;
};
// 3. Provider component
export const SidebarProvider = ({ children }: { children: ReactNode }) => {
const state = useMemo(() => new SidebarState(), []);
return (
<SidebarContext.Provider value={state}>
{children}
</SidebarContext.Provider>
);
};Organize imports in this order, separated by blank lines:
// 1. External libraries
import { observer } from "mobx-react-lite";
import styled from "styled-components";
// 2. Types
import type { Channel } from "@quack/api";
// 3. Core / models
import { useApp } from "../contexts/appState";
// 4. Components (atoms → molecules → organisms)
import { Icon } from "../atoms/Icon";
import { Button } from "../molecules/Button";
// 5. Utilities / helpers
import { formatDate } from "../utils/date";- Tests live in
__tests__/directories adjacent to the code they test. - Use
Deno.test()as the test runner. - Use
@planigale/testingAgentfor HTTP integration tests. - Test files follow the pattern
featureName.test.ts.
import { assertEquals } from "@std/assert";
import { Agent } from "@planigale/testing";
Deno.test("channel:create - creates a public channel", async () => {
const agent = await Agent.from(app);
const res = await agent.post("/api/channels", {
body: { name: "general", channelType: "public" },
});
assertEquals(res.status, 201);
});- No component-level tests currently (planned).
- Storybook stories serve as visual tests.
- Chromatic for visual regression testing.
Follow Conventional Commits:
<type>(<scope>): <description>
[optional body]
Types: feat, fix, refactor, chore, docs, test, style, perf.
Scopes: storybook, emoji, auth, channels, messages, encryption, etc.
Examples:
feat(channels): add direct message channel creation
fix(emoji): cache Fuse instance to prevent infinite render loop
chore(storybook): standardize molecule and organism stories
docs(architecture): add system architecture documentation
main— Production-ready releases (squash merge fromdev).dev— Integration branch (PRs target here).- Feature branches —
feature/descriptionorfix/description.
- PRs target the
devbranch. - Squash merge to keep linear history.
- CI must pass before merge.
- Use styled-components for all CSS.
- Access theme values via
${({ theme }) => theme.property}. - Use transient props (
$propName) for styled-component-only props. - Theme definitions live in
app/src/js/components/contexts/themes.json. - Four themes available:
light,dark,test,dark-orange-test.