Building product features
The parts of a feature
A feature module groups UI, frontend-facing contracts, and a runnable demo while keeping production integrations outside.
A feature module succeeds when its internal anatomy is predictable. When every product workflow invents its own file structure, navigating our codebase requires continuous re-orientation.
A clean feature boundary divides naturally into three co-located responsibilities, while production integrations stay outside the module.
The three parts
Inside the feature directory, files group by responsibility rather than technical file type:
ui: The feature's views, layouts, and presentational components. It renders prepared display models and emits user intent, while state coordination drives the port outside markup.api: The contract describing what the feature needs from the outside world. It contains the feature-owned port interface and the data shapes crossing that boundary.demo: A standalone harness running the feature with controlled mock data. It lets teams test and review UI states without touching live backend infrastructure.
Feature module
ui
Views & layouts
api
Port contract
demo
Mock harness
Adapter (outside the module)
Implements the port against real services
The golden rule is dependency direction: feature code never imports backend clients, HTTP libraries, or production adapters. The feature states what it needs; outside code satisfies that contract.
Keep views focused on presentation
Even with a dedicated port contract, components can collapse if templates take on workflow coordination. If a view calls the port directly in an async effect, tracks retry counters, and evaluates permissions like user.role === 'admin' inline, presentation markup becomes coupled to business rules.
We keep that division clean inside ui. Visual components should receive finished models—ready-to-render strings, arrays, and boolean flags like canManage: boolean. State coordination (whether written as a custom hook, a producer, or a state machine) manages pending requests and drives the port, while the view simply renders the outcome and reports user interactions. This keeps visual adjustments safe from state regressions and allows the view to mount in a test harness with plain mock data.
Ports describe what the UI needs
A frontend view should not bind directly to backend response payloads. This is the essence of Ports and Adapters (Hexagonal Architecture) applied to the frontend: the feature defines an explicit contract at its edge, expressed in UI-facing terms:
export interface EditMemberPort {
loadMember(): Promise<Member>;
saveMember(change: MemberChange): Promise<void>;
}
This contract reflects what the screen needs to render, not how the database stores it. The UI never parses query parameters, handles raw HTTP status codes, or imports entity schemas from an external API package.
Adapters isolate production realities
Production applications must eventually talk to real services. That translation belongs in an integration adapter located outside the feature module:
export function createBackendEditMemberAdapter(
client: MembersApiClient,
memberId: string,
): EditMemberPort {
return {
async loadMember(): Promise<Member> {
const response = await client.getMember(memberId);
return toMember(response);
},
async saveMember(change: MemberChange): Promise<void> {
await client.updateMember(memberId, toUpdateRequest(change));
},
};
}
The adapter absorbs backend volatility. If an endpoint URL changes, a field renames, or an API migrates from REST to GraphQL, only the adapter changes. The feature's UI and internal contracts remain untouched.
The demo proves the boundary
The demo directory is the practical test of the feature boundary. It pairs the feature UI with an in-memory mock implementation of the port:
export const mockEditMemberPort: EditMemberPort = {
async loadMember() {
return { name: 'Alice', role: 'Admin' };
},
async saveMember() {},
};
If we cannot run the feature in isolation without logging into a VPN, seeding a database, or starting five local microservices, the boundary has failed.
Module boundary
- Inside feature
- Presentational views, port contracts, mock demo harness
- Outside feature
- Production adapters, network clients, host routing
When ui, api, and demo stay together and production plumbing stays outside, a feature becomes a true unit of change: fast to develop, easy to test, and resilient to backend shifts.