fic rendering requires strict boundary enforcement. The following implementation demonstrates a production-ready pattern using TypeScript for shared domain logic, platform adapters for native API access, and contract-first bridges to prevent abstraction leaks.
Step 1: Isolate Domain Logic from Presentation
Create a pure TypeScript package that contains data models, repositories, state management, and business rules. This package must have zero dependencies on UI frameworks, native modules, or platform-specific APIs.
// packages/shared-core/src/models/User.ts
export interface User {
id: string;
displayName: string;
lastActive: Date;
preferences: UserPreferences;
}
export interface UserPreferences {
theme: 'system' | 'light' | 'dark';
notifications: boolean;
offlineCacheTTL: number;
}
Establish explicit interfaces for platform-specific capabilities. These contracts enforce what the shared layer can request and what the native layer must provide.
// packages/shared-core/src/contracts/PlatformBridge.ts
export interface PlatformBridge {
getDeviceId(): Promise<string>;
requestBiometricAuth(): Promise<boolean>;
getNetworkStatus(): Promise<'wifi' | 'cellular' | 'offline'>;
openDeepLink(url: string): void;
}
Build thin wrappers that satisfy the contracts using native APIs. These adapters live in the UI layer but are consumed by the shared core.
// apps/mobile-native/src/adapters/ReactNativeBridge.ts
import { NativeModules, Platform } from 'react-native';
import { PlatformBridge } from '@shared-core/contracts';
const { DeviceInfoModule, AuthModule, NetworkModule } = NativeModules;
export class ReactNativeBridge implements PlatformBridge {
async getDeviceId(): Promise<string> {
return DeviceInfoModule.getUniqueId();
}
async requestBiometricAuth(): Promise<boolean> {
return AuthModule.authenticate();
}
async getNetworkStatus(): Promise<'wifi' | 'cellular' | 'offline'> {
const info = await NetworkModule.getConnectionInfo();
if (info.type === 'none') return 'offline';
return info.type === 'wifi' ? 'wifi' : 'cellular';
}
openDeepLink(url: string): void {
Platform.OS === 'ios'
? NativeModules.LinkingManager.openURL(url)
: NativeModules.IntentModule.openURL(url);
}
}
Inject the platform contract into shared repositories. The core never knows which platform is running; it only knows the contract is satisfied.
// packages/shared-core/src/repositories/UserRepository.ts
import { PlatformBridge } from '../contracts';
import { User } from '../models';
export class UserRepository {
constructor(private readonly bridge: PlatformBridge) {}
async syncUserProfile(): Promise<User> {
const deviceId = await this.bridge.getDeviceId();
const network = await this.bridge.getNetworkStatus();
const cached = await this.loadFromCache(deviceId);
if (cached && network !== 'offline') {
const remote = await this.fetchFromAPI(deviceId);
await this.persistToCache(deviceId, remote);
return remote;
}
return cached ?? this.createDefaultUser(deviceId);
}
private async loadFromCache(id: string): Promise<User | null> {
// Platform-agnostic cache logic (e.g., AsyncStorage wrapper)
return null;
}
private async fetchFromAPI(id: string): Promise<User> {
// Pure HTTP/GraphQL call
return {} as User;
}
private async persistToCache(id: string, user: User): Promise<void> {
// Platform-agnostic persistence
}
private createDefaultUser(id: string): User {
return { id, displayName: 'Guest', lastActive: new Date(), preferences: { theme: 'system', notifications: false, offlineCacheTTL: 3600 } };
}
}
Architecture Decisions and Rationale
- Why TypeScript for shared logic? Strict typing prevents contract drift, enables IDE autocompletion across teams, and compiles to zero-cost JavaScript. It avoids the runtime overhead of Kotlin Multiplatform or Dart while maintaining compile-time safety.
- Why contract-first bridges? Implicit platform calls create silent failures during upgrades. Explicit interfaces force native teams to implement required methods, and CI can validate contract compliance.
- Why not full cross-platform UI? UI is inherently platform-specific. Safe areas, gesture navigators, haptic feedback, and accessibility trees differ fundamentally. Abstracting them introduces reconciliation overhead and breaks platform UX guidelines.
- Why monorepo structure? Shared contracts and models require version synchronization. A monorepo eliminates npm publish cycles, enables atomic refactors, and allows platform teams to consume shared packages directly during development.
Pitfall Guide
1. Assuming 100% Code Reuse is Achievable
Reality: Business logic reuse caps at 60-70%. UI reuse rarely exceeds 30% due to platform design systems. Attempting to force identical components creates fragile abstractions that break on OS updates. Best practice: Share data models, validation rules, and network layers. Render platform-specific UI with shared state.
2. Building Custom Bridges Without Version Contracts
Unversioned native modules cause silent failures when OS APIs change. A bridge that works on iOS 16 may crash on iOS 17 without compile-time warnings. Best practice: Define semantic versioning for bridge contracts. Use CI to validate that native implementations satisfy the TypeScript interfaces before merging.
Cross-platform teams frequently ship iOS apps with Android navigation patterns or vice versa. This increases cognitive load, reduces accessibility compliance, and triggers App Store rejections. Best practice: Adopt platform design tokens early. Map shared state to native navigation stacks, gesture handlers, and safe area insets.
Abstraction layers introduce measurable overhead. React Native's bridge serialization, Flutter's Skia compilation, and JS thread blocking all compound under heavy state updates. Best practice: Establish performance budgets in CI (e.g., <16ms frame rendering, <2s cold start). Profile early with native tools, not framework debuggers.
5. Neglecting App Bundle Size and Cold Start Metrics
Cross-platform apps bundle runtime engines, polyfills, and bridge modules. This inflates initial download size and delays first paint. Native apps ship only what's linked. Best practice: Tree-shake shared packages. Lazy-load platform modules. Measure bundle size deltas in every PR.
Polling or manual synchronization between shared logic and native UI creates race conditions and memory leaks. Best practice: Use event-driven architecture. Shared core emits state changes; platform UI subscribes via reactive streams (RxJS, Zustand, or native observables). Avoid双向绑定 across the bridge.
7. Skipping Native Profiling Early
Framework debuggers mask platform-specific bottlenecks. A smooth list in React Native may hide JS thread blocking that crashes on low-end Android devices. Best practice: Integrate Xcode Instruments and Android Profiler into the development workflow. Profile memory allocation, GPU rendering, and main thread utilization weekly.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| MVP with simple CRUD, tight deadline | Cross-Platform UI | Fastest path to market; low interaction complexity | Low initial, medium long-term bridge debt |
| Hardware-intensive (AR, Bluetooth, biometrics) | Pure Native | Direct API access, predictable latency, compliance | High initial, low maintenance |
| Enterprise app with complex state, multi-platform | Shared Logic + Native UI | Unified business rules, platform-optimized rendering | Medium initial, low long-term |
| Startup validating product-market fit | Cross-Platform UI + Shared Core | Rapid iteration, easy pivot to native if needed | Low initial, scalable |
| Regulated industry (finance, healthcare) | Pure Native or Shared Logic + Native UI | Audit trails, platform security, OS-level encryption | High initial, compliance-driven |
Configuration Template
// packages/shared-core/tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
// apps/mobile-rn/package.json
{
"scripts": {
"build:shared": "cd ../../packages/shared-core && npm run build",
"start": "react-native start",
"android": "react-native run-android",
"ios": "react-native run-ios",
"lint": "eslint . --ext .ts,.tsx",
"test": "jest"
},
"dependencies": {
"@shared-core": "file:../../packages/shared-core/dist"
}
}
# .github/workflows/bridge-validation.yml
name: Validate Platform Contracts
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: cd packages/shared-core && npm run build
- run: cd apps/mobile-rn && npx tsc --noEmit
- name: Check contract compliance
run: npx ts-node scripts/validate-bridges.ts
Quick Start Guide
- Initialize monorepo:
npx create-turbo@latest mobile-platform --package-manager npm
- Add shared core:
cd packages && npm init @shared-core && npm i typescript --save-dev
- Configure TypeScript: Copy the
tsconfig.json template, run npx tsc --init, and set "strict": true
- Spin up React Native app:
cd apps && npx react-native init mobile-rn --typescript
- Link shared package: Add
"@shared-core": "file:../../packages/shared-core/dist" to apps/mobile-rn/package.json, run npm install, then npm run ios or npm run android
You now have a contract-first architecture where shared logic compiles independently, platform adapters satisfy explicit interfaces, and CI validates bridge compliance before deployment.