Software Architecture Patterns: MVC, Clean Architecture, Hexagonal and Event-Driven
Architecture patterns are blueprints for organizing code. They solve recurring structural problems: how to separate concerns, how to keep business logic independent of frameworks, how to scale teams and systems. Understanding these patterns is expected at senior developer level and comes up in system design and architectural interviews.
Why Architecture Matters
Without intentional architecture, codebases become "big balls of mud" β changes anywhere break things everywhere, tests are impossible to write, and onboarding new developers takes months. Good architecture makes code:
- Testable β business logic can be tested without a running server or database
- Maintainable β changes are localized; the impact of modifications is predictable
- Adaptable β you can swap databases, frameworks, or delivery mechanisms without rewriting business logic
MVC (Model-View-Controller)
The oldest and most widely recognized pattern. Separates an application into three components:
codeUser Action β βΌ Controller βββΊ Model (business logic + data) β β β Database βΌ View (renders response)
javascript-- Express MVC example -- Model: business logic and data access class UserModel { async findById(id) { return db.query("SELECT * FROM users WHERE id = $1", [id]); } async create(data) { const hash = await bcrypt.hash(data.password, 12); return db.query( "INSERT INTO users (name, email, password_hash) VALUES ($1, $2, $3) RETURNING *", [data.name, data.email, hash] ); } } -- Controller: handles HTTP, delegates to model, sends response class UserController { constructor(userModel) { this.userModel = userModel; } async getUser(req, res) { const user = await this.userModel.findById(req.params.id); if (!user) return res.status(404).json({ error: "Not found" }); res.json(user); } async createUser(req, res) { const user = await this.userModel.create(req.body); res.status(201).json(user); } } -- View: in REST APIs, this is the JSON serialization -- In server-rendered apps, this is the template
Problems with naive MVC: Models often become "fat models" containing unrelated logic. Controllers directly depend on database details. Testing requires a real database.
Layered (N-Tier) Architecture
Organizes code into horizontal layers, each only depending on the layer below:
codeβββββββββββββββββββββββββββ β Presentation Layer β HTTP controllers, CLI, GraphQL resolvers βββββββββββββββββββββββββββ€ β Application Layer β Use cases, orchestration, DTOs βββββββββββββββββββββββββββ€ β Domain Layer β Entities, business rules, domain services βββββββββββββββββββββββββββ€ β Infrastructure Layer β Database, email, file storage, HTTP clients βββββββββββββββββββββββββββ
Dependencies only flow downward. The domain layer has no dependencies on frameworks or databases.
codesrc/ βββ presentation/ β βββ controllers/ β βββ UserController.ts βββ application/ β βββ use-cases/ β βββ RegisterUser.ts β βββ GetUserProfile.ts βββ domain/ β βββ entities/ β β βββ User.ts β βββ repositories/ β β βββ UserRepository.ts (interface only) β βββ services/ β βββ PasswordHasher.ts (interface only) βββ infrastructure/ βββ database/ β βββ PostgresUserRepository.ts βββ services/ βββ BcryptPasswordHasher.ts
Clean Architecture
Robert C. Martin's Clean Architecture makes the dependency rule explicit and absolute: source code dependencies must point inward. Inner circles know nothing about outer circles.
codeβββββββββββββββββββββββββββββββ β Frameworks β Express, Next.js, Spring β βββββββββββββββββββββββ β β β Interface β β Controllers, Presenters β β Adapters β β β β βββββββββββββββ β β β β β Application β β β Use Cases β β β βββββββββ β β β β β β βDomain β β β β Entities, Business Rules β β β βββββββββ β β β β β βββββββββββββββ β β β βββββββββββββββββββββββ β βββββββββββββββββββββββββββββββ Dependencies point INWARD ONLY
typescript-- Domain entity: no framework dependencies class Order { private items: OrderItem[] = []; private status: OrderStatus = OrderStatus.PENDING; addItem(product: Product, quantity: number): void { if (quantity <= 0) throw new Error("Quantity must be positive"); this.items.push(new OrderItem(product, quantity)); } get total(): number { return this.items.reduce((sum, item) => sum + item.subtotal, 0); } confirm(): void { if (this.items.length === 0) throw new Error("Cannot confirm empty order"); this.status = OrderStatus.CONFIRMED; } } -- Use case: orchestrates domain objects, depends on interfaces interface OrderRepository { save(order: Order): Promise<void>; findById(id: string): Promise<Order | null>; } interface PaymentGateway { charge(amount: number, customerId: string): Promise<PaymentResult>; } class PlaceOrderUseCase { constructor( private orderRepo: OrderRepository, private paymentGateway: PaymentGateway, private eventBus: EventBus, ) {} async execute(command: PlaceOrderCommand): Promise<Order> { const order = new Order(command.customerId); command.items.forEach(item => order.addItem(item.product, item.quantity)); order.confirm(); const payment = await this.paymentGateway.charge(order.total, command.customerId); if (!payment.success) throw new PaymentFailedError(payment.reason); await this.orderRepo.save(order); this.eventBus.publish(new OrderPlacedEvent(order)); return order; } } -- Infrastructure: implements interfaces, depends on real libraries class PostgresOrderRepository implements OrderRepository { async save(order: Order): Promise<void> { await db.query("INSERT INTO orders ...", [order.id, order.total]); } async findById(id: string): Promise<Order | null> { ... } }
The PlaceOrderUseCase test needs no database, no HTTP server, no payment processor β just mock implementations of the interfaces.
Hexagonal Architecture (Ports and Adapters)
Alistair Cockburn's pattern describes the application as a hexagon with ports (interfaces) and adapters (implementations):
codeREST API Adapter β HTTP Port βββββ€ β βββββββββββββββββββ β β CLI Port βββΊ Application βββ Message Queue Port β (Core Logic) β β β βββββββββββββββββββ β DB Port βββββ€ β PostgreSQL Adapter
- Ports = interfaces defined by the application (what it needs)
- Adapters = implementations that plug into ports (REST, CLI, database, email, etc.)
The application core is completely isolated from its delivery mechanism. You can test it without HTTP, swap databases without changing business logic, add a CLI without modifying use cases.
CQRS (Command Query Responsibility Segregation)
Separate the model for reading from the model for writing:
codeWrite Side (Commands) Read Side (Queries) User sends command User requests data β β βΌ βΌ Command Handler Query Handler (validates, updates) (reads optimized view) β β βΌ βΌ Write Database ββeventsβββΊ Read Database (normalized) (denormalized, fast reads)
javascript-- Write side: handles commands, enforces business rules async function handlePlaceOrder(command) { const order = await orderRepo.findById(command.orderId); order.confirm(); await orderRepo.save(order); await eventBus.publish(new OrderConfirmedEvent(order)); } -- Read side: optimized for queries, no business logic async function getOrderSummary(orderId) { -- Reads from a denormalized view built from events return db.query( "SELECT * FROM order_summaries_view WHERE id = $1", [orderId] ); }
CQRS is powerful when read and write patterns differ significantly β complex writes, simple reads, or needing to scale reads independently.
Event Sourcing
Instead of storing the current state, store the sequence of events that led to it:
javascript-- Traditional: store current state UPDATE orders SET status = 'shipped', shipped_at = NOW() WHERE id = 42; -- Event Sourcing: store the event INSERT INTO order_events (order_id, type, data, occurred_at) VALUES (42, 'OrderShipped', 'carrier:FedEx,tracking:123', NOW()); -- Reconstruct state by replaying events function reconstructOrder(events) { return events.reduce((order, event) => { switch (event.type) { case 'OrderCreated': return { ...order, status: 'pending', items: event.data.items }; case 'OrderConfirmed': return { ...order, status: 'confirmed' }; case 'OrderShipped': return { ...order, status: 'shipped', tracking: event.data.tracking }; case 'OrderCancelled': return { ...order, status: 'cancelled' }; default: return order; } }, {}); }
Benefits: Complete audit trail, time-travel debugging, ability to rebuild any read model from history. Costs: More complex queries, storage grows unboundedly, requires snapshotting for performance.
Common Interview Questions
Q: What is the dependency inversion principle in the context of Clean Architecture?
High-level modules (use cases, business logic) should not depend on low-level modules (databases, HTTP frameworks). Both should depend on abstractions (interfaces). In Clean Architecture, the use case defines an interface (UserRepository) and the infrastructure layer implements it (PostgresUserRepository). The dependency arrow points inward β toward the domain β not outward toward the database.
Q: What is the difference between CQRS and Event Sourcing?
They are complementary but separate patterns. CQRS separates read and write models β you can use CQRS with a traditional relational database. Event Sourcing stores state as a sequence of events rather than current values β you can use Event Sourcing without CQRS. They are often used together because Event Sourcing naturally produces events that can update CQRS read models.
Q: When would you NOT use Clean Architecture?
For simple CRUD applications, Clean Architecture is over-engineering. The extra layers of abstraction add complexity without benefit when there is no complex business logic. Start with a simpler approach (MVC or layered) and evolve toward Clean Architecture only when the domain complexity justifies it.
Practice on Froquiz
Architecture and design pattern questions appear in senior developer and tech lead interviews. Explore our backend and system design quizzes on Froquiz to test your knowledge.
Summary
- MVC separates presentation, logic, and data β widely understood but prone to fat models
- Layered architecture organizes by horizontal concerns with dependencies flowing downward
- Clean Architecture enforces strict inward-only dependencies β domain logic has zero framework dependencies
- Hexagonal defines ports (interfaces) and adapters (implementations) β swap adapters without changing core logic
- CQRS separates read and write models β enables independent scaling and optimization
- Event Sourcing stores events instead of state β complete history, time-travel debugging
- Start simple; evolve to more complex patterns only when domain complexity justifies the overhead