Software Architecture Patterns: Layered, Hexagonal, and Clean
Architecture defines how your code is organized and how dependencies flow.
Layered Architecture
Presentation Layer → Controllers, API handlers
Business Layer → Services, use cases
Data Layer → Repositories, ORM
Infrastructure → Database, external services
// Layered example
// Layer 1: Controller (Presentation)
class UserController {
constructor(private userService: UserService) {}
async createUser(req: Request, res: Response) {
const user = await this.userService.create(req.body);
res.status(201).json(user);
}
}
// Layer 2: Service (Business)
class UserService {
constructor(private userRepo: UserRepository) {}
async create(data: CreateUserDto): Promise<User> {
await this.validateEmail(data.email);
return this.userRepo.save(new User(data));
}
}
// Layer 3: Repository (Data)
class UserRepository {
async save(user: User): Promise<User> {
return this.db.users.create(user);
}
}
Hexagonal Architecture (Ports & Adapters)
Isolates the domain from infrastructure concerns.
// Domain Core (no external dependencies)
class UserService {
constructor(
private userPort: UserPort, // port (interface)
private emailPort: EmailPort // port (interface)
) {}
async register(email: string, password: string): Promise<User> {
const exists = await this.userPort.findByEmail(email);
if (exists) throw new DuplicateEmailError();
const user = User.create(email, password);
await this.userPort.save(user);
await this.emailPort.sendWelcome(email);
return user;
}
}
// Port (interface defined by domain)
interface UserPort {
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
}
interface EmailPort {
sendWelcome(email: string): Promise<void>;
}
// Adapters (implement ports, depend on external libs)
class PostgresUserAdapter implements UserPort {
constructor(private db: PrismaClient) {}
async findByEmail(email: string) {
return this.db.user.findUnique({ where: { email } });
}
async save(user: User) {
await this.db.user.upsert({ where: { id: user.id }, create: user, update: user });
}
}
class SendGridEmailAdapter implements EmailPort {
async sendWelcome(email: string) {
await sendGrid.send({ to: email, subject: 'Welcome!', text: '...' });
}
}
Clean Architecture
// Entities - enterprise business rules
class User {
private constructor(
readonly id: string,
readonly email: Email,
readonly createdAt: Date
) {}
static create(email: string): User {
return new User(crypto.randomUUID(), Email.of(email), new Date());
}
}
// Use Cases - application business rules
class RegisterUserUseCase {
constructor(
private userRepo: UserRepository,
private emailService: EmailService,
private eventBus: EventBus
) {}
async execute(request: RegisterUserRequest): Promise<RegisterUserResponse> {
const existing = await this.userRepo.findByEmail(request.email);
if (existing) return { success: false, error: 'Email already registered' };
const user = User.create(request.email);
await this.userRepo.save(user);
await this.emailService.sendWelcome(user.email.toString());
await this.eventBus.publish(new UserRegistered(user.id));
return { success: true, userId: user.id };
}
}
// Interface Adapters - controllers, presenters, gateways
class RegisterUserController {
constructor(private useCase: RegisterUserUseCase) {}
async handle(req: Request, res: Response) {
const result = await this.useCase.execute({ email: req.body.email });
if (!result.success) return res.status(400).json({ error: result.error });
res.status(201).json({ id: result.userId });
}
}
When to Use Each
| Pattern | Best For |
|---|---|
| Layered | Simple CRUD apps, small teams |
| Hexagonal | Multiple adapters (CLI + HTTP + events), high testability |
| Clean | Complex domains, long-lived systems |
Architecture is a tool, not a dogma—choose based on your context.