Opinionated Clean Architecture for React, React Native, and npm packages. MVVM + MobX + Inversify, with AI-ready skills for consistent feature scaffolding.
SaferSkills independently audited clean-architecture-stack (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Una arquitectura opinada y consistente para construir apps React, React Native y librerías npm mantenibles, basada en MVVM + MobX + Inversify + Clean Architecture.
Clean Architecture Stack — capas concéntricas
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í:
docs/00-philosophy.md) — los principios rectores que motivan cada decisión.skills/) — archivos .md con 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.docs/02-decision-records/) — registros de decisiones de arquitectura que explican el "por qué" detrás de cada elección.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:
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();
}
}domain/// src/domain/repositories/ClientRepository.ts
export interface ClientRepository {
getAll(): Promise<Client[]>;
create(client: Client): Promise<Client>;
// ...
}data/ y mapea modelos a entidades// 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:#fffdata/, no importa Firebase, no importa axios.Alert, ni navigate, ni hooks, ni window.src/ui/screens/<Feature>/<Feature>Screen.tsx y <Feature>ViewModel.ts, sin excepciones.components/.src/ui/styles; configuración y opciones reusables en src/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 |
skills/react-native/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.
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._
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.