Una arquitectura opinada y consistente para construir apps React, React Native y librerías npm mantenibles, basada en MVVM + MobX + Inversify + Clean Architecture.
Este repositorio es la documentación de una arquitectura que he venido refinando en proyectos React y React Native. No es un framework, no es un paquete npm, no es un boilerplate. Es un conjunto de reglas, convenciones y plantillas que cualquier desarrollador (o LLM, ver más abajo) puede leer y aplicar para escribir código mantenible, testeable y consistente.
Encontrarás aquí:
- Filosofía (
docs/00-philosophy.md) — los principios rectores que motivan cada decisión. - Skills (
skills/) — archivos.mdcon instrucciones precisas, diseñados para que tanto humanos como asistentes de IA (Claude, Cursor, Copilot) los consuman y generen código que cumpla las reglas. - ADRs (
docs/02-decision-records/) — registros de decisiones de arquitectura que explican el "por qué" detrás de cada elección. - Ejemplos (
examples/) — snippets canónicos de cada pieza (ViewModel, UseCase, Entity, Repository, DI bindings).
El ecosistema React actual está dominado por hooks, Zustand/Jotai, server components y patrones funcionales. Son excelentes para muchos casos. Esta arquitectura no compite con ellos en su terreno: compite cuando:
- El dominio del negocio es rico (no eres un wrapper de un CRUD).
- El equipo es mediano o grande (3+ desarrolladores).
- El código va a vivir años y va a ser tocado por gente que no estuvo en su diseño original.
- Quieres que agregar una feature siempre se sienta igual, sin importar quién la escriba.
En esos contextos, la disciplina de capas + DI + ViewModels rinde mucho más que un montón de hooks compartidos. Si tu app es un dashboard de 5 pantallas con 2 endpoints, esto es overkill — usa Zustand y sigue tu camino.
| Úsalo cuando… | Evítalo cuando… |
|---|---|
| Tu app tiene reglas de negocio no triviales | Es un sitio mayormente estático o de marketing |
| Vas a tener múltiples fuentes de datos (REST, GraphQL, FB) | Tienes un solo endpoint y nada más |
| El equipo crece y necesitas onboarding predecible | Estás solo y vas a quedarte solo |
| Quieres testear lógica sin levantar la UI | El código es desechable o un prototipo |
| Necesitas reemplazar el backend sin tocar la UI | El backend es estable y nunca va a cambiar |
| Vas a tener varios productos compartiendo dominio | Es una app one-shot |
Más detalle en docs/04-when-not-to-use.md.
Vamos a ver el flujo completo de "agregar un cliente", de la pantalla hasta la API.
sequenceDiagram
participant S as ClientsScreen
participant ViewModel as ClientsViewModel
participant UC as GetAllClientUseCase
participant R as ClientRepository
participant API as REST API
S->>ViewModel: viewModel.loadAll()
ViewModel->>ViewModel: updateLoadingState(true)
ViewModel->>UC: uc.run()
UC->>R: repo.getAll()
R->>API: GET /clients
API-->>R: ClientModel[]
R->>R: models.map(m => m.toDomain())
R-->>UC: Client[]
UC-->>ViewModel: Client[]
ViewModel->>ViewModel: runInAction(() => isItemsResponse = data)
ViewModel->>ViewModel: updateLoadingState(false)
ViewModel-->>S: re-render (observer)
// src/ui/screens/Clients/ClientsScreen.tsx
const ClientsScreen = observer(() => {
const viewModel = useViewModel<ClientsViewModel>(TYPES.ClientsViewModel);
useEffect(() => { viewModel.loadAll(); }, [viewModel]);
return (
<SafeAreaView>
{viewModel.isClientsLoading && <Spinner />}
{viewModel.isClientsError && <Text>{viewModel.isClientsError}</Text>}
<FlatList data={viewModel.isClientsResponse} ... />
<PrimaryButton label="Agregar" onPress={() => viewModel.create(formValues)} />
</SafeAreaView>
);
});// src/ui/screens/Clients/ClientsViewModel.ts
@injectable()
export class ClientsViewModel {
isClientsLoading = false;
isClientsError: string | null = null;
isClientsResponse: Client[] | null = null;
constructor(
@inject(TYPES.GetAllClientUseCase) private getAll: GetAllClientUseCase,
@inject(TYPES.CreateClientUseCase) private createUC: CreateClientUseCase,
) {
makeAutoObservable(this);
}
async loadAll() {
this.updateLoadingState(true, null, 'items');
try {
const response = await this.getAll.run();
runInAction(() => { this.isClientsResponse = response; });
this.updateLoadingState(false, null, 'items');
} catch (e) { this.handleError(e, 'items'); }
}
// ... updateLoadingState, handleError, create, etc.
}// src/domain/useCases/GetAllClientUseCase/index.ts
@injectable()
export class GetAllClientUseCase implements UseCase<void, Client[]> {
constructor(
@inject(TYPES.ClientRepository) private repo: ClientRepository,
) {}
async run(): Promise<Client[]> {
return this.repo.getAll();
}
}// src/domain/repositories/ClientRepository.ts
export interface ClientRepository {
getAll(): Promise<Client[]>;
create(client: Client): Promise<Client>;
// ...
}// src/data/repositories/ClientRepositoryImpl.ts
@injectable()
export class ClientRepositoryImpl implements ClientRepository {
constructor(
@inject(TYPES.ClientService) private service: ClientService,
) {}
async getAll(): Promise<Client[]> {
const models = await this.service.fetchAll();
return models.map(m => m.toDomain());
}
}Eso es todo. Cualquier feature nueva sigue ese mismo flujo, escrito por cualquier persona del equipo, queda igual. Esa es la promesa.
Ver el flujo completo de archivos en examples/.
flowchart LR
UI["🖥️ UI<br/>Screens + Components"]
ViewModel["🧠 ViewModel<br/>(MobX + Inversify)"]
UC["⚙️ UseCases<br/>(1 acción = 1 UC)"]
REPO["📜 Repository<br/>Interface (domain)"]
IMPL["🔌 Repository<br/>Impl (data)"]
SVC["🌐 Service<br/>HTTP / Firebase"]
UI --> ViewModel
ViewModel --> UC
UC --> REPO
IMPL -.implements.-> REPO
IMPL --> SVC
style UI fill:#1A2F5E,stroke:#2D7EF8,color:#fff
style ViewModel fill:#1A2F5E,stroke:#2D7EF8,color:#fff
style UC fill:#0A1628,stroke:#2D7EF8,color:#fff
style REPO fill:#0A1628,stroke:#2D7EF8,color:#fff
style IMPL fill:#0A1628,stroke:#9B59B6,color:#fff
style SVC fill:#0A1628,stroke:#9B59B6,color:#fff
- UI depende solo del ViewModel. No importa
data/, no importa Firebase, no importa axios. - ViewModel depende solo de UseCases. No conoce repositorios.
- UseCases dependen solo de contratos del dominio (interfaces).
domain/no importa nada de framework ni infraestructura. Es portable.- Cada acción de negocio = un UseCase. Uno solo, en su propia carpeta.
- Los modelos de transporte (DTOs) nunca llegan a la UI. Siempre mapeados a entidades del dominio.
- Las ViewModels son UI-agnósticas. No tienen
Alert, ninavigate, ni hooks, niwindow. - Cada Screen vive en su propia carpeta con su ViewModel.
src/ui/screens/<Feature>/<Feature>Screen.tsxy<Feature>ViewModel.ts, sin excepciones. - La Screen solo compone UI. Sin subcomponentes internos, sin transformación de datos, sin branching de negocio; los componentes privados van en
components/. - Las constantes no viven en la Screen. Tokens visuales en
src/ui/styles; configuración y opciones reusables ensrc/config.
| Pieza | Elección | Alternativa rechazada | ADR |
|---|---|---|---|
| Estado | MobX (makeAutoObservable) |
Zustand, Redux Toolkit | 001 |
| Inyección | Inversify (@injectable + TYPES) |
React Context, factories | 002 |
| Entidades | Clases con [key: string]: any |
Interfaces puras | 003 |
| Granularidad ViewModel→UC | 1 acción = 1 UseCase | Service con N métodos | 004 |
| Patrón ViewModel | ICalls + updateLoadingState |
useState por flag |
005 |
| Logging | Logger con scope (no inyectado) |
console.*, ILogger por DI |
006 |
| Streams realtime | SubscriptionUseCase |
run(): Promise<Unsubscribe> |
007 |
| Estado compartido | Stores singleton (MobX) | React Context, RootStore god-object | 008 |
| Transporte (data) | Capa Manager separada de Service | Service habla Axios/Firebase directo | 009 |
| Formato de skills | Frontmatter + secciones XML | Markdown libre | 010 |
| Boundary de Screens | Carpeta propia + Screen visual | Screens con lógica/subcomponentes | 011 |
- Lee este README completo y la filosofía detrás de las decisiones.
- Revisa el Tour rápido y los ejemplos canónicos.
- Consulta la skill que aplica a tu plataforma:
- React Native (Expo):
skills/react-native/ - React web: en Fase 2
- npm package: en Fase 2
- React Native (Expo):
- Para cada feature nueva, sigue el PR Checklist.
Más detalle en docs/01-getting-started.md.
Las skills en skills/ no son documentación pasiva. Están escritas como instrucciones ejecutables para asistentes de IA: si pegas el contenido de feature-scaffold-rn.md en Claude Projects, Cursor Rules, o un system prompt, el LLM va a generar features que cumplen estas reglas sin que tengas que repetirlas en cada prompt.
Todas siguen un formato canónico (frontmatter + secciones XML <purpose>/<when_to_use>/<rules>/<examples>/<output_format>/<see_also>) basado en las prácticas de prompt y context engineering de Anthropic. El estándar de formato y la convención para instalarlas y mantenerlas en cada proyecto (p.ej. .claude/skills/<name>/SKILL.md) viven en skill-authoring. Este repo es la fuente de verdad; los proyectos consumen copias y se resincronizan desde aquí.
| Skill | Propósito |
|---|---|
| clean-architecture-rn-expo-mvvm | Reglas generales de arquitectura (RN Expo) |
| feature-scaffold-rn | Scaffold completo de una feature vertical |
| unit-testing-clean-architecture | Tests unitarios (obligatorios) — contrato completo |
| realtime-and-global-state-rn | Streams realtime, stores globales y offline/sync |
| design-system-rn | Tokens y componentes del design system |
| pr-checklist-clean-architecture | Checklist para revisar PRs |
| skill-authoring | Formato canónico de las skills + cómo mantenerlas en proyectos |
¿Por qué MobX en 2026? Porque makeAutoObservable + clases es el match perfecto para MVVM y la ViewModel-as-class. Zustand es excelente, pero te empuja a un estilo funcional/hooks que choca con la disciplina de capas que buscamos. Detalle en ADR 001.
¿Inversify no es exagerado para React? Para una app pequeña, sí. Para apps con 20+ pantallas, decenas de UseCases y múltiples adaptadores de datos, Inversify se paga solo. Detalle en ADR 002.
¿Por qué [key: string]: any en entidades? Es una concesión consciente: privilegia velocidad de iteración con backends inestables sobre tipado exhaustivo. Detalle en ADR 003.
¿"1 acción = 1 UseCase" no genera explosión de archivos? En apps puramente CRUD, sí. En apps con dominio rico, esa explosión es exactamente lo que da claridad. Detalle en ADR 004.
Más en docs/03-faq.md.
- ✅ Fase 1 — Skills RN + docs base + ADRs principales (este release)
- 🚧 Fase 2 — Skills React web + monorepo/npm package
- 🔭 Fase 3 — Skills Python (FastAPI + hexagonal)
- 🔭 Fase 4 — CLI generador de features (
npx cas-cli new-feature Clients)
Detalle en ROADMAP.md. Historial de cambios en CHANGELOG.md.
Las skills evolucionan con el uso real. Si encuentras un caso que no cubren, una regla que choca con tu contexto, o un patrón mejor: abre un issue o PR. Ver CONTRIBUTING.md.
MIT — ver LICENSE.
Escrito por @Kevinparra535. Si esto te ayudó, deja una estrella ⭐ en el repo.
