Skip to main content

Developer Experience & Setup

A team's velocity is bounded by its developer experience. If it takes one or two days to set up a laptop, if the dev server crashes every hour, if there is uncertainty on which env vars are required there is a tax paid on every feature, every sprint, every new hire. Good DX isn't a luxury. It's how you stop bleeding time to friction that has nothing to do with the actual problem you're solving.


Why this matters

Developer experience is where S&P's value of Evolution meets daily reality. Every minute an engineer spends fighting tooling, guessing at configuration, or waiting for a build is a minute not spent solving the client's problem. Teams with good DX onboard faster, ship with fewer "works on my machine" bugs, and retain engineers who would otherwise burn out on preventable friction.

The goal is not to automate everything or chase the latest tooling trends. The goal is to make the common path frictionless: clone, install, run, develop, commit, push. When that loop is fast and reliable, everything else (code quality, review turnaround, deployment confidence) gets easier.


The standard

Environment setup

Every S&P project should be runnable on a fresh machine with minimal manual steps. The fewer things a developer has to "just know," the healthier the project.

Node.js version management

Use nvm with an .nvmrc file at the repository root. This removes all ambiguity about which Node version the project expects.

Package manager

pnpm is the recommended package manager for new projects. It's faster than npm, uses less disk space through content-addressable storage, and handles monorepo workspaces natively. Existing projects on npm or yarn don't need to migrate for migration's sake. But new projects should start with pnpm unless there's a specific reason not to.

IDE

Cursor is the preferred IDE. It's a VS Code fork with integrated AI capabilities, so it runs all VS Code extensions and respects .vscode/ settings. Projects should commit both .vscode/ (settings, launch configs, recommended extensions) and .cursor/rules/ (AI coding standards) to the repo. For guidance on what to put in .cursor/rules/, CLAUDE.md, and how to build AI-assisted workflows, see AI-Assisted Engineering.

Engineers who prefer VS Code can use it, the configs are compatible. What matters is that the project's shared settings are committed, not which editor opens them.

For nvm configuration, pnpm setup, IDE extensions, and the complete .vscode/extensions.json, see DevOps Reference -- Environment setup and tooling.

Linting and formatting

New projects: Biome. Starting with NestJS 12 projects, use Biome as the single tool for linting and formatting. Biome replaces both ESLint and Prettier with one binary, it's faster (written in Rust), requires less configuration, and eliminates the class of bugs caused by ESLint and Prettier configs conflicting with each other.

Existing projects: ESLint + Prettier. Projects already on ESLint + Prettier don't need to migrate mid-flight. The tooling works, and the migration cost isn't justified until a natural break point (major framework upgrade, new project phase).

For Biome configuration, ESLint+Prettier setup, and the migration path between them, see DevOps Reference -- Linting and formatting.

Git hooks and commit conventions

Automate what humans will forget. Linting on save is a suggestion; linting on commit is a guarantee.

New projects: Lefthook. Use Lefthook for git hooks, it's faster than Husky, configured in a single YAML file, runs tasks in parallel, and doesn't require npm lifecycle scripts to install.

Existing projects: Husky + lint-staged. Projects already using Husky + lint-staged can stay on them.

The --max-warnings=0 flag is intentional in both setups. Warnings that are permanently ignored are worse than no warnings at all, they train the team to ignore the linter output.

For Lefthook and Husky configuration examples, see DevOps Reference -- Git hooks.

Conventional Commits

Use commitlint with @commitlint/config-conventional on the commit-msg hook (in both Lefthook and Husky setups). This enforces structured commit messages that are parseable by tooling (changelogs, release notes, CI triggers).

feat: add pagination to users endpoint
fix: handle null avatar in profile response
chore: bump biome to v2
docs: update API authentication guide
refactor: extract payment validation to shared module

Allowed types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test.

The value isn't the format itself, it's that git log --oneline becomes a readable history of what changed and why, instead of a stream of "fix stuff" and "WIP".

Monorepo structure

S&P projects typically use a pnpm workspace monorepo. The standard layout separates applications, shared libraries, and infrastructure: keeping related code in the same repo so that cross-cutting changes are atomic commits rather than coordinated multi-repo merges.

