¿Cómo estructurar una aplicación web moderna? ¿Cómo asegurar que siga siendo mantenible con el tiempo? En esta guía, crearemos una aplicación Next.js utilizando arquitectura hexagonal y validación robusta de formularios. Para hacer el aprendizaje más divertido, ¡crearemos un sistema de registro para la Academia Jedi!
El código fuente está disponible en GitHub.
Tabla de Contenidos
- Introducción a la Arquitectura Hexagonal
- Instalación y Estructura
- El Dominio
- La Infraestructura
- La Interfaz de Usuario
- Yendo Más Allá
1. Introducción a la Arquitectura Hexagonal
1.1 ¿Qué es la Arquitectura Hexagonal?
La Arquitectura Hexagonal, también conocida como "Puertos y Adaptadores", es un patrón arquitectónico que permite crear aplicaciones donde los componentes de negocio están:
- Desacoplados de los detalles técnicos
- Comprobables de forma aislada
- Independientes del framework utilizado
La arquitectura se divide en tres capas principales:
- El Dominio: Contiene la lógica de negocio pura
- Los Puertos: Definen interfaces para comunicarse con el dominio
- Los Adaptadores: Implementan puertos para interactuar con el mundo exterior
1.2 ¿Por qué Usar Arquitectura Hexagonal?
Ventajas
- Clara separación de responsabilidades
- Lógica de negocio protegida y centralizada
- Pruebas facilitadas
- Flexibilidad para cambiar tecnologías
- Mantenimiento simplificado
Casos de Uso Ideales
- Aplicaciones con reglas de negocio complejas
- Proyectos destinados a evolucionar con el tiempo
- Sistemas que requieren alta capacidad de prueba
- Aplicaciones que necesitan soportar múltiples interfaces
[El contenido continúa...]
2. Instalación y Estructura
2.1 Instalación Completa
bash
1# Creación del proyecto
2npx create-next-app@latest jedi-academy
Al crear el proyecto, responde las siguientes preguntas:
¿Te gustaría usar TypeScript? Sí
¿Te gustaría usar ESLint? Sí
¿Te gustaría usar Tailwind CSS? Sí
¿Te gustaría usar el directorio `src/`? Sí
¿Te gustaría usar App Router? (recomendado) Sí
¿Te gustaría personalizar el alias de importación predeterminado (@/*)? Sí
Usando Next.js versión 15.0.0+ con Turbopack
Una vez creado el proyecto, instala las dependencias:
bash
1cd jedi-academy
2
3# Dependencias principales para gestión y validación de formularios
4npm install @hookform/resolvers zod react-hook-form
5
6# Base de datos Prisma
7npm install @prisma/client
8npm install -D prisma
9
10# Instalación de shadcn/ui
11npx shadcn@latest init
Al inicializar shadcn/ui, responde las siguientes preguntas:
¿Te gustaría usar TypeScript (recomendado)? Sí
¿Qué estilo te gustaría usar? › Predeterminado
¿Qué color te gustaría usar como color base? › Slate
¿Dónde está tu archivo CSS global? › src/app/globals.css
¿Te gustaría usar variables CSS para colores? › Sí
¿Dónde está ubicado tu tailwind.config.js? › tailwind.config.js
Configura el alias de importación para componentes: › @/components
Configura el alias de importación para utilidades: › @/lib/utils
¿Estás usando React Server Components? › Sí
# Instalar componentes necesarios
npx shadcn@latest add form input select textarea button card toast table
bash
1# Utilidades de fecha
2npm install date-fns
3
4# Base de datos
5npx prisma init --datasource-provider sqlite
2.2 Estructura del Proyecto
src/
├── app/
│ ├── actions/
│ │ └── register-padawan.ts
│ ├── components/
│ │ ├── ui/ # componentes shadcn/ui
│ │ ├── jedi-form.tsx
│ │ └── padawan-list.tsx
│ ├── lib/
│ │ └── utils.ts # utilidades shadcn/ui
│ └── page.tsx
├── core/
│ ├── domain/
│ │ ├── models/
│ │ │ └── padawan.ts
│ │ ├── ports/
│ │ │ └── padawan-repository.ts
│ │ ├── services/
│ │ │ └── padawan-eligibility.ts
│ │ ├── validation/
│ │ │ └── padawan-schema.ts
│ │ └── events/
│ │ └── padawan-registered.ts
│ └── usecases/
│ └── register-padawan.ts
└── infrastructure/
└── db/
├── prisma/
│ ├── migrations/
│ └── schema.prisma
├── prisma.ts
└── repositories/
└── prisma-padawan-repository.ts
Esta estructura sigue una clara arquitectura hexagonal con:
app/
- Capa de interfaz de usuario de Next.js
actions/
- Acciones del Servidor para manejo de formularios
components/
- Componentes React con separación UI/negocio
lib/
- Utilidades compartidas
core/
- Lógica de negocio pura
domain/
- Modelos, puertos y servicios de negocio
usecases/
- Casos de uso de la aplicación
infrastructure/
- Implementaciones técnicas
db/
- Configuración de Prisma e implementaciones de repositorio
3. El Dominio: Núcleo de la Aplicación
3.1 Entendiendo el Dominio
El dominio es el corazón de nuestra aplicación. Aquí es donde traducimos las reglas de negocio a código, independientemente de cualquier consideración técnica. En nuestro caso, el dominio representa todas las reglas que gobiernan el registro en la Academia Jedi.
Principios Clave del Dominio
- Independencia Tecnológica
- Sin dependencias de framework
- Sin código relacionado con persistencia
- Sin lógica de UI
- Reglas de Negocio Centralizadas
- Toda la lógica de negocio está en el dominio
- Las reglas son explícitas y documentadas
- Las validaciones de negocio están separadas de las validaciones técnicas
- Modelado Rico
- Uso de tipos e interfaces precisos
- Objetos de Valor para conceptos importantes de negocio
- Entidades con identidad clara
3.2 Componentes del Dominio
3.2.1 Modelos
Los modelos representan conceptos fundamentales del negocio.
typescript
1// src/core/domain/models/padawan.ts
2export type PrimarySkill = "combat" | "healing" | "meditation" | "telepathy";
3
4export interface Padawan {
5 id?: string;
6 name: string;
7 age: number;
8 midichlorianCount: number;
9 homeworld: string;
10 primarySkill: PrimarySkill;
11 background: string;
12 createdAt?: Date;
13}
14
15export class Result<T> {
16 private constructor(
17 private readonly _isSuccess: boolean,
18 private readonly value?: T,
19 private readonly error?: string
20 ) {}
21
22 static success<T>(value: T): Result<T> {
23 return new Result<T>(true, value);
24 }
25
26 static failure<T>(error: string): Result<T> {
27 return new Result<T>(false, undefined, error);
28 }
29
30 getError(): string | undefined {
31 return this.error;
32 }
33
34 getValue(): T | undefined {
35 return this.value;
36 }
37
38 isSuccess(): boolean {
39 return this._isSuccess;
40 }
41}
3.2.2 Eventos del Dominio
Los eventos representan hechos importantes que han ocurrido en el dominio.
typescript
1// src/core/domain/events/padawan-registered.ts
2export interface DomainEvent {
3 occurredOn: Date;
4}
5
6export class PadawanRegistered implements DomainEvent {
7 occurredOn: Date = new Date();
8
9 constructor(
10 public readonly padawanId: string,
11 public readonly name: string,
12 public readonly midichlorianCount: number
13 ) {}
14}
3.2.3 Puertos (Interfaces)
Los puertos definen cómo el dominio interactúa con el mundo exterior.
typescript
1// src/core/domain/ports/padawan-repository.ts
2import { Padawan, Result } from "@/core/domain/models/padawan";
3
4export interface PadawanRepository {
5 save(padawan: Omit<Padawan, "id" | "createdAt">): Promise<Result<Padawan>>;
6 findById(id: string): Promise<Result<Padawan | null>>;
7 findAll(): Promise<Result<Padawan[]>>;
8}
3.2.4 Servicios del Dominio
Los servicios del dominio encapsulan la lógica de negocio que no encaja naturalmente en una entidad.
typescript
1// src/core/domain/services/padawan-eligibility.ts
2import { Padawan, Result } from "@/core/domain/models/padawan";
3
4export class PadawanEligibilityService {
5 static check(padawan: Omit<Padawan, "id" | "createdAt">): Result<void> {
6 if (padawan.age < 4) {
7 return Result.failure("Demasiado joven para comenzar el entrenamiento");
8 }
9 if (padawan.age > 30) {
10 return Result.failure("Demasiado mayor para comenzar el entrenamiento");
11 }
12
13 if (padawan.midichlorianCount < 7000) {
14 return Result.failure("El conteo de midiclorianos es muy bajo");
15 }
16 if (padawan.midichlorianCount > 20000) {
17 return Result.failure("Conteo de midiclorianos sospechoso");
18 }
19
20 if (padawan.background.length < 20) {
21 return Result.failure("La historia personal necesita más detalles");
22 }
23
24 return Result.success(void 0);
25 }
26}
3.2.5 Casos de Uso
Los casos de uso orquestan interacciones entre diferentes elementos del dominio para lograr una funcionalidad de negocio.
typescript
1// src/core/usecases/register-padawan.ts
2import { Padawan, Result } from "@/core/domain/models/padawan";
3import { PadawanRepository } from "@/core/domain/ports/padawan-repository";
4import { PadawanEligibilityService } from "@/core/domain/services/padawan-eligibility";
5import { PadawanRegistered } from "@/core/domain/events/padawan-registered";
6import { EventDispatcher } from "@/core/domain/ports/event-dispatcher";
7
8export class RegisterPadawanUseCase {
9 constructor(
10 private readonly padawanRepository: PadawanRepository,
11 private readonly eventDispatcher: EventDispatcher
12 ) {}
13
14 async execute(
15 padawan: Omit<Padawan, "id" | "createdAt">
16 ): Promise<Result<Padawan>> {
17 const eligibilityResult = PadawanEligibilityService.check(padawan);
18 if (!eligibilityResult.isSuccess()) {
19 return Result.failure(eligibilityResult.getError()!);
20 }
21
22 const saveResult = await this.padawanRepository.save(padawan);
23 if (!saveResult.isSuccess()) {
24 return Result.failure(saveResult.getError()!);
25 }
26
27 const savedPadawan = saveResult.getValue()!;
28
29 await this.eventDispatcher.dispatch(
30 new PadawanRegistered(
31 savedPadawan.id!,
32 savedPadawan.name,
33 savedPadawan.midichlorianCount
34 )
35 );
36
37 return Result.success(savedPadawan);
38 }
39}
4. La Infraestructura
La infraestructura implementa los puertos definidos en el dominio para interactuar con el mundo exterior.
4.1 Configuración de Prisma
prisma
1// prisma/schema.prisma
2generator client {
3 provider = "prisma-client-js"
4}
5
6datasource db {
7 provider = "sqlite"
8 url = env("DATABASE_URL")
9}
10
11model Padawan {
12 id String @id @default(uuid())
13 name String
14 age Int
15 midichlorianCount Int
16 homeworld String
17 primarySkill String
18 background String
19 createdAt DateTime @default(now())
20
21 @@map("padawans")
22}
4.2 Implementación del Adaptador
typescript
1// src/infrastructure/db/repositories/prisma-padawan-repository.ts
2import { Padawan, Result } from "@/core/domain/models/padawan";
3import { PadawanRepository } from "@/core/domain/ports/padawan-repository";
4import { prisma } from "@/infrastructure/db/prisma";
5
6export class PrismaPadawanRepository implements PadawanRepository {
7 async save(
8 padawan: Omit<Padawan, "id" | "createdAt">
9 ): Promise<Result<Padawan>> {
10 try {
11 const saved = await prisma.padawan.create({
12 data: padawan,
13 });
14
15 return Result.success(this.mapPrismaPadawanToDomain(saved));
16 } catch (error: unknown) {
17 if (error instanceof Error) {
18 return Result.failure(`Error durante el guardado: ${error.message}`);
19 }
20 return Result.failure('Ocurrió un error desconocido durante el guardado');
21 }
22 }
23
24 async findById(id: string): Promise<Result<Padawan | null>> {
25 try {
26 const padawan = await prisma.padawan.findUnique({
27 where: { id },
28 });
29
30 return Result.success(
31 padawan ? this.mapPrismaPadawanToDomain(padawan) : null
32 );
33 } catch (error: unknown) {
34 if (error instanceof Error) {
35 return Result.failure(`Error durante la búsqueda: ${error.message}`);
36 }
37 return Result.failure('Ocurrió un error desconocido durante la búsqueda');
38 }
39 }
40
41 async findAll(): Promise<Result<Padawan[]>> {
42 try {
43 const padawans = await prisma.padawan.findMany({
44 orderBy: { createdAt: "desc" },
45 });
46
47 return Result.success(padawans.map(this.mapPrismaPadawanToDomain));
48 } catch (error: unknown) {
49 if (error instanceof Error) {
50 return Result.failure(`Error durante la recuperación: ${error.message}`);
51 }
52 return Result.failure('Ocurrió un error desconocido durante la recuperación');
53 }
54 }
55
56 private mapPrismaPadawanToDomain(padawan: any): Padawan {
57 return {
58 id: padawan.id,
59 name: padawan.name,
60 age: padawan.age,
61 midichlorianCount: padawan.midichlorianCount,
62 homeworld: padawan.homeworld,
63 primarySkill: padawan.primarySkill,
64 background: padawan.background,
65 createdAt: padawan.createdAt,
66 };
67 }
68}
Ahora podemos generar la base de datos.
Para exploración adicional, Prisma proporciona un cliente para visualizar la base de datos.
5.2 Acción del Servidor para el Registro
typescript
1// src/app/actions/register-padawan.ts
2'use server'
3
4import { PrismaPadawanRepository } from '@/infrastructure/db/repositories/prisma-padawan-repository';
5import { ConsoleEventDispatcher } from '@/infrastructure/services/event-dispatcher';
6import { RegisterPadawanUseCase } from '@/core/usecases/register-padawan';
7import { padawanSchema } from '@/core/domain/validation/padawan-schema';
8import { revalidatePath } from 'next/cache';
9
10const padawanRepository = new PrismaPadawanRepository();
11const eventDispatcher = new ConsoleEventDispatcher();
12const registerPadawan = new RegisterPadawanUseCase(
13 padawanRepository,
14 eventDispatcher
15);
16
17export async function handlePadawanRegistration(formData: FormData) {
18 try {
19 const validatedData = padawanSchema.parse(Object.fromEntries(formData));
20 const result = await registerPadawan.execute(validatedData);
21
22 if (!result.isSuccess()) {
23 return {
24 success: false,
25 message: result.getError()
26 };
27 }
28
29 revalidatePath('/');
30 return {
31 success: true,
32 message: "¡Que la Fuerza te acompañe, joven Padawan! 🌟"
33 };
34 } catch (error) {
35 return {
36 success: false,
37 message: "Se ha detectado una perturbación en la Fuerza... 🌀"
38 };
39 }
40}
5.3 Componente del Formulario
typescript
1// src/app/components/jedi-form.tsx
2"use client";
3
4import { useForm } from "react-hook-form";
5import { zodResolver } from "@hookform/resolvers/zod";
6import { useToast } from "@/hooks/use-toast";
7
8import {
9 Form,
10 FormControl,
11 FormField,
12 FormItem,
13 FormLabel,
14 FormMessage,
15} from "@/components/ui/form";
16import {
17 Card,
18 CardHeader,
19 CardTitle,
20 CardDescription,
21 CardContent,
22} from "@/components/ui/card";
23import { Input } from "@/components/ui/input";
24import { Button } from "@/components/ui/button";
25import { Textarea } from "@/components/ui/textarea";
26import {
27 Select,
28 SelectContent,
29 SelectItem,
30 SelectTrigger,
31 SelectValue,
32} from "@/components/ui/select";
33import {
34 padawanSchema,
35 type PadawanFormData,
36} from "@/core/domain/validation/padawan-schema";
37import { handlePadawanRegistration } from "../actions/register-padawan";
38
39export function JediRegistrationForm() {
40 const { toast } = useToast();
41 const form = useForm<PadawanFormData>({
42 resolver: zodResolver(padawanSchema),
43 defaultValues: {
44 name: "",
45 age: 0,
46 midichlorianCount: 7000,
47 homeworld: "",
48 primarySkill: "combat",
49 background: "",
50 },
51 });
52
53 async function onSubmit(data: PadawanFormData) {
54 const formData = new FormData();
55 Object.entries(data).forEach(([key, value]) => {
56 formData.append(key, value.toString());
57 });
58
59 const result = await handlePadawanRegistration(formData);
60
61 toast({
62 title: result.success ? "Registro exitoso" : "Error",
63 description: result.message,
64 variant: result.success ? "default" : "destructive",
65 });
66
67 if (result.success) {
68 form.reset();
69 }
70 }
71
72 return (
73 <Card className="w-full max-w-md mx-auto">
74 <CardHeader>
75 <CardTitle>Academia Jedi</CardTitle>
76 <CardDescription>
77 Envía tu solicitud para unirte a la Orden
78 </CardDescription>
79 </CardHeader>
80 <CardContent>
81 <Form {...form}>
82 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
83 <FormField
84 control={form.control}
85 name="name"
86 render={({ field }) => (
87 <FormItem>
88 <FormLabel>Nombre del Padawan</FormLabel>
89 <FormControl>
90 <Input placeholder="Luke Skywalker" {...field} />
91 </FormControl>
92 <FormMessage />
93 </FormItem>
94 )}
95 />
96
97 <FormField
98 control={form.control}
99 name="age"
100 render={({ field }) => (
101 <FormItem>
102 <FormLabel>Edad</FormLabel>
103 <FormControl>
104 <Input
105 type="number"
106 min="4"
107 max="30"
108 placeholder="19"
109 {...field}
110 onChange={(e) => field.onChange(parseInt(e.target.value))}
111 />
112 </FormControl>
113 <FormMessage />
114 </FormItem>
115 )}
116 />
117
118 <FormField
119 control={form.control}
120 name="midichlorianCount"
121 render={({ field }) => (
122 <FormItem>
123 <FormLabel>Conteo de Midiclorianos</FormLabel>
124 <FormControl>
125 <Input
126 type="number"
127 min="7000"
128 max="20000"
129 step="100"
130 {...field}
131 onChange={(e) => field.onChange(parseInt(e.target.value))}
132 />
133 </FormControl>
134 <FormMessage />
135 </FormItem>
136 )}
137 />
138
139 <FormField
140 control={form.control}
141 name="homeworld"
142 render={({ field }) => (
143 <FormItem>
144 <FormLabel>Planeta Natal</FormLabel>
145 <FormControl>
146 <Input placeholder="Tatooine" {...field} />
147 </FormControl>
148 <FormMessage />
149 </FormItem>
150 )}
151 />
152
153 <FormField
154 control={form.control}
155 name="primarySkill"
156 render={({ field }) => (
157 <FormItem>
158 <FormLabel>Habilidad Principal</FormLabel>
159 <Select
160 onValueChange={field.onChange}
161 defaultValue={field.value}
162 >
163 <FormControl>
164 <SelectTrigger>
165 <SelectValue placeholder="Elige una habilidad" />
166 </SelectTrigger>
167 </FormControl>
168 <SelectContent>
169 <SelectItem value="combat">
170 Combate con Sable de Luz ⚔️
171 </SelectItem>
172 <SelectItem value="healing">
173 Sanación con la Fuerza 💚
174 </SelectItem>
175 <SelectItem value="meditation">
176 Meditación Profunda 🧘
177 </SelectItem>
178 <SelectItem value="telepathy">Telepatía 🧠</SelectItem>
179 </SelectContent>
180 </Select>
181 <FormMessage />
182 </FormItem>
183 )}
184 />
185
186 <FormField
187 control={form.control}
188 name="background"
189 render={({ field }) => (
190 <FormItem>
191 <FormLabel>Tu Historia</FormLabel>
192 <FormControl>
193 <Textarea
194 placeholder="Cuéntanos sobre tu viaje..."
195 className="resize-none"
196 rows={4}
197 {...field}
198 />
199 </FormControl>
200 <FormMessage />
201 </FormItem>
202 )}
203 />
204
205 <Button type="submit" className="w-full">
206 Enviar mi solicitud 🌟
207 </Button>
208 </form>
209 </Form>
210 </CardContent>
211 </Card>
212 );
213}
5.4 Lista de Padawans
typescript
1// src/app/components/padawan-list.tsx
2"use client";
3
4import {
5 Table,
6 TableBody,
7 TableCaption,
8 TableCell,
9 TableHead,
10 TableHeader,
11 TableRow,
12} from "@/components/ui/table";
13import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
14import { format } from "date-fns";
15import { es } from "date-fns/locale";
16import type { Padawan } from "@/core/domain/models/padawan";
17
18type PadawanListProps = {
19 padawans: Padawan[];
20};
21
22const skillEmoji = {
23 combat: "⚔️ Combate con Sable de Luz",
24 healing: "💚 Sanación con la Fuerza",
25 meditation: "🧘 Meditación Profunda",
26 telepathy: "🧠 Telepatía",
27} as const;
28
29export function PadawanList({ padawans }: PadawanListProps) {
30 if (padawans.length === 0) {
31 return (
32 <Card>
33 <CardContent className="pt-6">
34 <p className="text-center text-muted-foreground">
35 Aún no hay Padawans registrados...
36 </p>
37 </CardContent>
38 </Card>
39 );
40 }
41
42 return (
43 <Card>
44 <CardHeader>
45 <CardTitle>Lista de Candidatos</CardTitle>
46 </CardHeader>
47 <CardContent>
48 <Table>
49 <TableHeader>
50 <TableRow>
51 <TableHead>Nombre</TableHead>
52 <TableHead>Edad</TableHead>
53 <TableHead>Planeta Natal</TableHead>
54 <TableHead>Especialidad</TableHead>
55 <TableHead>Midiclorianos</TableHead>
56 <TableHead>Registro</TableHead>
57 </TableRow>
58 </TableHeader>
59 <TableBody>
60 {padawans.map((padawan) => (
61 <TableRow key={padawan.id}>
62 <TableCell className="font-medium">{padawan.name}</TableCell>
63 <TableCell>{padawan.age} años</TableCell>
64 <TableCell>{padawan.homeworld}</TableCell>
65 <TableCell>{skillEmoji[padawan.primarySkill]}</TableCell>
66 <TableCell>
67 {padawan.midichlorianCount.toLocaleString()}
68 </TableCell>
69 <TableCell>
70 {format(new Date(padawan.createdAt!), "Pp", { locale: es })}
71 </TableCell>
72 </TableRow>
73 ))}
74 </TableBody>
75 </Table>
76 </CardContent>
77 </Card>
78 );
79}
Estos componentes utilizan shadcn/ui para crear una interfaz de usuario moderna y accesible, con:
- JediRegistrationForm:
- Validación completa con react-hook-form y Zod
- Manejo de errores con mensajes contextuales
- Retroalimentación al usuario con toasts
- Reinicio del formulario después de un envío exitoso
- PadawanList:
- Visualización tabular de datos
- Manejo de estado vacío
- Formateo de fechas en español
- Iconos de habilidades
- Estilo consistente con el formulario
Todo se integra perfectamente en nuestra arquitectura hexagonal mientras se mantiene en la capa de presentación, sin lógica de negocio.
6. Yendo Más Allá
6.1 Mejoras Técnicas
- Caché y Rendimiento
typescript
1// Ejemplo de implementación de caché con Redis
2export class RedisPadawanCache {
3 async cacheResults(key: string, data: any): Promise<void> {
4 await redis.set(key, JSON.stringify(data), "EX", 3600);
5 }
6}
- Monitoreo y Registro
typescript
1// Servicio de monitoreo
2export class ApplicationMonitoring {
3 trackDomainEvent(event: DomainEvent): void {
4 // Enviar al servicio de monitoreo
5 }
6}
6.2 Evoluciones Funcionales
- Flujo de Aprobación
- Sistema de Calificación
- Gestión de Promociones
- Comunicación entre Padawans
Conclusión
La arquitectura hexagonal nos ha permitido crear una aplicación que es:
- Mantenible
- Clara separación de responsabilidades
- Código de negocio aislado y protegido
- Escalable
- Fácil de agregar nuevas características
- Capacidad de cambiar la infraestructura
- Soporte para nuevos canales de entrada/salida
Para ir más allá, podrías:
- Agregar autenticación
- Implementar un sistema de caché
- Configurar monitoreo
- Establecer pruebas automatizadas
- Agregar webhooks
Recuerda: ¡la buena arquitectura es como la Fuerza - debe estar en equilibrio! 🌟
[Fin de la traducción]