Back to Blog
react-nativearchitecturemobiletypescript

React Native Feature-First Architecture: Boundaries, Public APIs, and Shared Code

Learn how to enforce feature boundaries in React Native using public APIs, shared modules, and lint rules to keep a feature-first architecture scalable.

5 min read

React Native Feature-First Architecture: Boundaries, Public APIs, and Shared Code

A feature-first structure is the easiest way I've found to keep React Native projects scalable. But the real win doesn't come from the folders themselves.

It comes from enforcing boundaries.

In my previous post, I covered the basic idea of organizing code by feature, not by file type. This one is about what usually breaks after the honeymoon phase: features importing each other, shared becoming a junk drawer, and "quick fixes" turning into permanent architecture.

The problem: feature-first without boundaries becomes "feature-ish"

Feature-first is simple:

  • features/ contains product domains (auth, profile, feed, payments, etc.)
  • shared/ contains reusable pieces
  • each feature owns its screens, hooks, services, components

But as the app grows, this happens:

  • Feature A imports Feature B "just this once"
  • The same domain type gets duplicated in three places
  • Shared becomes a random collection of everything
  • Refactors start feeling like defusing a bomb in a washing machine

You can avoid most of this with one rule:

A feature must expose a public API, and other features can only interact through that API (or through shared).

The model: features as modules, not folders

Think of each feature as a small module with:

  • private internals (anything inside the feature folder)
  • a single entry point (an index.ts)
  • clear rules about what can be imported from outside

Example:

src/
  features/
    auth/
      components/
      hooks/
      screens/
      services/
      types.ts
      index.ts

The public API pattern

features/auth/index.ts is the only thing the outside world should import.

// features/auth/index.ts
export * from "./hooks/useLogin";
export * from "./services/authService";
export * from "./types";

Inside the feature, you can import anything. Outside the feature, you only import from:

  • features/auth
  • or shared/*

This creates a clean "surface area" per feature.

The golden rule: no cross-feature deep imports

Bad:

// ❌ feature A importing deep internals of feature B
import { authService } from "../auth/services/authService";

Good:

// ✅ feature A importing feature B public API
import { authService } from "../auth";

Even better, if the dependency is truly shared across domains:

// ✅ moved to shared because multiple features need it
import { httpClient } from "../../shared/http";

What should go in shared (and what shouldn't)

Shared should be boring. Predictable. Almost bureaucratic.

Good candidates:

  • UI primitives: buttons, inputs, typography, spacing helpers
  • cross-feature utilities: date formatting, analytics wrappers, storage helpers
  • infra modules: http client, logger, feature flags
  • types that are truly cross-domain: ApiError, Paginated<T>

Bad candidates:

  • feature-specific business logic that you "might reuse later"
  • a component that depends on auth state but you put in shared "because it's used in two places"
  • random helpers you can't name properly (this is how shared/utils2 is born)

A practical heuristic:

If it has product meaning (auth, checkout, profile), it's probably not shared.

Enforcing boundaries with lint rules

Architecture rules that live in Notion get ignored. Architecture rules that live in ESLint get enforced.

Here's a simple option: forbid deep imports across features/*.

// .eslintrc.js (example)
module.exports = {
  rules: {
    "no-restricted-imports": [
      "error",
      {
        patterns: [
          {
            group: ["src/features/*/*"],
            message:
              "Do not deep import from other features. Import from the feature public API (index.ts) instead.",
          },
        ],
      },
    ],
  },
};

This is intentionally strict. It forces you to design public APIs and keep boundaries clean.

The "shared is getting big" problem

If shared/ starts growing too much, don't panic. Just split it by intent.

Example:

shared/
  ui/
  lib/
  config/
  hooks/
  types/

Now "shared" isn't a junk drawer, it's a small marketplace:

  • ui/ sells components
  • lib/ sells utilities
  • config/ sells constants and env wiring

Cross-feature collaboration without spaghetti

Sometimes Feature A needs something from Feature B. That's normal. The goal is to avoid tight coupling.

Three patterns that work well:

  1. Public API calls
  • Feature A imports a function from Feature B's index.ts
  1. Shared domain or contract
  • Move shared types/contracts to shared/types or a dedicated shared module
  1. Event-style communication
  • Feature A emits an event (analytics, navigation intent), feature B reacts
  • This is great for "side-effects" and keeps business logic from leaking everywhere

A quick checklist before you add a new file

When adding something new, ask:

  • Is this clearly owned by one feature?

    • Put it in that feature.
  • Will multiple features truly need it?

    • Put it in shared/, but in the right bucket (ui, lib, types).
  • Am I importing deep internals of another feature?

    • Stop. Add/extend the other feature's public API instead.

Conclusion

Feature-first architecture scales because it optimizes for cohesion. It stays scalable because you enforce boundaries.

Folders give you the map. Boundaries keep the city from turning into chaos.

If you want, next I can write the natural Part 3: "Feature-first + state management: where state lives, and how to avoid global soup."