Architectural patterns solve the problem of organizing code as applications grow. The right pattern depends on what you’re building — a server-side MVC app, a React SPA with complex state, or a large system that needs to be independently testable at each layer.
Model-View-Controller (MVC)
MVC splits an application into three roles: the Model holds data and business logic; the View renders the data; the Controller handles user input and coordinates between them. The key benefit is that Views can change without touching Models, and Models can be tested without any rendering.
The Observer pattern connects them — the Model notifies Views when data changes, without knowing what those Views do with the data:
class UserModel {
#users = [];
#observers = [];
subscribe(observer) { this.#observers.push(observer); }
notify() { this.#observers.forEach(obs => obs.update()); }
addUser(user) {
this.#users.push({ id: Date.now(), ...user });
this.notify();
}
getUsers() { return [...this.#users]; } // return copy to prevent direct mutation
}
class UserListView {
constructor(model) {
this.model = model;
model.subscribe(this);
}
update() {
const users = this.model.getUsers();
console.log('Users:', users.map(u => u.name).join(', '));
}
}
class UserController {
constructor(model) { this.model = model; }
createUser(name, email) {
if (!name || !email.includes('@')) {
throw new Error('Invalid user data');
}
this.model.addUser({ name, email });
}
}
const model = new UserModel();
const view = new UserListView(model);
const controller = new UserController(model);
controller.createUser('Alice', '[email protected]');
// Users: Alice
controller.createUser('Bob', '[email protected]');
// Users: Alice, Bob
When to use MVC: server-side apps (Express + template engines), traditional web apps where the server renders HTML, or simple SPAs. It falls apart when you have many views that need the same data — which is why Flux exists.
MVVM: Data Binding
MVVM (Model-View-ViewModel) introduces a ViewModel that acts as a translation layer between the Model and View. The View binds to ViewModel properties and the ViewModel updates automatically when the Model changes.
This is the pattern behind Vue.js, Angular, and Knockout.js. The key idea: Views don’t contain logic, they just bind to ViewModel properties:
class UserViewModel {
#model;
#observers = [];
constructor(model) {
this.#model = model;
}
subscribe(observer) { this.#observers.push(observer); }
notify() { this.#observers.forEach(obs => obs(this)); }
// Computed properties — transform model data for the view
get displayName() {
return `${this.#model.firstName} ${this.#model.lastName}`;
}
get emailLabel() {
return this.#model.email || '(no email set)';
}
get isValid() {
return !!this.#model.firstName && this.#model.email.includes('@');
}
// Commands — the View calls these; ViewModel validates and updates the model
updateEmail(email) {
if (!email.includes('@')) {
return { ok: false, error: 'Invalid email format' };
}
this.#model.email = email;
this.notify();
return { ok: true };
}
}
The View just reads ViewModel properties and calls ViewModel commands — no business logic in the View at all.
Flux: Unidirectional Data Flow
MVC breaks down when Views can directly update Models and vice versa, creating cycles that are hard to debug. Flux (the pattern behind Redux) enforces strict unidirectional data flow:
Action → Dispatcher → Store → View → (user interaction) → Action
Every state change goes through the same path. This makes debugging straightforward — every state change is an explicit action you can log.
// The store holds all state and handles actions
class Store {
#state = { users: [], loading: false, error: null };
#listeners = new Set();
subscribe(listener) {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener); // returns unsubscribe function
}
getState() { return { ...this.#state }; }
dispatch(action) {
this.#state = this.#reduce(this.#state, action);
this.#listeners.forEach(l => l(this.#state));
}
#reduce(state, action) {
switch (action.type) {
case 'USERS_LOADING':
return { ...state, loading: true, error: null };
case 'USERS_LOADED':
return { ...state, loading: false, users: action.payload };
case 'USERS_ERROR':
return { ...state, loading: false, error: action.payload };
case 'USER_ADDED':
return { ...state, users: [...state.users, action.payload] };
default:
return state;
}
}
}
// Action creators — functions that produce action objects
const actions = {
loadUsers: async (store) => {
store.dispatch({ type: 'USERS_LOADING' });
try {
const users = await fetch('/api/users').then(r => r.json());
store.dispatch({ type: 'USERS_LOADED', payload: users });
} catch (err) {
store.dispatch({ type: 'USERS_ERROR', payload: err.message });
}
},
addUser: (user) => ({ type: 'USER_ADDED', payload: user }),
};
const store = new Store();
// Any component can subscribe and get consistent state
const unsubscribe = store.subscribe(state => {
renderUserList(state.users);
if (state.loading) showSpinner();
if (state.error) showError(state.error);
});
actions.loadUsers(store);
Flux/Redux is most valuable in large SPAs where many components read the same state. For simple apps, it’s overkill — start with MVC or local component state and migrate to Flux when state management becomes painful.
Clean Architecture: Testable Layers
Clean Architecture organizes code into concentric layers: business rules at the center (no dependencies), use cases wrapping them, and infrastructure (databases, HTTP, frameworks) at the outside. Dependencies point inward only — your business logic never imports Express, Mongoose, or any framework.
// Domain layer — pure business logic, no imports from outer layers
class User {
constructor(id, name, email) {
if (!name || !email.includes('@')) {
throw new Error('Invalid user data');
}
this.id = id;
this.name = name;
this.email = email;
}
rename(newName) {
if (!newName) throw new Error('Name cannot be empty');
return new User(this.id, newName, this.email); // immutable
}
}
// Application layer — orchestrates domain objects and calls repository
class CreateUserUseCase {
constructor(userRepository, emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
async execute(name, email) {
const existing = await this.userRepository.findByEmail(email);
if (existing) throw new Error('Email already registered');
const user = new User(null, name, email);
const saved = await this.userRepository.save(user);
await this.emailService.sendWelcome(saved.email);
return saved;
}
}
// Infrastructure layer — implements the interfaces the use case depends on
class PostgresUserRepository {
constructor(db) { this.db = db; }
async findByEmail(email) {
return this.db.query('SELECT * FROM users WHERE email = $1', [email]);
}
async save(user) {
const [saved] = await this.db.query(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
[user.name, user.email]
);
return new User(saved.id, saved.name, saved.email);
}
}
// The controller (HTTP layer) wires everything together
class UserController {
constructor(createUserUseCase) {
this.createUserUseCase = createUserUseCase;
}
async handlePost(req, res) {
try {
const user = await this.createUserUseCase.execute(req.body.name, req.body.email);
res.status(201).json({ id: user.id, name: user.name });
} catch (err) {
const status = err.message.includes('already') ? 409 : 400;
res.status(status).json({ error: err.message });
}
}
}
The critical benefit: CreateUserUseCase can be tested with in-memory fakes for the repository and email service — no database, no HTTP, tests run in milliseconds. The domain layer has no dependencies at all.
Choosing a Pattern
| Pattern | Best for | Downside |
|---|---|---|
| MVC | Server-rendered apps, simple SPAs | Bidirectional data flow gets tangled |
| MVVM | Data-binding UIs (Vue, Angular) | More boilerplate than MVC |
| Flux/Redux | Large SPAs, many components sharing state | Overkill for simple apps |
| Clean Architecture | Large systems, domain-heavy business logic | Most upfront structure |
Start simple. Add architecture when the complexity justifies it — not before.
Comments