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.
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/utils2is 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 componentslib/sells utilitiesconfig/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:
- Public API calls
- Feature A imports a function from Feature B's
index.ts
- Shared domain or contract
- Move shared types/contracts to
shared/typesor a dedicated shared module
- 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).
- Put it in
-
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."