Skip to Content
QA Docs & Abangku 0.1.0 is released. Read more →
Product EngineerRepository & Project Structure

Repository & Project Structure

3.1 Monorepo vs multi-repo

This is a decision, not a default. Evaluate it per project/organization using these criteria rather than assuming one answer:

Favors a monorepo when:

  • Frontend and backend (or multiple services) share types, schemas, or business logic that would otherwise need to be duplicated or published as versioned packages just to stay in sync.
  • A single PR frequently needs to change both a client and its server contract together (e.g. an internal RPC-style API, see 7.2), and you want that reviewed and merged as one atomic change.
  • The team is small-to-medium and cross-cutting refactors (renaming a shared type, changing a shared schema) are common enough that coordinating them across separate repos would slow the team down.

Favors multi-repo when:

  • Services have genuinely independent release cadences, ownership, or access-control boundaries (e.g. a partner-facing service maintained by a different team, or code that must not be visible to engineers on another team).
  • A service is polyglot in a way that gains little from living next to the TS/Node codebase (see 2.3).
  • Build/CI times or repository size have already become a bottleneck that a monorepo’s tooling doesn’t solve for your setup.

Current answer: we use a monorepo (Turborepo). This is the concrete answer today, not a mandate. If a future service meets the multi-repo criteria above (e.g. an isolated Go service with its own release cadence), it is expected to live in its own repository even though the rest of the codebase is monorepo.

Rule: don’t default to a monorepo out of convention, and don’t split into multiple repos just because a service uses a different language. Check the criteria above per project; state which criteria applied when you set up a new repository or add a package to the existing monorepo.

3.2 Structure by layer responsibility

Structure code by what it is responsible for, not by the framework folder names a specific tool happens to use today. This document defines three layers that any fullstack codebase has, regardless of framework:

  • Presentation layer: anything concerned with rendering and user interaction: components, pages/routes, view-level state, styling. This layer should contain no direct database access and no business rules beyond what’s needed to render and validate user input.
  • Domain logic layer: business rules, decision logic, and orchestration that don’t care whether they’re invoked from a web request, a background job, or a test. This layer should be framework- agnostic: it shouldn’t import a web framework’s request/response types or a specific ORM’s query builder directly.
  • Data access layer: the only layer allowed to talk to a database, external API, or file store directly. It exposes domain-shaped operations (e.g. findActiveUsersInTenant), not raw queries, to the domain logic layer above it.

Rule: when you create a folder, ask “which of these three responsibilities does this hold,” not “what does Next.js/Express/whatever call this kind of file.” A folder named after a framework convention (e.g. app/, pages/, controllers/) is fine as an implementation detail inside the presentation layer, but it should not be the top-level organizing principle of the whole codebase. This is what keeps the structure meaningful if the frontend framework changes later: the layers don’t move even if the framework does.

Dependency direction is one-way: presentation depends on domain logic, domain logic depends on data access abstractions (not concrete data access implementations where avoidable, see 8.5 on dependency injection). Data access never depends upward on domain logic or presentation. A change in the presentation layer should never require a change in the data access layer, and a violation of that is a structural bug, not a style nitpick.

3.3 Naming conventions

  • Files: kebab-case for file and folder names (user-profile.ts, not UserProfile.ts or user_profile.ts), except where a framework or tool enforces a different convention for a specific file (document the exception where it occurs, don’t silently deviate).
  • Components: PascalCase for the exported component/class identifier itself, even though the file name is kebab-case (user-profile.tsx exporting UserProfile).
  • Functions and variables: camelCase.
  • Types and interfaces: PascalCase, and prefer a plain type/interface name over a I/T prefix (User, not IUser): the prefix adds noise without adding information in a language with structural typing.
  • Booleans: prefix with is/has/should (isLoading, hasPermission) so intent is legible at the call site without reading the definition.
  • Consistency over preference: if an existing convention is already established in a file or module you’re editing, match it even if it’s not your personal preference. Do not mix conventions within the same module.

3.4 Promoting local code into a shared package

Code starts local (inside the app or service that needs it). Promote it into a shared package only when it meets more than one of these conditions. A single condition is usually not enough justification on its own:

  1. Actual reuse, not anticipated reuse. A second consumer already needs it, today, not “this might be useful elsewhere someday” (see 8.4 YAGNI).
  2. Stability. The code’s interface has stopped changing shape frequently. Promoting a still-churning piece of code creates a shared dependency that becomes expensive to change later, because now every consumer must be updated in lockstep.
  3. Domain-generic, not feature-specific. The code expresses a concept that is genuinely general (a date-formatting helper, a shared Zod schema, a design-system component) rather than logic specific to one feature that merely looks reusable because it’s well-written.

Shared package location: packages/*. Given the current monorepo setup (Turborepo, see 2.2), a promoted package lives under a top-level packages/ directory, as a sibling to the apps/ directory holding the individual applications and services that consume it. Name the package after the concept it expresses (e.g. packages/date-utils, packages/api-schemas), not after which app happened to need it first.

Rule: when you promote code, it moves to packages/* and its original call sites are updated to import from there; it does not get duplicated “for now” and reconciled later. A shared package with exactly one consumer today is acceptable if you can point to a concrete, already-planned second consumer; it is not acceptable as a speculative default for all utility code.