Skip to main content

Adding a feature

This is the most practical question a new developer asks: where does my code go?


Decision tree

Follow this tree to find the right home for your code:

Is it a URL-routed page or a multi-tab screen?
├── Yes → views/<your-page>/
└── No → Is it a self-contained UI widget loaded async?
├── Yes → ui-features/<your-feature>/
└── No → Is it state management, API calls, or business logic (no UI)?
├── Yes → data/<your-domain>-data/
└── No → Is it app-wide infrastructure (i18n, analytics, gateway)?
├── Yes → platform-libs/<your-service>/
└── No → Is it a pure utility or reusable component with no app deps?
└── Yes → libs/<your-util>/

When in doubt, the answer is almost always ui-features/.

Before you start writing code, skim Future-proof guidelines for new features below. It covers the package shape, state / data / networking / styling / testing choices, and the variations your feature will eventually have to run in. Picking the right defaults up front is much cheaper than retrofitting later.


Future-proof guidelines for new features

These are the patterns we recommend for code you write today — choices that have held up well across the codebase and won't bite you (or the next person) in six months. They're recommendations, not a hard rulebook. If your case genuinely doesn't fit, that's a useful conversation to have with the relevant platform team in #help-client-platform.

Package shape & ownership

  • Always use the Nx generator to create a new package — npx nx generate @postman/app-generator:library …. Don't hand-roll directories. The generator wires up tsconfig, package.json, exports, ESLint, project tags, test config, and project.json the way the toolchain expects. See Nx libs & module boundaries — How to generate a new Nx library for the full walkthrough.
  • Decouple from the monolith whenever possible. Leaf packages (data/*, libs/*) should always be fully decoupled — no src/renderer/ imports. ui-features/* and views/* are harder; sometimes they genuinely have to stay coupled because they need a legacy MobX store, context, or service. If yours has to stay coupled, mark it monolith-coupled in project.json and fill in __DECOUPLE_FIRST__README.md with the specific reasons. See Decoupling strategies for the (non-exhaustive) set of patterns we've used to break the coupling — dependency injection, event bridges, registries, globals — so you can pick the cheapest one for your case.
  • No barrel files. Use package.json#exports to declare the package's public surface. Barrel files re-export everything into one graph node, which kills tree-shaking and inflates the bundles of every consumer.
  • No package-level eslint with root: true. Inherit from the repo-root config so rule sets stay consistent and CI doesn't surprise you.
  • Add ADR-*.md files under <your-lib>/docs/ for any non-obvious design decision. Numbered, append-only (ADR-001-…, ADR-002-…). It saves the next person — and any AI agent operating on the package — from having to reverse-engineer why a particular shape was chosen.

State, data, and networking

  • Zustand for state. Use it as the default for new client-side state. There are still places where you may need to bridge to MobX (most of the monolith is still on MobX); that's fine, just don't reach for MobX as the default for greenfield code.
  • React Query for networking. If all you're doing is storing API responses for the UI to read, React Query's cache is often enough on its own — you may not need a separate store at all.
  • Sockets / realtime is still a gap. There's no opinionated wrapper today. If you need realtime, please talk to the platform team first so we can build the right shared thing rather than each squad rolling its own.
  • Zod 4 / Zod-mini for runtime validation of anything crossing a trust boundary — API responses, IPC payloads, deeplinks, persisted state on disk.

UI & styling

  • CSS Modules for new styling. No more styled-components. CSS Modules play better with the build system, have lower runtime cost, and integrate naturally with the Aether design tokens.

Tests

  • Vitest for new unit tests. Jest is the legacy default; Vitest is faster, has cleaner ESM / TypeScript support, and is where new investment is going.

Plan for the variations your feature will live in

Every feature ends up running in more contexts than you expect. Design for these axes up front — the facade pattern helps a lot (one stable interface in your feature, multiple implementations behind it for each variation):

AxisVariations to plan for
Processbrowser, desktop
Editionorange (Postman), purple (Enterprise), future apps
Modecloud, local

If you don't plan for these from the start, retrofitting later usually means rewriting every call site.

Async entrypoints to avoid duplicate chunks

If two dynamically-loaded packages (say WorkbenchA and WorkbenchB) both depend on a third package (@postman-app/heavy-script-parser), webpack will bundle that third package twice — once into each workbench chunk. To prevent the duplication, make the heavy package's own entrypoint a dynamic import too:

// libs/heavy-script-parser/src/index.ts
export function getHeavyScriptParser() {
// Production code would add caching, in-flight-check, and retry —
// omitted here for brevity.
return import(/* webpackChunkName: "heavy-script-parser" */ './lazy-entrypoint');
}

That way the parser ends up in its own shared chunk that both workbenches load on demand. You can split further if it makes sense — e.g. a getHeavyScriptParserCloud() vs getHeavyScriptParserLocal() entrypoint to keep cloud-only and local-only dependencies out of each other's bundles.

Use workspaceLifecycle for workspace context

When your feature needs to react to workspaceId, workspaceMode, or activeDirectory changes, use the workspaceLifecycle helper (entry point: registerWorkspaceLifecycle). It's battle-tested across the codebase and handles edge cases (race conditions on workspace switch, mode flips mid-render, directory permission loss) you almost certainly won't think of when rolling your own.

Data package layout: store / controller / service

If you're building a data/* package, keep these three concerns cleanly separated. Mixing them is the most common reason a data package ends up hard to consume from the outside:

