Skip to main content

Architectural Patterns in JavaScript

Published: May 24, 2026 Updated: August 29, 2026 Larry Qu 6 min read

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.

Resources

Comments

👍 Was this article helpful?