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 uptsconfig,package.json, exports, ESLint, project tags, test config, andproject.jsonthe 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 — nosrc/renderer/imports.ui-features/*andviews/*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 itmonolith-coupledinproject.jsonand fill in__DECOUPLE_FIRST__README.mdwith 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#exportsto 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
eslintwithroot: true. Inherit from the repo-root config so rule sets stay consistent and CI doesn't surprise you. - Add
ADR-*.mdfiles 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 unit tests. Every unit suite runs on Vitest; Jest is gone.
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):
| Axis | Variations to plan for |
|---|---|
| Process | browser, desktop |
| Edition | orange (Postman), purple (Enterprise), future apps |
| Mode | cloud, 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:
| Layer | What it does | What it can depend on |
|---|---|---|
| Store | Holds 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. |
| Controller | Orchestrates: calls the service to fetch, hydrates the store, runs Create / Update / Delete operations. | The store and the service. |
| Service | Talks 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
gatewayClientcall 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.
-
Add the key to
locales/en-US/<namespace>.json:{"activity": {"title": "Recent activity","empty": "No activity yet"}} -
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