Appearance
Crest — Conventions
| Product | Crest — Personal Finance App |
| Document version | 0.6 (Draft) — adds code style (§5), admin portal forms (§6), and commit messages (§7) |
| Date | 2026-10-02 |
| Based on | TechnicalDesign.md v0.8 |
| Owner | Reizkian Y. Radityatama |
| Status | Active — mandatory for all code and documents; enforced by tooling (§8) |
These rules apply to everyone who changes the Crest repository, including AI assistants. A change that breaks them fails CI and is not merged.
Crest has four applications: Apps/Api (NestJS), Apps/Mobile (Flutter), Apps/Admin (Angular), and Apps/Site (static pages). Each framework has its own default naming style; Crest's rules replace those defaults.
1. File naming — the rule
Every code file and every document file is named in PascalCase, in every application.
| Framework default (not used) | Crest name |
|---|---|
Flutter / Dart: login_page.dart, money_formatter.dart | LoginPage.dart, MoneyFormatter.dart |
NestJS: user.service.ts, user.controller.ts, create-user.dto.ts, users.module.ts | UserService.ts, UserController.ts, CreateUserBody.ts, UsersModule.ts |
Angular: access-request-list.component.ts / .html / .scss | AccessRequestList.ts, AccessRequestList.html, AccessRequestList.scss |
| Kind | Rule | Examples |
|---|---|---|
Dart source (Apps/Mobile) | PascalCase.dart, named after its main class or widget | LoginPage.dart, AddTransactionSheet.dart, SpaceSwitcher.dart, ApiClient.dart |
| Dart tests | Same name + _test.dart (the suffix is required by Dart's test runner) | LoginPage_test.dart, MoneyFormatter_test.dart, LogExpenseJourney_test.dart |
| Generated Dart files | Same name as their source + the generator's suffix | Transaction.g.dart, Transaction.freezed.dart |
TypeScript source (Apps/Api, Apps/Admin) | PascalCase.ts, named after its main export | UserService.ts, TransferController.ts, SpaceMemberGuard.ts, AccessRequestList.ts |
| TypeScript tests | Same name + Spec, in Test/ (never .spec.ts, never next to the source) | Test/Unit/Auth/AuthServiceSpec.ts, Test/Integration/Database/RowLevelSecuritySpec.ts |
| Angular templates and styles | Same name as the component | AccessRequestList.html, AccessRequestList.scss |
| Type declarations | PascalCase.d.ts | Env.d.ts |
| Scripts (TypeScript or shell) | PascalCase.ts / PascalCase.sh | CreateSuperAdmin.ts, ResetTwoFactor.ts, CheckFileNames.ts, Backup.sh |
| SQL migrations | 4-digit order prefix + _ + PascalCase | 0001_InitialSchema.sql, 0002_RowLevelSecurity.sql |
| Other SQL files | PascalCase.sql | SeedDemoData.sql |
| Email templates | PascalCase + extension | ActivationEmail.hbs, SpaceInvitationEmail.hbs |
Design prototype pages (Prototype/) | PascalCase + .dc.html (the design tool needs that ending to open a page) | CrestPrototypeV3.dc.html, CrestLoginOptions.dc.html |
Planning documents (Apps/Docs/) | PascalCase.md, in the folder of its section (Product/, Engineering/, Legal/) | Product/BusinessRequirements.md, Engineering/Conventions.md |
| Data and contract files written or generated for Crest | PascalCase.json | Packages/ApiContract/OpenApi.json, Packages/MoneyTestVectors/Conversion.json |
| Other documents | PascalCase.md | Infra/Runbooks/RebuildServer.md, Infra/ServerSetup.md |
Acronyms are written as words: ApiClient.dart, CsvExportService.ts, SqlHelpers.ts — not APIClient.
One main class, widget, or export per file, and the file is named after it: class UserService lives in UserService.ts; class LoginPage extends StatelessWidget lives in LoginPage.dart.
Do not use framework generators' default names. nest generate and ng generate create kebab-case files; rename them before committing, or create files by hand.
2. Exceptions — world standards
Files whose names are fixed by a tool, a framework, or a widely followed standard keep their standard names. Only the cases below are allowed; anything else must be PascalCase.
2.1 Configuration and tooling files
| File | Why |
|---|---|
package.json, pnpm-workspace.yaml, pnpm-lock.yaml, turbo.json, .npmrc | npm / pnpm / Turborepo |
tsconfig.json, tsconfig.*.json | TypeScript |
eslint.config.mjs, .prettierrc, .prettierignore, .editorconfig | Linters and formatters |
*.config.ts / *.config.mjs (e.g. jest.config.ts, drizzle.config.ts, playwright.config.ts) | Tool configs |
nest-cli.json, angular.json | NestJS and Angular |
Apps/Docs/.vitepress/config.mts, Apps/Docs/.vitepress/theme/index.ts | VitePress (the documentation site) finds its configuration and theme by these exact names |
pubspec.yaml, pubspec.lock, analysis_options.yaml, l10n.yaml, devtools_options.yaml, .fvmrc | Flutter / Dart (.fvmrc pins the Flutter version) |
Dockerfile, .dockerignore, docker-compose.yml, docker-compose.*.yml | Docker |
Caddyfile | Caddy |
.htaccess | Apache (shared hosting, Infra/Hosting) |
.github/workflows/*.yml, .github/dependabot.yml, .github/pull_request_template.md | GitHub |
.gitignore, .gitattributes, lefthook.yml | Git and Git hooks |
.env, .env.example, .env.* | Environment files |
README.md, LICENSE, CHANGELOG.md, CONTRIBUTING.md, SECURITY.md, CLAUDE.md | Repository-level standard documents |
2.2 Framework entry files
| File | Why |
|---|---|
Apps/Mobile/lib/main.dart | Flutter's tools start the app from this exact file. Keep it thin: it only calls runApp(const CrestApp()) from CrestApp.dart |
Prototype/support.js | The design tool's runtime. Every prototype page loads it by this exact name, so renaming it would break all of them |
Entry files that can be configured use PascalCase: Apps/Api/Src/Main.ts ("entryFile": "Main" in nest-cli.json) and Apps/Admin/Src/Main.ts (set in angular.json), and Apps/Admin/Src/Index.html (the index option).
2.3 Other standards
| What | Rule | Why |
|---|---|---|
| Flutter translation files | app_en.arb, app_id.arb | Flutter's localization tool expects this pattern |
| Admin translation files | Apps/Admin/Public/i18n/en.json, id.json (the folder is PascalCase; the files in it are addressed by URL, so lowercase) | BCP 47 language tags |
Static assets and public web files (Apps/Site/**, any assets/ or public/ folder, logo/) | lowercase kebab-case: crest-icon.svg, request-access.html, privacy.html | They are addressed by URL or asset path |
Flutter platform folders (android/, ios/, Apps/Mobile/web/, …) | Untouched | Generated and owned by Flutter and the platform tools |
| Generated and vendor files | Untouched: node_modules/, .dart_tool/, dist/, build/, coverage/, lockfiles | Not written by hand |
3. Folder naming
| Where | Rule | Examples |
|---|---|---|
| Every folder (repository structure, source code, documents, design handoff) | PascalCase, acronyms as words | Apps/Api, Apps/Mobile, Apps/Admin, Packages/ApiContract, Apps/Api/Migrations/, Apps/Docs/Engineering/, Infra/Runbooks/, Scripts/, Apps/Api/Src/Transactions/, Apps/Mobile/lib/Features/Auth/, Prototype/Assets/Logos/Moss/ |
| Folders a tool or framework names | As the tool requires (lowercase) | lib, integration_test, l10n, web, theme, workflows, node_modules |
Static and public folders (inside Apps/Site/, assets/**, public/**, logo/) | lowercase kebab-case | Apps/Mobile/assets/images/, Apps/Site/legal/ |
| Dot-folders | As the tool defines them | .github, .vscode, .claude |
Where a tool lets you choose, the folder is PascalCase: NestJS and Angular use Src (sourceRoot), Jest and the API use Test, builds go to Dist, and the Angular entry page is Src/Index.html. Flutter's Test is chosen too (run as flutter test Test), while Flutter fixes lib, web, android, and ios, and VitePress fixes theme. Names that are a standard in their own right keep it: l10n (localization) and i18n (internationalization) are not forced to PascalCase. Angular's default src/app/ folder is not used; the admin app's code lives in PascalCase folders directly under Src/ (e.g. Src/Core/, Src/Features/).
4. Names inside the code
| Thing | Style | Example |
|---|---|---|
| Classes, widgets, components, types, interfaces, enums (Dart and TypeScript) | PascalCase | LoginPage, UserService, Money, SpaceRole |
| Functions, methods, variables, properties (Dart and TypeScript) | camelCase | formatMoney, parseAmount, includeInTotals |
| Constants (TypeScript), environment variable names | SCREAMING_SNAKE_CASE | MAX_MEMBERS_PER_SPACE, DATABASE_URL_APP |
| Constants (Dart) | camelCase (Dart's standard) | maxMembersPerSpace |
| Dart package names, npm package names | lowercase (required by the tools) | crest_mobile, @crest/api |
| Database tables, columns, enum types, functions | snake_case (Postgres standard) | financial_account, created_at, upsert_transfer |
| API paths | lowercase kebab-case, plural nouns | /v1/financial-accounts, /v1/access-requests |
| JSON fields in the API | camelCase | financialAccountId, createdAt |
| String values of an enum, a union type, or an error code, in the database, the API, and the clients | PascalCase, the same spelling everywhere | Pending, CreditCard, NotFound (§5.10) |
The Crest terminology rule applies everywhere, including code and API names: never use account alone. Use User / app_user for a login and FinancialAccount / financial_account for money.
Lint settings that follow from these rules
Apps/Mobile/analysis_options.yamlturns off Dart'sfile_nameslint (which demands snake_case) and keeps every other recommended lint on.Apps/ApiandApps/Adminuse ESLint witheslint-plugin-check-fileset to PascalCase for files and folders insideSrc/.
5. Code style (TypeScript)
These rules apply to the TypeScript source of Apps/Api and Apps/Admin (Src/, not tests unless a rule says so). They exist so that every branch of the code can be read, and debugged, one condition at a time. ESLint enforces the ones marked (lint).
No conditional (ternary) expressions (
a ? b : c) (lint:no-ternary). Useif/elseand a named variable. Tests may use them.ts// No const status = databaseUp ? "ok" : "degraded"; // Yes let status: HealthStatus = "degraded"; if (databaseUp) status = "ok";Name the conditions of a complex
if. Anifthat combines more than two conditions gets one named boolean per condition first (lint).tsconst isSuperAdmin = auth.isSuperAdmin; const isAdminSession = auth.client === "admin"; const passedTwoFactor = auth.twoFactorVerified; if (isSuperAdmin && isAdminSession && passedTwoFactor) { … }Throw on invalid data; never hide it behind a fallback. If a value must be present or valid, check it and throw (an
ApiExceptionin request code, anErrorelsewhere). Defaults are for values that are genuinely optional, never to paper over bad data. In a money app a silent default is how a wrong balance happens.Name the caught error
error, or leave it out (catch { … }) when it is not used; nevercatch (e)or other one-letter names (lint, in tests too).No arrow functions, anywhere. Named functions are
functiondeclarations, and callbacks arefunctionexpressions (items.map(function (item) { return … })), in tests too (lint:no-restricted-syntaxandfunc-style). Function types such as(value: string) => voidare not arrow functions and stay.Build URLs with
URLand file paths withnode:path, never by joining strings (in tests too).tsconst link = new URL("/activate", environment.siteOrigin); link.searchParams.set("token", token); const file = join(Migrations.DIRECTORY, name);Comments say why, not what. Explain business rules (with their
BR-/FR-id), security reasons, and anything surprising. Never restate the code. A comment above a declaration (function, class, interface, type, enum, constant, class member) is a/** */doc comment, never//(lint);//is for a note inside a body.Every method of a service or repository has a doc comment (
/** … */) that says what it does for the caller, plus anything non-obvious: which database role it runs as, what it returns when nothing is found, which error it throws (lint:jsdoc/require-jsdocon*Service.tsand*Repository.ts). Every exported function, class, interface, type, enum, and constant has one too, in tests as well.Helper code is a static class, and its constants live inside it (lint,
Apps/Api/Src). A file of related functions is one class named after the file, withstaticmethods:Cursor.encode(),Money.parseAmount(),PasswordHasher.hash(). A file does not declare a top-levelfunction, and does not declare a top-levelconstunless its value is a call (Symbol("TOKEN"),promisify(fn)), which is how a framework token or wrapper is made. A constant is aprivate static readonlymember of the class that uses it, orstatic readonlywhen other code needs it (Money.SEPARATORS).ts// No const POSTGRES_TIMESTAMP = /^\d{4}-\d{2}-\d{2} …$/; export function encodeCursor(position: CursorPosition): string { … } // Yes export class Cursor { private static readonly POSTGRES_TIMESTAMP = /^\d{4}-\d{2}-\d{2} …$/; /** Turns a list position into the opaque `nextCursor` text. */ static encode(position: CursorPosition): string { … } }- Call a sibling by the class name (
Cursor.POSTGRES_TIMESTAMP,ValidationErrors.fieldErrors()), notthis, so a method still works when it is handed to a framework as a callback. - A static method passed as a callback (
@Transform(QueryArray.transform),exceptionFactory: ValidationErrors.toException) is declared withthis: void, which is what the@typescript-eslint/unbound-methodrule asks for. - Not covered: tests and test helpers;
Main.tsandSrc/Scripts/, which are programs; the decoratorsSrc/Auth/Public.tsandSrc/Auth/CurrentAuth.ts, which are functions by Nest's design; andApps/Admin, which follows Angular's structure. Classes with state (services, repositories, guards) are ordinary Nest classes and are not "helpers".
- Call a sibling by the class name (
String values are PascalCase, in the database and in the API (lint and a database test). The value of a string enum or union type (
AccessRequestStatus.Pending = "Pending"), a Postgres enum value ('CreditCard'), and an error code ("NotFound") are written the same way, with each word capitalised and no underscores. An enum member is spelled the same as its value. The API sends and receives a value exactly as it is stored, so no code translates between the database and the clients.ts// No export enum ErrorCode { NotFound = "NOT_FOUND" } export type AuthClient = "mobile" | "admin"; // Yes export enum ErrorCode { NotFound = "NotFound" } export type AuthClient = "Mobile" | "Admin";- Names stay as they are: the Postgres enum type (
access_request_status), columns, and JSON field names keep the styles in §4. Only the values change. - A value that an outside system defines keeps that system's spelling, with an
eslint-disable-next-line no-restricted-syntax -- <reason>that says so:NODE_ENV("production"), Postgres role names (crest_app), ISO currency and country codes, and locale codes (en,id). Punctuation, such as"."as a decimal separator, is not a word and is not checked. - Dart enums keep Dart's lowerCamelCase members (
TransactionKind.expense); the code that reads a value from the API or the shared test vectors turns"Expense"into the member (MoneyVectors_test.dart).
- Names stay as they are: the Postgres enum type (
6. Admin portal forms (Apps/Admin)
An edit page fills its form from one API response, as it is. The read, create, and update endpoints of a resource return the same shape (Apps/Docs/Engineering/ApiModuleStandard.md §4), so the page sets the form from the response without reshaping it, and shows the form only once it is filled. If the page has to patch the data first, the API's response is what needs fixing.
The request body is built inline in the save method, from the validated
form.value, typed with the generated client's request type. No separate mapper functions for an ordinary form.tssave(): void { if (this.form.invalid) return; const value = this.form.getRawValue(); const request: RejectAccessRequestBody = { // Optional fields are left out when empty, never sent as "". note: value.note.trim() || undefined, }; … }Optional text fields that are empty are left out, not sent as
"". Never loosen the API's validation to accept blank values from a form.
7. Commit messages
A commit message starts with a type, an optional scope, and a short summary in the imperative, checked by the commit-msg hook (Scripts/CheckCommitMessage.ts):
feat(api): add the access request list for Super Admins
fix(admin): keep the filter when paging
docs: add code style rules| Type | For |
|---|---|
feat | New behaviour users or clients can see |
fix | A bug fix |
docs | Documentation only |
refactor | Code change with no change in behaviour |
test | Tests only |
build, ci, chore | Tooling, dependencies, CI, housekeeping |
perf | Faster, same behaviour |
Scopes are the app or area: api, admin, mobile, site, db, infra, docs. Merge and revert commits made by Git keep their own messages.
8. Enforcement
| Layer | What it checks | When |
|---|---|---|
Scripts/CheckFileNames.ts | Every file and folder in the repository (Dart, TypeScript, HTML, SQL, Markdown, assets), against §1–§3; prints each violation with the reason | pnpm lint, pre-commit hook, CI |
ESLint (eslint-plugin-check-file, eslint-plugin-jsdoc) | TypeScript file and folder names in Apps/Api and Apps/Admin; the code style rules marked (lint) in §5; in Apps/Api controllers, the …Body / …Query / …Response names of every route (ApiModuleStandard.md §1.4); no top-level functions or constants in Apps/Api/Src (§5.9); PascalCase string values of enums and unions (§5.10); Test/Integration/Database/EnumValuesSpec.ts: PascalCase values of every Postgres enum | In the editor, pnpm lint, pre-commit hook, CI |
| Dart analyzer | Dart code style (with file_names disabled) | In the editor, flutter analyze, CI |
Git hooks (lefthook.yml) | Pre-commit: the file-name check, formatting, lint, and type check on staged files. Commit-msg: the message format (§7) | Every git commit |
CI (.github/workflows/ci.yml) | Runs all of the above; a violation fails the build | Every push and pull request |
CLAUDE.md | Tells AI assistants these rules before they write files | Every AI session |
| Pull request template | Checkbox: "File and folder names follow Conventions.md" | Every pull request |
Status: Scripts/CheckFileNames.ts and Scripts/FileNamingRules.ts exist and run today with node Scripts/CheckFileNames.ts (Node 24 runs TypeScript directly). ESLint, the Dart analyzer settings, the pre-commit hook, CI, and the pull request template are added in milestone M0 and call the same script.
The allowed exceptions (§2) are listed in one place, Scripts/FileNamingRules.ts. Adding an exception means changing that file and this document in the same pull request.
9. Diagrams in the documents
Diagrams are Mermaid (a ```mermaid block), never ASCII art. They render on the documentation site and on GitHub, and a reader can follow them without counting characters. Use the type that fits: a sequenceDiagram for a conversation between parties, a flowchart for structure or a process, an erDiagram for tables. Keep each one narrow enough to read at the width of the page (about 700 px): short labels, a top-to-bottom layout, and no more than four or five boxes in a row. A directory listing is not a diagram and stays a plain code block.
10. Changing these rules
Changes go through a pull request that updates this document, Scripts/FileNamingRules.ts, and any renamed files together.