For the standard monorepo layout, pnpm workspace configuration, and shared library patterns, see DevOps Reference -- Monorepo structure.

No barrel files

Do not use barrel files (index.ts that re-exports everything from a directory). They cause several real problems:

  • Bundle size. Importing one function from a barrel pulls in the entire module graph behind it. Tree-shaking helps in production builds, but dev servers and test runners pay the full cost.
  • Circular dependencies. Barrels create implicit dependency cycles that surface as mysterious runtime errors: undefined imports, initialization order bugs. These are painful to debug because the cycle isn't visible in any single file.
  • Slow IDE performance. TypeScript's language server has to resolve every re-export in the barrel to provide autocomplete. In large projects, this measurably slows down intellisense.
  • Unclear dependency graph. With barrels, every file appears to depend on the same index.ts. Without them, imports point directly to the source file, you can trace dependencies at a glance.

Import directly from the source file:

// Do this
import { UserService } from './users/user.service';

// Not this
import { UserService } from './users';

The one exception is the public API of an FSD slice, each slice's index.ts acts as an explicit boundary defining what other layers can consume. This is intentional encapsulation, not lazy re-exporting.

Frontend architecture: Feature Sliced Design

Feature Sliced Design (FSD) is the standard frontend architecture. It organizes code into layers (app, pages, widgets, features, entities, shared) with strict import rules, each layer can only import from layers below it. This prevents the "everything in components/" problem that plagues React projects as they grow.

Why FSD: Explicit dependency direction, team scalability (features are isolated slices), and immediate structural legibility for new developers.

