Software Architecture: MVC, CQRS, Event-Driven

Photo by Unsplash

Photo by Unsplash
Software architecture patterns are reusable solutions to recurring structural problems in software design. The right pattern can make your application easier to scale, test, and evolve. The wrong one can create unnecessary complexity that slows your team down for years. This post compares three of the most important patterns — MVC, CQRS, and Event-Driven Architecture — with concrete TypeScript examples to help you choose and combine them confidently.
Model-View-Controller is the most widely used architectural pattern in web development. It separates an application into three interconnected components: the Model (data and business logic), the View (presentation layer), and the Controller (request handling and coordination). MVC's longevity comes from its simplicity and broad familiarity — nearly every web framework supports it, and most developers understand it immediately.
Modern Next.js maps naturally to MVC concepts even without explicit MVC terminology. React Server Components act as Views, Route Handlers act as Controllers, and your data layer (Prisma models, service classes, repository functions) acts as the Model. The App Router's co-location of UI and data fetching in the same page component blurs the lines somewhat, but the separation of concerns remains.
// MVC in a Next.js App Router context
// Model — src/models/post.ts
export interface Post { id: string; title: string; body: string; authorId: string; }
export async function getPostById(id: string): Promise<Post | null> {
return db.post.findUnique({ where: { id } });
}
// Controller (Route Handler) — app/api/posts/[id]/route.ts
import { getPostById } from "@/models/post";
export async function GET(_: Request, { params }: { params: { id: string } }) {
const post = await getPostById(params.id);
if (!post) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(post);
}
// View — app/blog/[id]/page.tsx (React Server Component)
import { getPostById } from "@/models/post";
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await getPostById(params.id);
return <article><h1>{post?.title}</h1><p>{post?.body}</p></article>;
}In Next.js App Router, prefer React Server Components for data fetching over API route handlers. RSCs eliminate a network round-trip and reduce client-side JavaScript. Use Route Handlers only for client-initiated requests (form submissions, mutations from 'use client' components).
Command Query Responsibility Segregation separates the parts of your system that change state (Commands) from the parts that read state (Queries). This separation allows you to optimize each independently — a read model optimized for a specific UI view can be a completely different shape from the write model that enforces business rules. CQRS is powerful but adds complexity; it's most justified in systems where reads and writes have fundamentally different scaling needs.
A Command represents intent to change state: PlaceOrderCommand, CancelSubscriptionCommand. It should be validated and handled by a dedicated handler. A Query requests a projection of state optimized for a specific use case: GetOrderSummaryQuery returns a flat DTO designed for the orders list UI. The read model can be a SQL view, a Redis cache, or a separate read-optimized database — CQRS doesn't prescribe the implementation.
// CQRS: separate read models from write models
// Write side — Command
interface PlaceOrderCommand {
userId: string;
items: { productId: string; qty: number }[];
}
async function handlePlaceOrder(cmd: PlaceOrderCommand): Promise<string> {
const orderId = generateId();
await db.orders.insert({ id: orderId, ...cmd, status: "pending" });
await eventBus.publish("order.placed", { orderId, ...cmd });
return orderId;
}
// Read side — Query (optimized read model, possibly from a separate DB/view)
interface OrderSummaryDTO {
orderId: string;
customerName: string;
itemCount: number;
total: number;
status: string;
}
async function getOrderSummary(orderId: string): Promise<OrderSummaryDTO> {
return readDb.orderSummaries.findOne({ where: { orderId } });
}CQRS is often paired with Event Sourcing, where the write model stores a sequence of events (OrderPlaced, ItemShipped, OrderCancelled) rather than the current state. The read model is built by replaying these events. This gives you a complete audit trail, the ability to replay history to build new read models, and a natural integration with Event-Driven Architecture. The trade-off is significantly increased complexity — don't adopt Event Sourcing without a compelling need.
Event-Driven Architecture (EDA) is a pattern where components communicate by emitting and consuming events asynchronously through a message broker (Kafka, RabbitMQ, AWS EventBridge). EDA decouples producers from consumers — the order service publishes an 'order.placed' event without knowing or caring which services consume it. This enables extremely loose coupling, but requires careful thought about event schema evolution, ordering guarantees, and error handling.
Events are facts that happened in the past ('order was placed'). Commands are requests for something to happen ('place this order'). Queries ask for current state. Events are broadcast — any number of consumers can react. Commands are directed — one handler is responsible. Understanding this distinction prevents common design mistakes like naming events as commands or treating queries as events.
EDA introduces eventual consistency — when the order service publishes an event and the inventory service consumes it, there's a window where the inventory is not yet updated. This is acceptable for many use cases but catastrophic for others (e.g., charging a customer before confirming inventory). Always analyze your consistency requirements before adopting EDA, and implement compensating transactions (sagas) for flows that require strong consistency.
When an operation spans multiple services — place order, reserve inventory, charge payment — you can't use a database transaction across service boundaries. The Saga pattern orchestrates a series of local transactions, each publishing an event that triggers the next step. If any step fails, compensating transactions roll back the previous steps. Choreography sagas are decentralized (each service reacts to events); orchestration sagas use a central coordinator.
Real applications rarely use a single pattern in isolation. A common combination: MVC for the request/response layer, CQRS to separate read and write paths for complex domains, and EDA to integrate across service boundaries and handle async workflows. The key is understanding what problem each pattern solves and applying it surgically to the parts of your system where the trade-off makes sense.
Start with MVC. Add CQRS when your read and write models diverge significantly or when read performance is a bottleneck. Add EDA when you need to decouple services, handle async workflows (email notifications, background processing), or integrate with external systems. Add Event Sourcing only when you genuinely need an audit trail or the ability to replay history — it is the highest-complexity pattern and should be adopted last and with care.
Document significant architectural decisions using Architecture Decision Records (ADRs). An ADR captures the context (why are we making this decision?), the options considered, the decision made, and the consequences (positive and negative). ADRs live in your repository alongside the code and provide an invaluable historical record of why your system is the way it is — essential context for onboarding new engineers and revisiting past decisions.
Use the Structurizr DSL or C4 model diagrams to document your architecture at multiple levels of abstraction (system, container, component). Automated diagrams-as-code prevent documentation drift and give new team members a navigable map of your system.
Every architectural pattern exists to solve a specific problem. Match the pattern to the problem, not the other way around. MVC solves the separation of presentation, data, and control logic. CQRS solves the impedance mismatch between optimized reads and writes. EDA solves the coupling between components that need to react to each other's state changes. When in doubt, start simpler and evolve — architectural complexity is far easier to add than to remove.
Key architecture concepts in this post include MVC, CQRS, Event Sourcing, EDA, Saga, and eventual consistency.