@natify/core
v1.0.2
Published
Core of the Natify framework. Provides hexagonal architecture, module system, dependency injection, and tools for building decoupled React Native applications.
Readme
@natify/core
Core of the Natify framework. Provides hexagonal architecture, module system, dependency injection, and tools for building decoupled React Native applications.
Installation
pnpm add @natify/coreFeatures
- Hexagonal Architecture - Ports & Adapters pattern
- Module System - Organize your app into independent mini-apps
- Dependency Injection - DI Container with singletons and factories
- ActionBus - Inter-module communication (MediatR style)
- Generic Types - Automatic type inference for adapters
- Hot Reload - Dynamic module loading/unloading
- Included Adapters - Logger and Analytics by default
Layer Architecture (Recommended)
Natify recommends following Clean Architecture with layer separation, but it's flexible and you can use it without ViewModels if you prefer a simpler approach.
Recommended Architecture
┌─────────────────────────────────────┐
│ UI Layer (Views) │ ← Pure React Native components
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ ViewModels (UI State) │ ← Handle loading, errors, state
│ [OPTIONAL] │ ← You can skip this layer
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ UseCases (Business Logic) │ ← Pure, testable use cases
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ Ports (Interfaces) │ ← Contracts without implementation
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ Adapters (Implementations) │ ← Concrete native libraries
└─────────────────────────────────────┘Principles:
- Dependencies point toward the domain - Outer layers depend on inner layers
- Clean UI - Components only render, no business logic
- Isolated UseCases - Business logic independent of frameworks
- Agnostic interfaces - Ports don't know about implementations
Using with ViewModels (Recommended)
// ViewModel handles state and coordinates with UseCase
export function useLoginViewModel() {
const [state, { execute }] = useBaseViewModel();
const loginUseCase = useUseCase<LoginUseCase>("auth:login");
const login = async (email: string, password: string) => {
const result = await execute(() =>
loginUseCase.execute({ email, password })
);
return result;
};
return { state, actions: { login } };
}
// Clean component, only renders
function LoginScreen() {
const { state, actions } = useLoginViewModel();
return <Button onPress={() => actions.login(email, password)} />;
}Using without ViewModels (Also valid)
You can use Natify directly in components if you prefer a simpler approach:
// Component that uses UseCase directly
function LoginScreen() {
const [isLoading, setIsLoading] = useState(false);
const loginUseCase = useUseCase<LoginUseCase>("auth:login");
const handleLogin = async () => {
setIsLoading(true);
try {
await loginUseCase.execute({ email, password });
} catch (error) {
// Handle error
} finally {
setIsLoading(false);
}
};
return <Button onPress={handleLogin} disabled={isLoading} />;
}Note: ViewModels are optional but recommended for:
- Complex applications with multiple states
- Teams seeking clear separation of responsibilities
- Projects requiring exhaustive UI logic testing
For quick prototypes or simple apps, you can use UseCases directly in components.
Quick Guide
1. Configure Adapters
import { NatifyApp, ConsoleLoggerAdapter } from "@natify/core";
import { AxiosHttpAdapter } from "@natify/http-axios";
import { MMKVStorageAdapter } from "@natify/storage-mmkv";
import { createReactNavigationAdapter } from "@natify/navigation-react";
const adapters = {
http: new AxiosHttpAdapter("https://api.example.com"),
storage: new MMKVStorageAdapter(),
navigation: createReactNavigationAdapter(),
// Logger is optional, ConsoleLoggerAdapter is used by default if not provided
logger: new ConsoleLoggerAdapter(),
};2. Create Modules
import { createModule } from "@natify/core";
export const AuthModule = createModule("auth", "Authentication")
.requires("http", "storage", "navigation")
.screen({ name: "Login", component: LoginScreen })
.useCase("login", (adapters) => new LoginUseCase(adapters.http, adapters.storage))
.initialRoute("Login")
.build();3. Configure App
import { NatifyApp } from "@natify/core";
export default function App() {
return (
<NatifyApp
adapters={adapters}
modules={[AuthModule, ProductsModule]}
initialModule="auth"
/>
);
}Adapters Included in Core
ConsoleLoggerAdapter
Logging adapter that uses console to write logs. Used automatically if you don't provide a logger in the configuration.
import { ConsoleLoggerAdapter } from "@natify/core";
const logger = new ConsoleLoggerAdapter();
// Use directly
logger.info("User authenticated", { userId: "123" });
logger.error("Error loading data", error);
// Or inject it in adapters
const adapters = {
logger: new ConsoleLoggerAdapter(),
// ... other adapters
};Available log levels:
logger.debug(message, metadata?)logger.info(message, metadata?)logger.warn(message, metadata?)logger.error(message, error?, metadata?)
CompositeAnalyticsAdapter
Analytics adapter that allows combining multiple analytics providers into one. Useful when you need to send events to multiple services simultaneously.
import { CompositeAnalyticsAdapter } from "@natify/core";
import { FirebaseAnalyticsAdapter } from "@natify/analytics-firebase";
import { MixpanelAnalyticsAdapter } from "@natify/analytics-mixpanel";
// Create individual adapters
const firebase = new FirebaseAnalyticsAdapter();
const mixpanel = new MixpanelAnalyticsAdapter();
// Combine them into a single adapter
const analytics = new CompositeAnalyticsAdapter([firebase, mixpanel]);
// Initialize all
await analytics.init();
// Events are sent to all adapters
analytics.track("user_login", { method: "email" });
// ↑ Sent to both Firebase and MixpanelAvailable methods:
analytics.init()- Initializes all adaptersanalytics.identify(userId, traits?)- Identifies a useranalytics.track(event, properties?)- Records an eventanalytics.screen(name, properties?)- Records a screenanalytics.reset()- Resets all adapters
useAdapter vs useUseCase
useAdapter<T>(name) - Direct adapter access
Use useAdapter when you need direct access to a framework adapter, typically for:
- Navigation (
navigation) - Logging (
logger) - Simple operations without business logic
import { useAdapter, NavigationPort, LoggerPort } from "@natify/core";
function MyComponent() {
const navigation = useAdapter<NavigationPort>("navigation");
const logger = useAdapter<LoggerPort>("logger");
const handlePress = () => {
logger.info("Navigating to product detail");
navigation.navigate("products/ProductDetail", { id: "123" });
};
}useUseCase<T>(key) - Encapsulated business logic
Use useUseCase when the operation involves business logic, such as:
- Login/Logout
- CRUD operations
- Complex validations
import { useUseCase } from "@natify/core";
import { LoginUseCase } from "../usecases/LoginUseCase";
function LoginScreen() {
const loginUseCase = useUseCase<LoginUseCase>("auth:login");
const handleLogin = async (email: string, password: string) => {
await loginUseCase.execute({ email, password });
};
}General Rule
| Situation | Hook to use |
|-----------|-------------|
| Simple navigation | useAdapter<NavigationPort> |
| Logging | useAdapter<LoggerPort> |
| Business logic | useUseCase<MyUseCase> |
| Trivial operations (save setting) | useAdapter is fine |
ActionBus - Inter-Module Communication
The ActionBus allows modules to communicate without directly coupling.
Register Handler (in the module that provides the action)
import { actionBus } from "@natify/core";
// In AuthModule
export const AuthModule = createModule("auth", "Authentication")
.onInit(async (adapters) => {
actionBus.register("auth:logout", async () => {
await logoutUseCase.execute();
});
})
.build();Dispatch Action (from any module)
import { useActionDispatch } from "@natify/core";
function ProfileScreen() {
const dispatch = useActionDispatch();
const handleLogout = async () => {
await dispatch({ type: "auth:logout" });
};
}Module Hot Reload
import { useDynamicModules } from "@natify/core";
import { PremiumModule } from "./modules/premium";
function SettingsScreen() {
const { loadModule, unloadModule, isModuleLoaded } = useDynamicModules();
const togglePremium = async () => {
if (isModuleLoaded("premium")) {
await unloadModule("premium");
} else {
await loadModule(PremiumModule);
}
};
}Navigation Hooks
import { useNavigationParams, useCurrentRoute, useAdapter, NavigationPort } from "@natify/core";
function ProductDetail() {
// Get typed parameters
const { productId } = useNavigationParams<{ productId: string }>();
// Navigation using the adapter directly
const navigation = useAdapter<NavigationPort>("navigation");
// Current route
const currentRoute = useCurrentRoute();
const handleGoBack = () => {
navigation.goBack();
};
const handleNavigate = () => {
navigation.navigate("products/ProductList");
};
}BaseViewModel
Base hook for ViewModels with loading and error handling:
import { useBaseViewModel, useUseCase } from "@natify/core";
export function useLoginViewModel() {
const [state, { execute, clearError }] = useBaseViewModel();
const loginUseCase = useUseCase<LoginUseCase>("auth:login");
const login = async (email: string, password: string) => {
const result = await execute(() =>
loginUseCase.execute({ email, password })
);
if (result) {
// Success
}
};
return {
state, // { isLoading, error }
actions: { login, clearError },
};
}BaseViewModel state:
state.isLoading- Indicates if there's an operation in progressstate.error- Error from the last operation (if any)execute(fn)- Executes an async function and handles loading/error automaticallyclearError()- Clears the current error
Available Ports
The core defines the following interfaces (Ports) that must be implemented by adapters:
HttpClientPort- HTTP client for REST requestsStoragePort- Local storage (key-value)NavigationPort- Navigation between screensBiometricPort- Biometric authenticationPermissionPort- Device permission managementImagePickerPort- Image selection from gallery or cameraLoggerPort- Logging systemAnalyticsPort- Event tracking and analyticsStateManagerPort- Global state managementGraphQLPort- GraphQL client (optional)
Each Port defines a contract that adapters must fulfill, allowing swapping implementations without changing business code.
Error System
Natify provides a typed and consistent error system:
import { NatifyError, NatifyErrorCode } from "@natify/core";
// Create a typed error
throw new NatifyError(
NatifyErrorCode.NETWORK_ERROR,
"Could not connect to server",
originalError,
{ url: "/api/users", retries: 3 }
);
// Handle errors
try {
await http.get("/users");
} catch (error) {
if (error instanceof NatifyError) {
switch (error.code) {
case NatifyErrorCode.UNAUTHORIZED:
// Redirect to login
break;
case NatifyErrorCode.NETWORK_ERROR:
// Show no connection message
break;
}
}
}Available error codes:
NETWORK_ERROR- Generic network errorTIMEOUT- Request timeoutUNAUTHORIZED- HTTP 401FORBIDDEN- HTTP 403NOT_FOUND- HTTP 404SERVER_ERROR- HTTP 500+STORAGE_READ_ERROR- Error reading storageSTORAGE_WRITE_ERROR- Error writing storageVALIDATION_ERROR- Validation errorUNKNOWN- Unknown error
Main Exports
// Ports (Interfaces)
export {
HttpClientPort,
StoragePort,
NavigationPort,
BiometricPort,
PermissionPort,
ImagePickerPort,
LoggerPort,
AnalyticsPort,
StateManagerPort,
GraphQLPort,
Port
}
// Context
export { NatifyProvider, useAdapter }
// Dependency Injection
export { DIContainer, container, useUseCase }
// Module System
export { createModule, useModules, useDynamicModules }
// ViewModel
export { useBaseViewModel }
// Navigation Hooks
export { useNavigationParams, useCurrentRoute }
// ActionBus
export { actionBus, useActionDispatch, useActionHandler }
// App
export { NatifyApp, DefaultSplash }
// Errors
export { NatifyError, NatifyErrorCode }
// Adapters (included in core)
export { ConsoleLoggerAdapter, CompositeAnalyticsAdapter }Testing
Natify facilitates testing through dependency injection:
// Mock an adapter
const mockStorage = {
getItem: jest.fn().mockResolvedValue("token"),
setItem: jest.fn().mockResolvedValue(undefined),
removeItem: jest.fn().mockResolvedValue(undefined),
clear: jest.fn().mockResolvedValue(undefined),
capability: "storage" as const,
};
// Test a UseCase without native dependencies
const loginUseCase = new LoginUseCase(mockStorage);
const result = await loginUseCase.execute({ email: "[email protected]", password: "123" });
expect(mockStorage.setItem).toHaveBeenCalledWith("auth_token", expect.any(String));More Information
- Main README - Complete framework documentation
- Usage Examples - Complete demo app
- Development Guides - Conventions and best practices