Supported frameworks: Next.js (default (FSD works with App Router, keep route files thin) and React + Vite (for SPAs that don't need SSR). The architecture is framework-agnostic) only the app/ layer adapts to the framework's routing mechanism.

For the full layer hierarchy, slice structure, Next.js App Router adaptation, import rules enforcement with eslint-plugin-boundaries, and React+Vite setup, see Frontend Reference. Project structure.

API type generation

The backend generates an OpenAPI specification (via @nestjs/swagger). The frontend consumes it to auto-generate TypeScript types and API client code. This eliminates a whole class of bugs where frontend and backend disagree on request/response shapes. Recommended tools: openapi-typescript for types only, orval for generated API clients with React Query hooks.

For the backend OpenAPI setup (@nestjs/swagger plugin, controller decorators, JSON spec export), see Backend Reference (API design implementation. For the frontend consumption pipeline (type generation config, TanStack Query integration), see Frontend Reference) API consumption.

Local development

Database

Projects with a database should include a docker-compose.yml that starts the required services (PostgreSQL, Redis, etc.) with a single command. No developer should need to install database software directly on their machine.

PostgreSQL 18 is the standard for new projects. Pin the major version in Docker Compose.

For the Docker Compose configuration and local database setup, see DevOps Reference -- Local development infrastructure.

Environment variables

Every app must include a .env.example file listing all required variables with placeholder values and brief comments explaining what each one does. This file is committed to the repo. The actual .env is gitignored.

# .env.example
DATABASE_URL=postgresql://dev:dev@localhost:5432/app_dev
FIREBASE_PROJECT_ID=your-project-id # Firebase console → Project settings
RESEND_API_KEY=re_xxxxxxxxxxxx # https://resend.com/api-keys
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxx # https://dashboard.stripe.com/apikeys

When an engineer adds a new env var to the code, they must also add it to .env.example in the same PR. Reviewers should check for this.

Dev scripts

Use consistent script names across all apps in the monorepo:

ScriptPurpose
devStart development server with hot reload
buildProduction build
startRun production build locally
lintRun linter (Biome or ESLint)
formatRun formatter (Biome or Prettier)
checkRun linter + formatter in one pass (Biome projects)
testRun unit tests
test:e2eRun end-to-end tests
db:migrateRun database migrations
db:seedSeed database with development data

Run them via pnpm filters from the root: pnpm --filter api dev, pnpm --filter web dev.

Local debugging

Once the app is running, most of a developer's time is spent on the other loop: something behaves wrong, and you have to work out why. This is detective work, and the team should be equipped for it. The failure mode to avoid is changing lines until the symptom disappears: that fixes the symptom, leaves the cause, and usually breaks something else. The bar is higher: reproduce the problem, form a hypothesis, test it, and confirm the fix addressed the actual cause.

This section is the local development loop. Reading logs during a production incident is a different skill covered in Observability & Incidents; the techniques there (correlation IDs, reading a stack trace, filtering a log stream) apply here too and are cross-referenced rather than repeated.

Debug like a detective. The method is the scientific method, and it is faster than guessing even when guessing feels quicker:

  1. Reproduce it consistently first. A bug you cannot trigger on demand is a bug you cannot confirm you fixed. Nail down the exact steps, inputs, and state before touching any code.
  2. Form one hypothesis at a time, as expected-versus-actual. "I expect the endpoint to return the updated user, I observe it returns the stale one, I think the cache is not being invalidated because the update path skips the eviction." Vague hunches ("something's off with caching") are not testable.
  3. Test the smallest possible change. One variable at a time. Changing three things and watching the symptom vanish tells you nothing about which one mattered.
  4. Read the first error, not the last, and find your own code in the stack trace. In a cascade the last error is usually a downstream symptom. The techniques for this are in Observability & Incidents.

Root cause before fix is not bureaucracy. It is the difference between a fix that holds and a fix that comes back next sprint.

Run the backend in debug mode. Attaching a debugger and setting a breakpoint lets you inspect live state (variables, the call stack, the actual request object) at the moment things go wrong. That is strictly more information than adding a log line, re-running, reading, and repeating. Use the NestJS start:debug script and attach from Cursor or VS Code with a committed launch config. For the --inspect scripts and the debugging-a-single-test recipe, see Backend Reference. Local debugging. For the shared .vscode/launch.json, see DevOps Reference. Debug configuration.

Use debug-level logging, never console.log. When a breakpoint is impractical (an async race, a fast loop, something that only reproduces under load), add structured log lines at debug level via the NestJS logger, not console.log. The reasoning is the same as in production (Observability & Incidents): console.log is unstructured, unleveled, and easy to leave behind by accident. Set LOG_LEVEL=debug locally to see them (pino-pretty is already wired for development). Before you commit, strip the temporary lines or leave them at debug level, never promote them to info.

Share runnable requests, not screenshots. A screenshot of a response is a dead end: nobody can run it, tweak a header, and see what changes. Share the request instead.

  • Backend: keep a Bruno collection committed to the repo so every endpoint has a versioned, runnable request that the whole team shares. See Backend Reference. Local debugging.
  • Frontend to backend: when the frontend team is looking at an API and something is wrong, share the request as a cURL in the Slack thread or PR (browser DevTools, Network tab, right-click the request, Copy as cURL), not a screenshot of the failing response. The backend engineer then reproduces it verbatim, headers and body included, in one paste. See Frontend Reference. Debugging API calls in the browser.

Trace a request from frontend to backend. When a bug spans both sides, the x-correlation-id header is the thread that ties a frontend action to the backend logs it produced (defined in Observability & Incidents). The frontend API client sends and surfaces it; the backend logs it on every line; one ID reconstructs the whole round trip. On the frontend side, the DevTools Network tab and the TanStack Query devtools show request state and cache behaviour. See Frontend Reference. Debugging API calls in the browser.

Write down what you find. Keep a running note in the ticket or PR as you investigate: the hypothesis, what you expected, what you actually observed, and the conclusion. This costs a minute and pays for itself twice: it stops you (and the next person) re-walking the same dead ends, and a local finding about a production-shaped failure is the raw material for a runbook or a post-incident review (Observability & Incidents). This is the Engineering Principles "write it down" habit applied to debugging.

ORM and database access

S&P doesn't mandate a single ORM, the right choice depends on the project's needs (TypeORM, Drizzle, Prisma, and Kysely are all viable). What matters more than which ORM you pick: one ORM per data layer (don't mix), migrations committed and reviewed like code, and seeds for local development.

For the full ORM comparison table, PostgreSQL conventions, migration workflow, and database security practices, see Backend Reference. Database patterns.

CI/CD

S&P projects use different CI platforms depending on the hosting context:

  • Bitbucket Pipelines: For projects hosted on Bitbucket (bitbucket-pipelines.yml)
  • CircleCI: Increasingly used across S&P projects; strong fit for complex pipelines and GCP OIDC integration (.circleci/config.yml)
  • GitHub Actions: For projects hosted on GitHub, including some client projects (.github/workflows/*.yml)

Regardless of platform, every CI pipeline should:

  1. Run lint + type-check on every PR. Fast feedback. No exceptions.
  2. Run tests on every PR. Unit and integration at minimum.
  3. Build the artifact on every PR. Catch build failures before merge, not after.
  4. Deploy automatically on merge to environment branches. Development and staging should never require manual deployment steps.
  5. Use path filtering in monorepos. Don't rebuild and redeploy the API when only the web app changed. Most CI platforms support change detection.

Project scaffolding

S&P maintains a monorepo template that serves as the starting point for new projects:

engineering-forward/templates/backend-monorepo: pnpm workspace monorepo with NestJS API, Next.js web app, shared libraries, GCP Terraform infrastructure, CI pipeline configs, and AI IDE rules.

When starting a new project:

  1. Clone the template and strip the parts you don't need (e.g., remove functions/ if no cloud functions, remove infrastructure-secure/ if standard infra is sufficient).
  2. Update package.json names, .nvmrc, and environment-specific configuration.
  3. Replace placeholder README content with project-specific setup instructions.
  4. Run through the "First day on a project" checklist below to verify everything works end to end.

The template is a starting point, not a cage. Adapt it to the project's actual needs. But start from it: reinventing monorepo setup from scratch every time is waste.

Joining an existing project

Onboarding to an existing codebase should not depend on tribal knowledge. If a new developer can't get the project running by following the README, the README is broken: fix it before moving on.

First day on a project: verification checklist:

  1. Clone the repo and run nvm use && pnpm install. Both should succeed without errors.
  2. Copy .env.example to .env and fill in any values that require access credentials (Slack the team lead if unclear which ones).
  3. Start the database with docker compose up -d.
  4. Run migrations: pnpm --filter api db:migrate.
  5. Seed development data: pnpm --filter api db:seed.
  6. Start the API dev server: pnpm --filter api dev. Hit the health endpoint.
  7. Start the web dev server: pnpm --filter web dev. Verify the page loads.
  8. Run the test suite: pnpm test. All tests should pass on a fresh clone.
  9. Make a trivial change, commit it, and verify git hooks fire (lint, commitlint).
  10. Open a draft PR to verify CI runs.

If any step fails, document the fix and update the README before doing anything else. The next person who joins will hit the same wall.


Critical thinking

When to invest in DX tooling

Not every project needs the full monorepo template with Terraform, CI/CD, and cloud functions. A short-lived prototype or a small internal tool might be fine with a simpler setup. The question to ask is: will more than two developers work on this for more than one sprint? If yes, invest in proper DX. If no, keep it minimal but don't skip the basics (git hooks, .env.example, working dev script).

When to deviate from the template

The template encodes current best practices, but it's not sacred. Deviate when:

  • The project's hosting constraints require a different CI platform or infrastructure provider.
  • A client mandates specific tooling (their ESLint config, their Git workflow).
  • The project is genuinely small enough that the monorepo structure adds more overhead than value.

When you deviate, document why in the project's README. "We use X instead of Y because [reason]" is enough.

The "works on my machine" test

Before you mark a DX setup as "done," have someone who has never touched the project follow the README on a clean machine. If they can't get to a running dev environment in under an hour, the setup isn't done, it's documented for you, not for the team.

Tooling churn

New build tools, bundlers, and dev servers appear constantly. Resist the urge to adopt something just because it's faster in a benchmark. Evaluate based on: does it solve a real friction we have today? Is it mature enough that we won't be debugging the tool instead of our code? Does the team want to maintain this migration? Stability beats novelty in production tooling.

Debugger or logs: pick by the bug, not by habit

Reach for the debugger when you can reproduce the bug locally and the question is about control flow or state: which branch ran, what a variable actually held, why a guard let something through. A breakpoint answers those in seconds. Reach for logs when you cannot pause the system: a timing or concurrency bug, something that only appears under load, a failure that spans multiple services, or anything happening in a deployed environment. Most developers default to whichever they used last; the faster habit is to match the tool to the shape of the bug. And whichever you use, clean up after yourself: a committed breakpoint statement (debugger;) or a stray info-level log line is noise the next person has to notice and remove.


Checklist

Use this before declaring a project's DX "ready": whether it's a new project or you've just improved an existing one.

  • .nvmrc exists and matches the Node version in CI and Dockerfiles
  • pnpm install completes without errors on a fresh clone
  • .env.example lists every required env var with comments
  • docker compose up starts all external dependencies (database, cache, etc.)
  • pnpm dev (or filtered equivalent) starts the app with hot reload
  • pnpm test passes on a fresh clone with no additional setup
  • Git hooks are configured (Lefthook or Husky): lint on pre-commit, commitlint on commit-msg
  • CI pipeline runs lint, type-check, test, and build on every PR
  • README has step-by-step setup instructions that a new developer can follow
  • .vscode/ and .cursor/rules/ are committed with shared settings
  • Frontend projects follow Feature Sliced Design layer structure
  • No barrel files (except FSD slice public API boundaries)
  • API types are auto-generated from backend OpenAPI spec
  • Monorepo uses consistent script names across all apps
  • .vscode/launch.json is committed with a backend debug/attach config
  • LOG_LEVEL is documented in .env.example and defaults sensibly (info in most environments, debug for local investigation)
  • A Bruno collection is committed and kept current with the API
  • The x-correlation-id header is propagated from the frontend API client so a request can be traced end to end

AI tips

  • Use AI IDE rules to enforce project conventions. Cursor supports .cursor/rules/ files that teach the AI your project's patterns: naming conventions, module structure, testing approach. This is more effective than hoping the AI guesses your conventions from context. The S&P template includes starter rules for NestJS and Next.js. AI-Assisted Engineering covers this in depth, including CLAUDE.md structure, custom skills, and workflow orchestration.
  • Generate boilerplate with AI, then review. New NestJS modules, CRUD endpoints, FSD feature slices, these follow predictable patterns. Let AI generate the scaffold, then review it against the project's actual conventions. Don't let AI output bypass your git hooks.
  • Automate env var documentation. When you add a new dependency that requires configuration, ask AI to update .env.example with the new variable, a placeholder value, and a comment explaining where to get the real value.
  • Troubleshoot setup issues faster. When onboarding hits an error, paste the full error output into AI. Setup errors are usually environment-specific and well-documented. AI is good at matching symptoms to solutions.
  • Generate Docker Compose services. Describe what your app needs (PostgreSQL 18 with PostGIS, Redis, local S3-compatible storage) and let AI generate the docker-compose.yml. Review ports, volumes, and version pins.
  • Migrate ESLint configs to Biome. Paste your existing .eslintrc and .prettierrc and ask AI to generate the equivalent biome.json. Biome's rule names differ from ESLint's, so a manual pass is still needed, but AI gets you 90% there.
  • Generate OpenAPI client types. If you're setting up the API type generation pipeline for the first time, describe your backend's OpenAPI setup and let AI generate the orval or openapi-typescript config.
  • Turn a stack trace into a hypothesis. Paste the full stack trace and the surrounding code. Ask AI to identify the likely first-error frame (the one in your own code) and propose a ranked list of hypotheses. Treat these as leads to verify, not answers: AI is good at pattern-matching a trace, bad at knowing your runtime state.
  • Rubber-duck a hypothesis before you touch code. State the bug as expected-versus-actual and ask AI to poke holes in your reasoning: what else could produce this symptom? This catches the tunnel-vision fix (the one that assumes the first guess is right) before you spend an hour on it.
  • Generate requests from the spec. Give AI the OpenAPI spec or the controller and ask it to produce a Bruno request or a cURL command for an endpoint, with realistic headers and body. Faster than hand-writing them, and a good way to seed a shared collection.
  • Generate the debug config. Describe the app (NestJS on which port, monorepo filter) and ask AI to write the .vscode/launch.json attach configuration. Verify the port matches your --inspect flag.

Resources

S&P internal:

Tooling:

Architecture:

NestJS:

Frontend: