1. 09Feature modules are units of change
  2. 10The parts of a feature
  3. 11Making a feature ready to build
  4. 12Building a feature from idea to implementation
  5. 13Testing a feature without the host
  6. 14The application is the composer

Building product features

12

Building a feature from idea to implementation

A feature implementation puts the plan into a module, demo, and production host without collapsing their boundaries.

By Eyal Ellenbogen August 27, 2026 4 min read

When we begin implementing a feature, the default impulse is to connect the live endpoint as quickly as possible to see real data on screen. That early spark of progress feels productive, but it immediately locks the user interface to whatever state happens to exist on a shared server. The moment we want to design an empty state, test a network retry, or inspect a loading skeleton, development grinds to a halt while hunting for valid test accounts.

A predictable implementation workflow runs in the opposite sequence. We build the feature inside an isolated feature module, prove every interaction in a self-contained demo harness, and connect the live production backend only after interface behavior is solid.

Build against the port

The port drafted during refinement represents the feature boundary. It defines the operations the user interface requires without leaking HTTP headers, endpoints, or serialization formats.

export interface MemberInvitePort {
  load(): Promise<InviteBatch>;
  send(invite: PendingInvite): Promise<void>;
}

Inside the module, we keep the internal parts focused. Visual components render prepared view models, while a state facade or producer coordinates actions against the port. Component styling, focus rings, and layout primitives come directly from the design system, leaving the feature codebase responsible solely for its own product decisions.

Starting with the contract lets us write the first functional slice immediately, rendering real components against a simulated contract before a single backend route has deployed.

Replay states in the demo

A demo harness is the environment where a feature runs while the backend is moving, incomplete, or broken.

Every scenario on the state map deserves an explicit fixture: initial loading, empty results, a pending Save workflow, inline validation failures, and server errors. The mock adapter returns those fixtures intentionally:

export const mockInvitePort: MemberInvitePort = {
  load: async () => ({ pendingInvites: [] }),
  send: async () => {
    await new Promise((resolve) => setTimeout(resolve, 800));
    throw new Error('Quota exceeded');
  },
};

Delaying a response lets us tune loading skeletons without artificial network throttling, and throwing an error proves that the recovery banner actually renders. Product managers and designers can test edge cases in an isolated workspace instead of hunting down staging accounts with valid credentials.

If a scenario cannot be reproduced without booting the full application shell and authenticating against a live server, the module boundary is leaking. Something in the view is reaching past the port.

The wiring happens outside

Once user flows hold in the demo, we write the production adapter that maps live responses to the port. This adapter lives at the boundary, often beside the feature in the codebase, translating raw API structures into the domain types the feature expects.

The application host instantiates the adapter, supplies it to the feature, and mounts the feature on a route. Global routing, authentication tokens, and environment variables stay in that outer shell.

This is the point where teams are tempted to bypass the adapter and import the API client straight into the page component to save five minutes. That shortcut dissolves the contract. The moment the backend team renames a field or migrates from REST to GraphQL, that schema change breaks the view.

Verify boundaries independently

Verification should test each layer for the specific promise it makes:

  • Feature behavior is validated against the demo fixture to confirm that user interactions trigger expected state transitions, that form submissions display inline errors, and that empty states provide clear calls to action.
  • The production adapter is validated separately to prove that real network payloads map correctly into domain models and that outbound user commands produce valid requests.

Testing a feature without the host keeps these validation surfaces separate, avoiding brittle end-to-end tests where a failure could mean a broken UI, a changed payload, or an unavailable staging server.

We can enforce these boundaries with dependency lint rules. If a component in ui can import an API client or an external store directly, the architecture exists only as a suggestion.

Expect implementation to refine the contract

A state map is a design tool, not a frozen contract. Building the real interface inevitably exposes an unmapped edge case, an overlooked loading state when a Save operation fails, or an unexpected data dependency.

We should treat every discovery as an architectural choice. An unhandled empty state belongs on the state map and in the demo fixtures. A reusable keyboard interaction belongs in shared UI infrastructure. A missing backend attribute belongs on the port interface first, then in the mock fixture, and finally in the live adapter.

Updating the port first ensures that new capabilities remain visible to demo harnesses, unit tests, and design reviews. When all mapped scenarios run smoothly in isolation and the production host fulfills the same contract, the feature is ready to ship.