LayerWhat it doesWhat it can depend on
StoreHolds the data; exposes read-only selectors.Util helpers and other leaf stores. Nothing else. Keeping the store thin makes it easy for external consumers (sidebars, widgets, other features) to subscribe without dragging in the network layer.
ControllerOrchestrates: calls the service to fetch, hydrates the store, runs Create / Update / Delete operations.The store and the service.
ServiceTalks to the persistence layer — cloud endpoint or local filesystem. Uses React Query where possible.Whatever it needs to make the network or disk call. Should be private to the controller.

Reads always go directly through the store. CUD operations always go through the controller. External consumers should never import the service.

Norms (not rules)

  • It's OK to ask for review on a design doc, or for help during implementation. Earlier is cheaper than later.
  • Tell platform teams where the friction is. When something feels harder than it should, that's a prioritization signal — it helps us know what to fix next.

Creating a new ui-feature (most common case)

The walkthrough below uses MobX + a raw gatewayClient call because that matches the shape of most existing features in the codebase today. For new packages, prefer the guidelines above — Zustand for state and React Query for networking — unless you have a specific reason to match the legacy shape.

Let's say your ticket is: "Add a new Workspace Activity feed widget."

Step 1: Generate the package

npx nx generate @postman/app-generator:library \
--type=ui-feature \
--name=workspace-activity-feed \
--scope=collaboration

This creates:

ui-features/workspace-activity-feed/
├── src/
│ ├── components/
│ │ └── WorkspaceActivityFeed.tsx ← Main component
│ └── index.ts ← Public API (what others can import)
├── package.json
├── project.json ← Nx config + tags
└── tsconfig.json

Step 2: Build the component

// ui-features/workspace-activity-feed/src/components/WorkspaceActivityFeed.tsx
import React from 'react';
import { observer } from 'mobx-react-lite';
import { useWorkspaceActivityStore } from '@postman-app/workspace-activity-data';
import { Text, Spinner } from '@postman-app/aether-components';
import { useTranslation } from '@postman-app/i18n-sdk';

export const WorkspaceActivityFeed = observer(() => {
const { t } = useTranslation('workspace-activity');
const store = useWorkspaceActivityStore();

if (store.isLoading) return <Spinner />;

return (
<div>
<Text variant="heading">{t('activity.title')}</Text>
{store.activities.map(activity => (
<ActivityItem key={activity.id} activity={activity} />
))}
</div>
);
});

Step 3: Export from the package

// ui-features/workspace-activity-feed/src/index.ts
export { WorkspaceActivityFeed } from './components/WorkspaceActivityFeed';

Step 4: If you need data, create a data package

npx nx generate @postman/app-generator:library \
--type=data \
--name=workspace-activity-data \
--scope=collaboration
// data/workspace-activity-data/src/store.ts
import { makeAutoObservable } from 'mobx';
import { gatewayClient } from '@postman-app/gateway-service';

export class WorkspaceActivityStore {
activities = [];
isLoading = false;

constructor() {
makeAutoObservable(this);
}

async fetch(workspaceId: string) {
this.isLoading = true;
this.activities = await gatewayClient.get(`/workspaces/${workspaceId}/activity`);
this.isLoading = false;
}
}

Step 5: Load the feature from a view

// views/workspace-overview/src/components/WorkspaceOverviewContainer.tsx
import React, { lazy, Suspense } from 'react';

// Lazy load the ui-feature — it is NOT in the initial bundle
const WorkspaceActivityFeed = lazy(() =>
import('@postman-app/workspace-activity-feed').then(m => ({ default: m.WorkspaceActivityFeed }))
);

export function WorkspaceOverviewContainer() {
return (
<Suspense fallback={<div>Loading...</div>}>
<WorkspaceActivityFeed />
</Suspense>
);
}

Adding a new page (view)

If your feature is a brand new URL-addressable page:

npx nx generate @postman/app-generator:library \
--type=view \
--name=workspace-activity-page

Then register it in the router (in src/renderer/onboarding/src/features/ or the manifest system — check with your team for the current pattern).


Adding a translation string

All user-facing text must be translated. Never hardcode English strings.

  1. Add the key to locales/en-US/<namespace>.json:

    {
    "activity": {
    "title": "Recent activity",
    "empty": "No activity yet"
    }
    }
  2. Use it in the component:

    import { useTranslation } from '@postman-app/i18n-sdk';
    const { t } = useTranslation('workspace-activity');
    t('activity.title') // → "Recent activity"

Adding analytics

Use the analytics service to track user interactions:

import { analyticsService } from '@postman-app/analytics-service';

// Track an event when the user performs an action
analyticsService.track('activity_feed_viewed', {
workspaceId,
activityCount: store.activities.length,
});

Event names and schemas should be documented in the event registry (ask your squad's analytics lead).


Checklist before opening a PR

□ Code is in an Nx package — not in src/renderer/
□ Package generated with the Nx generator (not created manually)
□ Package's public surface is declared via package.json#exports (no barrel files)
□ Package is decoupled from the monolith, OR is tagged monolith-coupled
in project.json with reasons recorded in __DECOUPLE_FIRST__README.md
□ Any non-obvious design decision is captured in <your-lib>/docs/ADR-*.md
□ State uses Zustand (or a documented reason to bridge to MobX)
□ Networking uses React Query (where applicable)
□ Boundary inputs validated with Zod 4 / Zod-mini
□ Styling uses CSS Modules (no new styled-components)
□ Unit tests written in Vitest, co-located with the component
□ Feature works across the expected variations: browser/desktop,
orange/purple, cloud/local
□ Imports use @postman-app/* aliases (no cross-package relative imports)
□ All user-facing strings use t() from @postman-app/i18n-sdk
□ nx lint <package> passes
□ nx test <package> passes
□ yarn typecheck passes
□ yarn validate-modules passes
□ No new circular dependencies (yarn knip)