How to structure a modern web application? How to ensure it remains maintainable over time? In this guide, we'll create a Next.js application using hexagonal architecture and robust form validation. To make learning more fun, we'll create a Jedi Academy registration system!
The source code is available on GitHub.
Table of Contents
- Introduction to Hexagonal Architecture
- Installation and Structure
- The Domain
- The Infrastructure
- The User Interface
- Going Further
1. Introduction to Hexagonal Architecture
1.1 What is Hexagonal Architecture?
Hexagonal Architecture, also known as "Ports and Adapters", is an architectural pattern that allows creating applications where business components are:
- Decoupled from technical details
- Testable in isolation
- Independent from the framework used
The architecture is divided into three main layers:
- The Domain: Contains pure business logic
- The Ports: Define interfaces to communicate with the domain
- The Adapters: Implement ports to interact with the outside world
1.2 Why Use Hexagonal Architecture?
Advantages
- Clear separation of concerns
- Protected and centralized business logic
- Facilitated testing
- Flexibility to change technologies
- Simplified maintenance
Ideal Use Cases
- Applications with complex business rules
- Projects intended to evolve over time
- Systems requiring high testability
- Applications needing to support multiple interfaces
[Content continues...]
2. Installation and Structure
2.1 Complete Installation
1# Project creation
2npx create-next-app@latest jedi-academy
When creating the project, answer the following questions:
Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? Yes
Would you like to use `src/` directory? Yes
Would you like to use App Router? (recommended) Yes
Would you like to customize the default import alias (@/*)? Yes
Using Next.js version 15.0.0+ with Turbopack
Once the project is created, install the dependencies:
1cd jedi-academy
2
3# Main dependencies for form management and validation
4npm install @hookform/resolvers zod react-hook-form
5
6# Prisma database
7npm install @prisma/client
8npm install -D prisma
9
10# shadcn/ui installation
11npx shadcn@latest init
When initializing shadcn/ui, answer the following questions:
Would you like to use TypeScript (recommended)? Yes
Which style would you like to use? › Default
Which color would you like to use as base color? › Slate
Where is your global CSS file? › src/app/globals.css
Would you like to use CSS variables for colors? › Yes
Where is your tailwind.config.js located? › tailwind.config.js
Configure the import alias for components: › @/components
Configure the import alias for utils: › @/lib/utils
Are you using React Server Components? › Yes
# Install necessary components
npx shadcn@latest add form input select textarea button card toast table
1# Date utilities
2npm install date-fns
3
4# Database
5npx prisma init --datasource-provider sqlite
2.2 Project Structure
src/
├── app/
│ ├── actions/
│ │ └── register-padawan.ts
│ ├── components/
│ │ ├── ui/ # shadcn/ui components
│ │ ├── jedi-form.tsx
│ │ └── padawan-list.tsx
│ ├── lib/
│ │ └── utils.ts # shadcn/ui utilities
│ └── 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
This structure follows a clear hexagonal architecture with:
app/ - Next.js user interface layer
actions/ - Server Actions for form handling
components/ - React components with UI/business separation
lib/ - Shared utilities
core/ - Pure business logic
domain/ - Models, ports, and business services
usecases/ - Application use cases
infrastructure/ - Technical implementations
db/ - Prisma configuration and repository implementations
3. The Domain: Core of the Application
3.1 Understanding the Domain
The domain is the heart of our application. This is where we translate business rules into code, independently of any technical consideration. In our case, the domain represents all the rules governing registration at the Jedi Academy.
Key Domain Principles
- Technological Independence
- No framework dependencies
- No persistence-related code
- No UI logic
- Centralized Business Rules
- All business logic is in the domain
- Rules are explicit and documented
- Business validations are separated from technical validations
- Rich Modeling
- Use of precise types and interfaces
- Value Objects for important business concepts
- Entities with clear identity
3.2 Domain Components
3.2.1 Models
Models represent fundamental business concepts.
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 Domain Events
Events represent important facts that have occurred in the domain.
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 Ports (Interfaces)
Ports define how the domain interacts with the outside world.
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 Domain Services
Domain services encapsulate business logic that doesn't naturally fit into an entity.
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("Too young to begin the training");
8 }
9 if (padawan.age > 30) {
10 return Result.failure("Too old to begin the training");
11 }
12
13 if (padawan.midichlorianCount < 7000) {
14 return Result.failure("Midichlorian count is too low");
15 }
16 if (padawan.midichlorianCount > 20000) {
17 return Result.failure("Suspicious midichlorian count");
18 }
19
20 if (padawan.background.length < 20) {
21 return Result.failure("Personal story needs more details");
22 }
23
24 return Result.success(void 0);
25 }
26}
3.2.5 Use Cases
Use cases orchestrate interactions between different domain elements to achieve a business functionality.
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. The Infrastructure
The infrastructure implements the ports defined in the domain to interact with the outside world.
4.1 Prisma Configuration
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 Adapter Implementation
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 during save: ${error.message}`);
19 }
20 return Result.failure('An unknown error occurred during save');
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 during search: ${error.message}`);
36 }
37 return Result.failure('An unknown error occurred during search');
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 during retrieval: ${error.message}`);
51 }
52 return Result.failure('An unknown error occurred during retrieval');
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}
5. The User Interface
5.1 Validation with Zod
Input data validation uses Zod to ensure consistency with our domain.
1// src/core/domain/validation/padawan-schema.ts
2import { z } from "zod";
3
4export const padawanSchema = z.object({
5 name: z
6 .string()
7 .min(2, "A Padawan must have a name longer than 2 characters")
8 .max(50, "Even Qui-Gon Jinn doesn't have such a long name"),
9 age: z.coerce
10 .number()
11 .min(4, "Even Grogu started his training at age 4")
12 .max(30, "The Council considers this the age limit to begin training"),
13 midichlorianCount: z.coerce
14 .number()
15 .min(7000, "Midichlorian count is too low for Jedi training")
16 .max(20000, "Even Anakin only had 20000 midichlorians"),
17 homeworld: z
18 .string()
19 .min(2, "Your planet name is required"),
20 primarySkill: z.enum(["combat", "healing", "meditation", "telepathy"], {
21 errorMap: () => ({
22 message: "This skill is not recognized by the Jedi Order",
23 }),
24 }),
25 background: z
26 .string()
27 .min(20, "Tell us more about your journey"),
28});
29
30export type PadawanFormData = z.infer<typeof padawanSchema>;
Now we can generate the db.
For further exploration, Prisma provides a client to visualize the db.
5.2 Server Action for Registration
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: "May the Force be with you, young Padawan! 🌟"
33 };
34 } catch (error) {
35 return {
36 success: false,
37 message: "A disturbance in the Force has been detected... 🌀"
38 };
39 }
40}
5.3 Form Component
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 ? "Registration successful" : "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>Jedi Academy</CardTitle>
76 <CardDescription>
77 Submit your application to join the Order
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>Padawan Name</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>Age</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>Midichlorian Count</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>Homeworld</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>Primary Skill</FormLabel>
159 <Select
160 onValueChange={field.onChange}
161 defaultValue={field.value}
162 >
163 <FormControl>
164 <SelectTrigger>
165 <SelectValue placeholder="Choose a skill" />
166 </SelectTrigger>
167 </FormControl>
168 <SelectContent>
169 <SelectItem value="combat">
170 Lightsaber Combat ⚔️
171 </SelectItem>
172 <SelectItem value="healing">
173 Force Healing 💚
174 </SelectItem>
175 <SelectItem value="meditation">
176 Deep Meditation 🧘
177 </SelectItem>
178 <SelectItem value="telepathy">Telepathy 🧠</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>Your Story</FormLabel>
192 <FormControl>
193 <Textarea
194 placeholder="Tell us about your journey..."
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 Submit my application 🌟
207 </Button>
208 </form>
209 </Form>
210 </CardContent>
211 </Card>
212 );
213}
5.4 Padawan List
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 { enUS } from "date-fns/locale";
16import type { Padawan } from "@/core/domain/models/padawan";
17
18type PadawanListProps = {
19 padawans: Padawan[];
20};
21
22const skillEmoji = {
23 combat: "⚔️ Lightsaber Combat",
24 healing: "💚 Force Healing",
25 meditation: "🧘 Deep Meditation",
26 telepathy: "🧠 Telepathy",
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 No Padawans registered yet...
36 </p>
37 </CardContent>
38 </Card>
39 );
40 }
41
42 return (
43 <Card>
44 <CardHeader>
45 <CardTitle>Candidates List</CardTitle>
46 </CardHeader>
47 <CardContent>
48 <Table>
49 <TableHeader>
50 <TableRow>
51 <TableHead>Name</TableHead>
52 <TableHead>Age</TableHead>
53 <TableHead>Homeworld</TableHead>
54 <TableHead>Specialty</TableHead>
55 <TableHead>Midichlorians</TableHead>
56 <TableHead>Registration</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} years</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: enUS })}
71 </TableCell>
72 </TableRow>
73 ))}
74 </TableBody>
75 </Table>
76 </CardContent>
77 </Card>
78 );
79}
These components use shadcn/ui to create a modern and accessible user interface, with:
- JediRegistrationForm:
- Complete validation with react-hook-form and Zod
- Error handling with contextual messages
- User feedback with toasts
- Form reset after successful submission
- PadawanList:
- Tabular data display
- Empty state handling
- Date formatting in English
- Skill icons
- Consistent styling with the form
Everything integrates perfectly into our hexagonal architecture while staying at the presentation layer, without business logic.
6. Going Further
6.1 Technical Improvements
- Cache and Performance
1// Redis cache implementation example
2export class RedisPadawanCache {
3 async cacheResults(key: string, data: any): Promise<void> {
4 await redis.set(key, JSON.stringify(data), "EX", 3600);
5 }
6}
- Monitoring and Logging
1// Monitoring service
2export class ApplicationMonitoring {
3 trackDomainEvent(event: DomainEvent): void {
4 // Send to monitoring service
5 }
6}
6.2 Functional Evolutions
- Approval Workflow
- Rating System
- Promotion Management
- Padawan Communication
Conclusion
Hexagonal architecture has allowed us to create an application that is:
- Maintainable
- Clear separation of concerns
- Isolated and protected business code
- Scalable
- Easy to add new features
- Ability to change infrastructure
- Support for new input/output channels
To go further, you could:
- Add authentication
- Implement a caching system
- Set up monitoring
- Set up automated tests
- Add webhooks
Remember: good architecture is like the Force - it must be in balance! 🌟
[End of translation]